Files
hermes-desktop/tests/client/use-voice-dialogue.test.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

387 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { createVoiceEventSequencer } from '../../packages/client/src/utils/voiceDialogueEvents'
import { useVoiceDialogue } from '../../packages/client/src/composables/useVoiceDialogue'
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function localStorageSnapshot(): string {
return JSON.stringify(Object.fromEntries(
Array.from({ length: localStorage.length }, (_, index) => {
const key = localStorage.key(index) || ''
return [key, localStorage.getItem(key)]
}),
))
}
describe('createVoiceEventSequencer', () => {
it('emits monotonic seq/id pairs and preserves explicit timestamps', () => {
const nextEvent = createVoiceEventSequencer('session-1')
const first = nextEvent('capture.started', { captureId: 'capture-1', timestamp: '2026-06-06T00:00:00.000Z' })
const second = nextEvent('turn.started', { turnId: 'turn-1' })
expect(first.seq).toBe(1)
expect(first.id).toBe('session-1:1')
expect(first.timestamp).toBe('2026-06-06T00:00:00.000Z')
expect(second.seq).toBe(2)
expect(second.id).toBe('session-1:2')
expect(second.timestamp).toMatch(/Z$/)
})
})
describe('useVoiceDialogue', () => {
it('transcribes recorded audio and sends the normalized transcript', async () => {
const audio = new Blob(['voice'])
const sendMessage = vi.fn()
const transcribe = vi.fn().mockResolvedValue({ text: ' hello hermes ' })
const dialogue = useVoiceDialogue({ transcribe, sendMessage })
const { captureId } = await dialogue.beginCapture()
await dialogue.transcribeAndSend(captureId, audio)
expect(transcribe).toHaveBeenCalledWith(audio)
expect(sendMessage).toHaveBeenCalledTimes(1)
expect(sendMessage).toHaveBeenCalledWith('hello hermes')
expect(dialogue.transcript.value).toBe('')
expect(dialogue.status.value).toBe('idle')
expect(dialogue.activeCaptureId.value).toBeNull()
})
it('does not persist backend STT audio blobs or transcripts to localStorage', async () => {
localStorage.clear()
const transcriptSentinel = 'backend transcript sentinel'
const audioSentinel = 'backend audio sentinel'
const sendMessage = vi.fn()
const audio = new Blob([audioSentinel], { type: 'audio/webm' })
const dialogue = useVoiceDialogue({
transcribe: vi.fn().mockResolvedValue({ text: transcriptSentinel }),
sendMessage,
})
const { captureId } = await dialogue.beginCapture()
await dialogue.transcribeAndSend(captureId, audio)
const stored = localStorageSnapshot()
expect(sendMessage).toHaveBeenCalledWith(transcriptSentinel)
expect(stored).not.toContain(transcriptSentinel)
expect(stored).not.toContain(audioSentinel)
})
it('drops stale transcript callbacks by captureId', async () => {
const sendMessage = vi.fn()
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: 'new command' }),
})
const first = await dialogue.beginCapture()
const second = await dialogue.beginCapture()
await dialogue.commitTranscript(first.captureId, 'old command')
await dialogue.commitTranscript(second.captureId, 'new command')
expect(sendMessage).toHaveBeenCalledTimes(1)
expect(sendMessage).toHaveBeenCalledWith('new command')
})
it('does not persist browser transcripts to localStorage', async () => {
localStorage.clear()
const transcriptSentinel = 'browser transcript sentinel'
const sendMessage = vi.fn()
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const { captureId } = await dialogue.beginCapture()
await dialogue.commitTranscript(captureId, transcriptSentinel)
const stored = localStorageSnapshot()
expect(sendMessage).toHaveBeenCalledWith(transcriptSentinel)
expect(stored).not.toContain(transcriptSentinel)
})
it('emits turn and capture events in sequence', async () => {
const dialogue = useVoiceDialogue({
sendMessage: vi.fn(),
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const { captureId } = await dialogue.beginCapture()
await dialogue.commitTranscript(captureId, 'hello world')
const relevantTypes = dialogue.events.value
.map(event => event.type)
.filter(type => ['capture.started', 'transcript.done', 'turn.started', 'turn.ended'].includes(type))
expect(relevantTypes).toEqual([
'capture.started',
'transcript.done',
'turn.started',
'turn.ended',
])
})
it('retains only the last 50 events', () => {
const dialogue = useVoiceDialogue({
sendMessage: vi.fn(),
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
for (let index = 0; index < 55; index += 1) {
dialogue.markOutputStarted({ index })
}
expect(dialogue.events.value).toHaveLength(50)
const seqs = dialogue.events.value.map(event => event.seq)
expect(seqs).toEqual([...seqs].sort((left, right) => left - right))
expect(seqs[0]).toBe(seqs[seqs.length - 1] - 49)
})
it('drops stale transcribe results when capture changes before transcribe resolves', async () => {
const deferred = createDeferred<{ text: string }>()
const sendMessage = vi.fn()
const transcribe = vi.fn().mockReturnValue(deferred.promise)
const dialogue = useVoiceDialogue({ transcribe, sendMessage })
const first = await dialogue.beginCapture()
const pending = dialogue.transcribeAndSend(first.captureId, new Blob(['old']))
const second = await dialogue.beginCapture()
deferred.resolve({ text: 'stale transcript' })
await pending
expect(sendMessage).not.toHaveBeenCalled()
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
expect(dialogue.status.value).toBe('capturing')
})
it('does not emit stale turn completion when active capture changes during send', async () => {
const deferred = createDeferred<void>()
const sendMessage = vi.fn().mockReturnValue(deferred.promise)
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const first = await dialogue.beginCapture()
const pendingCommit = dialogue.commitTranscript(first.captureId, 'old')
const firstTurnStarted = dialogue.events.value.find(
(event: { type: string; captureId?: string; turnId?: string | null }) =>
event.type === 'turn.started' && event.captureId === first.captureId,
)
expect(firstTurnStarted?.turnId).toBeTruthy()
const second = await dialogue.beginCapture()
deferred.resolve()
await pendingCommit
expect(sendMessage).toHaveBeenCalledTimes(1)
expect(sendMessage).toHaveBeenCalledWith('old')
expect(
dialogue.events.value.some(
event => event.type === 'turn.ended' && event.turnId === firstTurnStarted?.turnId,
),
).toBe(false)
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
expect(dialogue.status.value).toBe('capturing')
})
it('suppresses stale transcribe rejections after a new capture begins', async () => {
const deferred = createDeferred<{ text: string }>()
const sendMessage = vi.fn()
const transcribe = vi.fn().mockReturnValue(deferred.promise)
const dialogue = useVoiceDialogue({ transcribe, sendMessage })
const first = await dialogue.beginCapture()
const pending = dialogue.transcribeAndSend(first.captureId, new Blob(['old']))
const second = await dialogue.beginCapture()
deferred.reject(new Error('transcribe failed'))
await expect(pending).resolves.toBeUndefined()
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
expect(dialogue.status.value).toBe('capturing')
expect(dialogue.error.value).toBeNull()
expect(
dialogue.events.value.some(
event => event.type === 'session.error' && event.captureId === first.captureId,
),
).toBe(false)
})
it('suppresses stale send rejections after a new capture begins', async () => {
const deferred = createDeferred<void>()
const sendMessage = vi.fn().mockReturnValue(deferred.promise)
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const first = await dialogue.beginCapture()
const pending = dialogue.commitTranscript(first.captureId, 'hello')
const firstTurnStarted = dialogue.events.value.find(
(event: { type: string; captureId?: string; turnId?: string | null }) =>
event.type === 'turn.started' && event.captureId === first.captureId,
)
expect(firstTurnStarted?.turnId).toBeTruthy()
const second = await dialogue.beginCapture()
deferred.reject(new Error('send failed'))
await expect(pending).resolves.toBeUndefined()
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
expect(dialogue.status.value).toBe('capturing')
expect(dialogue.error.value).toBeNull()
expect(
dialogue.events.value.some(
event => event.type === 'session.error' && event.captureId === first.captureId,
),
).toBe(false)
expect(
dialogue.events.value.some(
event => event.type === 'turn.ended' && event.turnId === firstTurnStarted?.turnId,
),
).toBe(false)
})
it('emits capture.cancelled before starting a superseding capture', async () => {
const dialogue = useVoiceDialogue({
sendMessage: vi.fn(),
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const first = await dialogue.beginCapture()
const second = await dialogue.beginCapture()
const cancelledIndex = dialogue.events.value.findIndex(
event => event.type === 'capture.cancelled' && event.captureId === first.captureId,
)
const startedIndex = dialogue.events.value.findIndex(
event => event.type === 'capture.started' && event.captureId === second.captureId,
)
expect(cancelledIndex).toBeGreaterThan(-1)
expect(startedIndex).toBeGreaterThan(-1)
expect(cancelledIndex).toBeLessThan(startedIndex)
})
it('emits terminal cancellation events for an active turn before starting a superseding capture', async () => {
const deferred = createDeferred<void>()
const sendMessage = vi.fn().mockReturnValue(deferred.promise)
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const first = await dialogue.beginCapture()
const pending = dialogue.commitTranscript(first.captureId, 'hello')
const firstTurnStarted = dialogue.events.value.find(
(event: { type: string; captureId?: string; turnId?: string | null }) =>
event.type === 'turn.started' && event.captureId === first.captureId,
)
expect(firstTurnStarted?.turnId).toBeTruthy()
const second = await dialogue.beginCapture()
const turnCancelledIndex = dialogue.events.value.findIndex(
event => event.type === 'turn.cancelled' && event.turnId === firstTurnStarted?.turnId,
)
const captureCancelledIndex = dialogue.events.value.findIndex(
event => event.type === 'capture.cancelled' && event.captureId === first.captureId,
)
const secondStartedIndex = dialogue.events.value.findIndex(
event => event.type === 'capture.started' && event.captureId === second.captureId,
)
expect(turnCancelledIndex).toBeGreaterThan(-1)
expect(captureCancelledIndex).toBeGreaterThan(-1)
expect(secondStartedIndex).toBeGreaterThan(-1)
expect(turnCancelledIndex).toBeLessThan(secondStartedIndex)
expect(captureCancelledIndex).toBeLessThan(secondStartedIndex)
deferred.resolve()
await pending
expect(
dialogue.events.value.some(
event => event.type === 'turn.ended' && event.turnId === firstTurnStarted?.turnId,
),
).toBe(false)
})
it('empty transcript cancels capture and does not send', async () => {
const sendMessage = vi.fn()
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: '' }),
})
const { captureId } = await dialogue.beginCapture()
await dialogue.commitTranscript(captureId, ' \n ')
expect(sendMessage).not.toHaveBeenCalled()
expect(dialogue.activeCaptureId.value).toBeNull()
expect(dialogue.status.value).toBe('idle')
expect(dialogue.events.value.map(event => event.type)).toContain('capture.cancelled')
})
it('beginCapture calls stopOutputAudio for the barge-in boundary', async () => {
const stopOutputAudio = vi.fn()
const dialogue = useVoiceDialogue({
sendMessage: vi.fn(),
transcribe: vi.fn().mockResolvedValue({ text: '' }),
stopOutputAudio,
})
await dialogue.beginCapture()
expect(stopOutputAudio).toHaveBeenCalledOnce()
})
it('continues capture when stopOutputAudio rejects', async () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const stopOutputAudio = vi.fn().mockRejectedValue(new Error('stop failed'))
const dialogue = useVoiceDialogue({
sendMessage: vi.fn(),
transcribe: vi.fn().mockResolvedValue({ text: '' }),
stopOutputAudio,
})
const started = await dialogue.beginCapture()
expect(stopOutputAudio).toHaveBeenCalledOnce()
expect(started.captureId).toBeTruthy()
expect(dialogue.status.value).toBe('capturing')
expect(dialogue.activeCaptureId.value).toBe(started.captureId)
expect(dialogue.error.value).toBeNull()
expect(dialogue.events.value.at(-1)?.type).toBe('capture.started')
expect(consoleWarn).toHaveBeenCalledWith('[useVoiceDialogue] Failed to stop output audio before capture:', expect.any(Error))
})
it('sets error state and emits session.error when sendMessage fails', async () => {
const sendMessage = vi.fn().mockRejectedValue(new Error('send failed'))
const dialogue = useVoiceDialogue({
sendMessage,
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
})
const { captureId } = await dialogue.beginCapture()
await expect(dialogue.commitTranscript(captureId, 'hello')).rejects.toThrow('send failed')
expect(dialogue.status.value).toBe('error')
expect(dialogue.error.value?.message).toBe('send failed')
expect(dialogue.events.value.at(-1)?.type).toBe('session.error')
})
})