Files
hermes-web-ui/tests/client/use-browser-speech-recognition.test.ts
ekko 11ed7fc182 Merge voice provider stack into main
Bring #1397-#1400 voice provider stack into main after #1396 landed directly on main.
2026-06-08 10:49:39 +08:00

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.')
})
})