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.
262 lines
8.5 KiB
TypeScript
262 lines
8.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { useBrowserSpeechRecognition } from '../../packages/client/src/composables/useBrowserSpeechRecognition'
|
|
|
|
class FakeSpeechRecognition {
|
|
static instances: FakeSpeechRecognition[] = []
|
|
|
|
lang = ''
|
|
interimResults = false
|
|
continuous = false
|
|
onresult: ((event: any) => void) | null = null
|
|
onerror: ((event: any) => void) | null = null
|
|
onend: (() => void) | null = null
|
|
started = false
|
|
stopCalls = 0
|
|
abortCalls = 0
|
|
|
|
constructor() {
|
|
FakeSpeechRecognition.instances.push(this)
|
|
}
|
|
|
|
start() {
|
|
this.started = true
|
|
}
|
|
|
|
stop() {
|
|
this.stopCalls += 1
|
|
}
|
|
|
|
abort() {
|
|
this.abortCalls += 1
|
|
}
|
|
|
|
emitResult(results: Array<{ transcript: string; isFinal: boolean }>, resultIndex = 0) {
|
|
this.onresult?.({
|
|
resultIndex,
|
|
results: results.map(result => ({
|
|
isFinal: result.isFinal,
|
|
0: { transcript: result.transcript },
|
|
length: 1,
|
|
})),
|
|
})
|
|
}
|
|
|
|
emitError(error: string) {
|
|
this.onerror?.({ error })
|
|
}
|
|
|
|
emitEnd() {
|
|
this.onend?.()
|
|
}
|
|
}
|
|
|
|
function installSpeechRecognition(globalName: 'SpeechRecognition' | 'webkitSpeechRecognition' = 'SpeechRecognition') {
|
|
const browserWindow = window as Window & {
|
|
SpeechRecognition?: typeof FakeSpeechRecognition
|
|
webkitSpeechRecognition?: typeof FakeSpeechRecognition
|
|
}
|
|
|
|
delete browserWindow.SpeechRecognition
|
|
delete browserWindow.webkitSpeechRecognition
|
|
browserWindow[globalName] = FakeSpeechRecognition
|
|
}
|
|
|
|
function removeSpeechRecognition() {
|
|
const browserWindow = window as Window & {
|
|
SpeechRecognition?: typeof FakeSpeechRecognition
|
|
webkitSpeechRecognition?: typeof FakeSpeechRecognition
|
|
}
|
|
|
|
delete browserWindow.SpeechRecognition
|
|
delete browserWindow.webkitSpeechRecognition
|
|
FakeSpeechRecognition.instances = []
|
|
}
|
|
|
|
describe('useBrowserSpeechRecognition', () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers()
|
|
removeSpeechRecognition()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
removeSpeechRecognition()
|
|
})
|
|
|
|
it('detects SpeechRecognition and webkitSpeechRecognition availability', () => {
|
|
installSpeechRecognition('SpeechRecognition')
|
|
expect(useBrowserSpeechRecognition().isSupported.value).toBe(true)
|
|
|
|
removeSpeechRecognition()
|
|
installSpeechRecognition('webkitSpeechRecognition')
|
|
expect(useBrowserSpeechRecognition().isSupported.value).toBe(true)
|
|
|
|
removeSpeechRecognition()
|
|
expect(useBrowserSpeechRecognition().isSupported.value).toBe(false)
|
|
})
|
|
|
|
it('start creates a recognizer, applies language settings, and enters listening state', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en-US' })
|
|
|
|
const instance = FakeSpeechRecognition.instances[0]
|
|
expect(instance).toBeDefined()
|
|
expect(instance.lang).toBe('en-US')
|
|
expect(instance.interimResults).toBe(true)
|
|
expect(instance.continuous).toBe(true)
|
|
expect(instance.started).toBe(true)
|
|
expect(recognition.status.value).toBe('listening')
|
|
expect(recognition.error.value).toBeNull()
|
|
})
|
|
|
|
it('records interim and final transcripts from onresult callbacks', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en' })
|
|
const instance = FakeSpeechRecognition.instances[0]
|
|
|
|
instance.emitResult([{ transcript: ' hello world ', isFinal: false }])
|
|
expect(recognition.partialTranscript.value).toBe('hello world')
|
|
expect(recognition.transcript.value).toBe('')
|
|
|
|
instance.emitResult([{ transcript: ' hello world ', isFinal: true }])
|
|
expect(recognition.transcript.value).toBe('hello world')
|
|
expect(recognition.partialTranscript.value).toBe('')
|
|
})
|
|
|
|
it('stop waits for final result and onend, then returns normalized transcript', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en' })
|
|
const instance = FakeSpeechRecognition.instances[0]
|
|
const stopPromise = recognition.stop()
|
|
|
|
expect(instance.stopCalls).toBe(1)
|
|
expect(recognition.status.value).toBe('stopping')
|
|
|
|
instance.emitResult([{ transcript: ' hello hermes ', isFinal: true }])
|
|
instance.emitEnd()
|
|
|
|
await expect(stopPromise).resolves.toBe('hello hermes')
|
|
expect(recognition.status.value).toBe('idle')
|
|
})
|
|
|
|
it('stop promotes the latest interim transcript when recognition ends without a final result', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en' })
|
|
const instance = FakeSpeechRecognition.instances[0]
|
|
|
|
instance.emitResult([{ transcript: ' hello from interim ', isFinal: false }])
|
|
|
|
const stopPromise = recognition.stop()
|
|
|
|
expect(instance.stopCalls).toBe(1)
|
|
instance.emitEnd()
|
|
|
|
await expect(stopPromise).resolves.toBe('hello from interim')
|
|
expect(recognition.status.value).toBe('idle')
|
|
})
|
|
|
|
it('stop timeout falls back to the latest interim transcript when no final result arrives', async () => {
|
|
vi.useFakeTimers()
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en' })
|
|
const instance = FakeSpeechRecognition.instances[0]
|
|
instance.emitResult([{ transcript: ' hello timeout fallback ', isFinal: false }])
|
|
|
|
const stopPromise = recognition.stop()
|
|
|
|
expect(instance.stopCalls).toBe(1)
|
|
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
await expect(stopPromise).resolves.toBe('hello timeout fallback')
|
|
expect(recognition.status.value).toBe('idle')
|
|
})
|
|
|
|
it('restarts recognition after a natural browser end while still listening', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en-US' })
|
|
const first = FakeSpeechRecognition.instances[0]
|
|
first.emitResult([{ transcript: 'first sentence', isFinal: true }])
|
|
first.emitEnd()
|
|
|
|
expect(recognition.status.value).toBe('listening')
|
|
expect(recognition.transcript.value).toBe('first sentence')
|
|
expect(FakeSpeechRecognition.instances).toHaveLength(2)
|
|
const second = FakeSpeechRecognition.instances[1]
|
|
expect(second.lang).toBe('en-US')
|
|
expect(second.continuous).toBe(true)
|
|
expect(second.started).toBe(true)
|
|
|
|
second.emitResult([{ transcript: 'second sentence', isFinal: true }])
|
|
const stopPromise = recognition.stop()
|
|
second.emitEnd()
|
|
|
|
await expect(stopPromise).resolves.toBe('first sentence second sentence')
|
|
expect(recognition.status.value).toBe('idle')
|
|
})
|
|
|
|
it('cancel aborts the active recognizer, clears state, and ignores stale final callbacks', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en' })
|
|
const instance = FakeSpeechRecognition.instances[0]
|
|
|
|
recognition.cancel()
|
|
|
|
expect(instance.abortCalls).toBe(1)
|
|
expect(recognition.status.value).toBe('idle')
|
|
expect(recognition.transcript.value).toBe('')
|
|
expect(recognition.partialTranscript.value).toBe('')
|
|
|
|
instance.emitResult([{ transcript: 'stale result', isFinal: true }])
|
|
instance.emitEnd()
|
|
|
|
expect(recognition.transcript.value).toBe('')
|
|
expect(recognition.partialTranscript.value).toBe('')
|
|
})
|
|
|
|
it('ignores stale callbacks from older recognition sessions', async () => {
|
|
installSpeechRecognition()
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await recognition.start({ language: 'en' })
|
|
const first = FakeSpeechRecognition.instances[0]
|
|
|
|
await recognition.start({ language: 'fr' })
|
|
const second = FakeSpeechRecognition.instances[1]
|
|
|
|
first.emitResult([{ transcript: 'old transcript', isFinal: true }])
|
|
first.emitEnd()
|
|
|
|
expect(recognition.transcript.value).toBe('')
|
|
expect(recognition.status.value).toBe('listening')
|
|
|
|
second.emitResult([{ transcript: 'bonjour', isFinal: false }])
|
|
expect(recognition.partialTranscript.value).toBe('bonjour')
|
|
})
|
|
|
|
it('reports a clear unsupported error when the browser API is unavailable', async () => {
|
|
const recognition = useBrowserSpeechRecognition()
|
|
|
|
await expect(recognition.start({ language: 'en' })).rejects.toThrow(
|
|
'Browser speech recognition is not supported in this browser.',
|
|
)
|
|
expect(recognition.status.value).toBe('error')
|
|
expect(recognition.error.value?.message).toBe('Browser speech recognition is not supported in this browser.')
|
|
})
|
|
})
|