Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
194 lines
5.5 KiB
TypeScript
194 lines
5.5 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
|
|
|
declare global {
|
|
interface Window {
|
|
__PW_FAKE_VOICE_CAPTURE__?: {
|
|
getUserMediaCalls: number
|
|
recorderState: string
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForRun(page: Page, index = 0) {
|
|
const handle = await page.waitForFunction((runIndex) => {
|
|
const state = (window as any).__PW_CHAT_SOCKET__
|
|
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
|
|
const run = runs[runIndex]
|
|
return run ? run.payload : null
|
|
}, index)
|
|
return handle.jsonValue() as Promise<any>
|
|
}
|
|
|
|
async function installMockVoiceCapture(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const state = {
|
|
getUserMediaCalls: 0,
|
|
recorderState: 'inactive',
|
|
}
|
|
|
|
Object.defineProperty(window, '__PW_FAKE_VOICE_CAPTURE__', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: state,
|
|
})
|
|
|
|
class FakeMediaRecorder {
|
|
static isTypeSupported() {
|
|
return true
|
|
}
|
|
|
|
stream: unknown
|
|
mimeType: string
|
|
state = 'inactive'
|
|
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
|
onerror: ((event: unknown) => void) | null = null
|
|
onstop: (() => void) | null = null
|
|
|
|
constructor(stream: unknown, options: { mimeType?: string } = {}) {
|
|
this.stream = stream
|
|
this.mimeType = options.mimeType || 'audio/webm'
|
|
}
|
|
|
|
start() {
|
|
this.state = 'recording'
|
|
state.recorderState = 'recording'
|
|
}
|
|
|
|
stop() {
|
|
if (this.state === 'inactive') {
|
|
return
|
|
}
|
|
|
|
this.state = 'inactive'
|
|
state.recorderState = 'inactive'
|
|
const blob = new Blob(['fake-voice-audio'], { type: this.mimeType })
|
|
setTimeout(() => {
|
|
if (this.ondataavailable) {
|
|
this.ondataavailable({ data: blob })
|
|
}
|
|
if (this.onstop) {
|
|
this.onstop()
|
|
}
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
const fakeTrack = {
|
|
kind: 'audio',
|
|
enabled: true,
|
|
readyState: 'live',
|
|
stop() {},
|
|
}
|
|
|
|
const fakeStream = {
|
|
active: true,
|
|
getTracks: () => [fakeTrack],
|
|
getAudioTracks: () => [fakeTrack],
|
|
getVideoTracks: () => [],
|
|
}
|
|
|
|
Object.defineProperty(window, 'MediaRecorder', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: FakeMediaRecorder,
|
|
})
|
|
Object.defineProperty(globalThis, 'MediaRecorder', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: FakeMediaRecorder,
|
|
})
|
|
|
|
const mediaDevices = navigator.mediaDevices || {}
|
|
Object.defineProperty(mediaDevices, 'getUserMedia', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: async () => {
|
|
state.getUserMediaCalls += 1
|
|
return fakeStream
|
|
},
|
|
})
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: mediaDevices,
|
|
})
|
|
})
|
|
}
|
|
|
|
test('records, transcribes, stages editable text, then sends through the real chat UI', async ({ page }) => {
|
|
await installMockVoiceCapture(page)
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem('hermes-stt-settings-v1', JSON.stringify({
|
|
provider: 'openai',
|
|
openaiModel: 'gpt-4o-transcribe',
|
|
openaiLanguage: '',
|
|
openaiPrompt: '',
|
|
customBaseUrl: '',
|
|
customModel: 'gpt-4o-transcribe',
|
|
customLanguage: '',
|
|
customPrompt: '',
|
|
}))
|
|
})
|
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
|
const api = await mockHermesApi(page)
|
|
await mockChatSocket(page)
|
|
|
|
let transcriptionRequests = 0
|
|
await page.route('**/api/hermes/stt/transcribe', async (route) => {
|
|
transcriptionRequests += 1
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ text: 'hello by voice', provider: 'openai' }),
|
|
})
|
|
})
|
|
|
|
await page.goto('/#/hermes/chat')
|
|
await page.evaluate(() => {
|
|
const state = window.__PW_FAKE_VOICE_CAPTURE__
|
|
if (!state) {
|
|
return
|
|
}
|
|
|
|
const FakeMediaRecorder = window.MediaRecorder
|
|
globalThis.MediaRecorder = FakeMediaRecorder
|
|
|
|
if (navigator.mediaDevices) {
|
|
navigator.mediaDevices.getUserMedia = async () => {
|
|
state.getUserMediaCalls += 1
|
|
const fakeMediaStream: any = {
|
|
active: true,
|
|
getTracks: () => [{ kind: 'audio', enabled: true, readyState: 'live', stop() {} }],
|
|
getAudioTracks: () => [{ kind: 'audio', enabled: true, readyState: 'live', stop() {} }],
|
|
getVideoTracks: () => [],
|
|
}
|
|
return fakeMediaStream
|
|
}
|
|
}
|
|
})
|
|
expect(await page.evaluate(() => window.__PW_FAKE_VOICE_CAPTURE__)).toEqual({
|
|
getUserMediaCalls: 0,
|
|
recorderState: 'inactive',
|
|
})
|
|
|
|
const toggle = page.getByTestId('voice-record-toggle')
|
|
await expect(toggle).toBeVisible()
|
|
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
|
|
await toggle.click()
|
|
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
|
await page.waitForFunction(() => window.__PW_FAKE_VOICE_CAPTURE__?.recorderState === 'recording')
|
|
|
|
await toggle.click()
|
|
|
|
await expect(page.locator('textarea')).toHaveValue('hello by voice')
|
|
expect(transcriptionRequests).toBe(1)
|
|
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
|
|
await page.getByRole('button', { name: /send/i }).click()
|
|
const run = await waitForRun(page)
|
|
expect(run.input).toBe('hello by voice')
|
|
await expect(page.locator('p').filter({ hasText: /^hello by voice$/ })).toBeVisible()
|
|
expect(api.unexpectedRequests).toEqual([])
|
|
})
|