Files
hermes-ui/tests/client/use-browser-speech-recognition.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
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.
2026-10-16 10:34:35 +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.')
})
})