Files
hermes-ui/tests/client/voice-dialogue-controls.test.ts
T
toor effc29085c Phase 006: migrate the chat page from naive-ui to Arco Design Vue
Swaps the rendering layer only. Chat store logic, keyboard handling, and the
/chat-run Socket.IO contract are untouched, so a mixed UI cannot hide a chat
regression behind a library swap.

Arco's dropdowns take their items from a #content slot of a-doption children
rather than a droplist prop, and Arco Triggers cannot be nested, so the two
toolbar menus lost their a-tooltip in favour of a native title. Both rules are
recorded in docs/arco-mapping.md, and tests/e2e/chat-input-dropdown.spec.ts plus
tests/e2e/chat-rendering-smoke.spec.ts keep the menus, the streaming markdown
rendering, and the no-Arco-inside-the-bubble red line from regressing silently.
2026-10-17 22:17:32 +08:00

780 lines
29 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { defineComponent } from 'vue'
import ArcoVue from '@arco-design/web-vue'
import { useChatStore } from '@/stores/hermes/chat'
import VoiceDialogueControls from '@/components/hermes/chat/VoiceDialogueControls.vue'
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
import VoiceTranscriptOverlay from '@/components/hermes/chat/VoiceTranscriptOverlay.vue'
const {
micStartMock,
micStopMock,
micCancelMock,
transcribeSpeechMock,
browserStartMock,
browserStopMock,
browserCancelMock,
browserClearErrorMock,
speechStopMock,
micRecorderState,
browserRecognitionStatus,
browserRecognitionTranscript,
browserRecognitionPartialTranscript,
browserRecognitionError,
browserRecognitionIsSupported,
useVoiceDialogueOverride,
} = vi.hoisted(() => ({
micStartMock: vi.fn(),
micStopMock: vi.fn(),
micCancelMock: vi.fn(),
transcribeSpeechMock: vi.fn(),
browserStartMock: vi.fn(),
browserStopMock: vi.fn(),
browserCancelMock: vi.fn(),
browserClearErrorMock: vi.fn(),
speechStopMock: vi.fn(),
micRecorderState: {
value: {
status: 'idle' as 'idle' | 'requesting' | 'recording' | 'stopping' | 'error',
error: null as Error | null,
startedAt: null as number | null,
mimeType: 'audio/webm' as string | null,
},
},
browserRecognitionStatus: { value: 'idle' as 'idle' | 'listening' | 'stopping' | 'error' },
browserRecognitionTranscript: { value: '' },
browserRecognitionPartialTranscript: { value: '' },
browserRecognitionError: { value: null as Error | null },
browserRecognitionIsSupported: { value: true },
useVoiceDialogueOverride: {
value: null as null | ((deps: unknown) => unknown),
},
}))
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 }
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (!params) return key
return `${key} ${Object.values(params).join(' ')}`
},
}),
}))
// Arco's imperative message API needs a live DOM; the rest renders for real.
vi.mock('@arco-design/web-vue', async () => {
const actual =
await vi.importActual<typeof import('@arco-design/web-vue')>(
'@arco-design/web-vue',
)
return {
...actual,
Message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
},
}
})
vi.mock('@/api/hermes/sessions', () => ({
fetchContextLength: vi.fn().mockResolvedValue(256000),
}))
vi.mock('@/api/hermes/model-context', () => ({
setModelContext: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useToolTraceVisibility', () => ({
useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }),
}))
vi.mock('@/composables/useMicRecorder', () => ({
useMicRecorder: () => ({
state: micRecorderState,
isRecording: { value: false },
start: micStartMock,
stop: micStopMock,
cancel: micCancelMock,
}),
}))
vi.mock('@/composables/useSpeech', () => ({
useGlobalSpeech: () => ({
stop: speechStopMock,
}),
}))
vi.mock('@/api/hermes/stt', () => ({
transcribeSpeech: transcribeSpeechMock,
}))
vi.mock('@/composables/useBrowserSpeechRecognition', () => ({
useBrowserSpeechRecognition: () => ({
isSupported: browserRecognitionIsSupported,
status: browserRecognitionStatus,
transcript: browserRecognitionTranscript,
partialTranscript: browserRecognitionPartialTranscript,
error: browserRecognitionError,
start: browserStartMock,
stop: browserStopMock,
cancel: browserCancelMock,
clearError: browserClearErrorMock,
}),
}))
vi.mock('@/composables/useVoiceDialogue', async () => {
const actual = await vi.importActual<typeof import('@/composables/useVoiceDialogue')>('@/composables/useVoiceDialogue')
return {
...actual,
useVoiceDialogue: (deps: Parameters<typeof actual.useVoiceDialogue>[0]) =>
useVoiceDialogueOverride.value?.(deps) ?? actual.useVoiceDialogue(deps),
}
})
describe('VoiceDialogueControls', () => {
function mountControls(overrides: Record<string, unknown> = {}) {
return mount(VoiceDialogueControls, {
props: {
status: 'idle',
transcript: '',
error: null,
onStart: vi.fn(),
onStop: vi.fn(),
onCancel: vi.fn(),
...overrides,
},
})
}
function mountChatInput(options: Parameters<typeof mount>[1] = {}) {
const pinia = createTestingPinia({ stubActions: true, createSpy: vi.fn })
const chatStore = useChatStore()
chatStore.sessions = [
{
id: 'session-voice',
title: 'session-voice',
source: 'cli',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
},
]
chatStore.activeSessionId = 'session-voice'
chatStore.activeSession = chatStore.sessions[0]
const wrapper = mount(ChatInput, {
...options,
global: {
plugins: [pinia, ArcoVue],
...options.global,
},
})
return { wrapper, chatStore }
}
function getButtonByText(wrapper: ReturnType<typeof mount>, text: string) {
const button = wrapper.findAll('button').find(candidate => candidate.text().includes(text))
if (!button) {
throw new Error(`Button containing "${text}" not found`)
}
return button
}
beforeEach(async () => {
localStorage.clear()
vi.clearAllMocks()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:mock-audio'),
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
})
micRecorderState.value = { status: 'idle', error: null, startedAt: null, mimeType: 'audio/webm' }
micStartMock.mockImplementation(async () => {
micRecorderState.value = { status: 'recording', error: null, startedAt: Date.now(), mimeType: 'audio/webm' }
})
micStopMock.mockImplementation(async () => {
micRecorderState.value = { status: 'idle', error: null, startedAt: null, mimeType: 'audio/webm' }
return new Blob(['audio'], { type: 'audio/webm' })
})
micCancelMock.mockImplementation(() => undefined)
browserStartMock.mockImplementation(async () => {
browserRecognitionStatus.value = 'listening'
browserRecognitionError.value = null
})
browserStopMock.mockImplementation(async () => {
browserRecognitionStatus.value = 'idle'
return 'hello hermes'
})
browserCancelMock.mockImplementation(() => {
browserRecognitionStatus.value = 'idle'
})
browserClearErrorMock.mockImplementation(() => {
browserRecognitionError.value = null
if (browserRecognitionStatus.value === 'error') {
browserRecognitionStatus.value = 'idle'
}
})
speechStopMock.mockImplementation(() => undefined)
transcribeSpeechMock.mockResolvedValue({
text: 'hello hermes',
provider: 'openai',
model: 'whisper-1',
durationMs: 1,
})
browserRecognitionStatus.value = 'idle'
browserRecognitionTranscript.value = ''
browserRecognitionPartialTranscript.value = ''
browserRecognitionError.value = null
browserRecognitionIsSupported.value = true
useVoiceDialogueOverride.value = null
const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
useSttSettings().reset()
})
it('starts recording from the mic button while idle', async () => {
const onStart = vi.fn()
const wrapper = mountControls({ onStart })
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
expect(onStart).toHaveBeenCalledTimes(1)
})
it('stops recording from the mic button while active', async () => {
const onStop = vi.fn()
const wrapper = mountControls({ status: 'capturing', onStop })
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
expect(onStop).toHaveBeenCalledTimes(1)
})
it('shows transcript text while active', () => {
const wrapper = mountControls({ status: 'capturing', transcript: 'hello' })
expect(wrapper.get('[data-testid="voice-transcript-overlay"]').text()).toContain('hello')
})
it('shows cancel only while active and calls onCancel', async () => {
const onCancel = vi.fn()
const idleWrapper = mountControls({ onCancel })
expect(idleWrapper.find('[data-testid="voice-record-cancel"]').exists()).toBe(false)
const activeWrapper = mountControls({ status: 'transcribing', onCancel })
await activeWrapper.get('[data-testid="voice-record-cancel"]').trigger('click')
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('shows overlay error when provided', () => {
const wrapper = mountControls({ status: 'idle', error: 'Mic permission denied' })
expect(wrapper.get('[data-testid="voice-transcript-overlay"]').text()).toContain('Mic permission denied')
})
it('renders human-readable status, transcript, and error diagnostics', () => {
const wrapper = mount(VoiceTranscriptOverlay, {
props: {
status: 'capturing',
transcript: 'hello hermes',
error: 'Mic permission denied',
},
})
const overlay = wrapper.get('[data-testid="voice-transcript-overlay"]')
expect(overlay.text()).toContain('chat.voiceInput.statusLabel chat.voiceInput.status.capturing')
expect(overlay.text()).toContain('chat.voiceInput.transcriptLabel hello hermes')
expect(overlay.text()).toContain('chat.voiceInput.errorLabel Mic permission denied')
})
it('does not render the debug event list when debug is false or omitted', async () => {
const events = [
{ type: 'session.started' },
{ type: 'capture.started' },
]
const wrapper = mount(VoiceTranscriptOverlay, {
props: {
status: 'capturing',
transcript: '',
events,
},
})
expect(wrapper.text()).not.toContain('chat.voiceInput.recentEvents')
expect(wrapper.text()).not.toContain('session.started')
expect(wrapper.text()).not.toContain('capture.started')
await wrapper.setProps({ debug: false })
expect(wrapper.text()).not.toContain('chat.voiceInput.recentEvents')
expect(wrapper.text()).not.toContain('session.started')
expect(wrapper.text()).not.toContain('capture.started')
})
it('renders only the last five event type strings in order when debug is true', () => {
const events = [
{ type: 'session.started', payload: { apiKey: 'secret-api-key-123' } },
{ type: 'capture.started', payload: { audioBlob: 'blob:super-secret-audio' } },
{ type: 'capture.partial' },
{ type: 'transcript.done', payload: { apiKey: 'secret-api-key-123' } },
{ type: 'turn.started', payload: { audioBlob: 'blob:super-secret-audio' } },
{ type: 'turn.ended', payload: { apiKey: 'secret-api-key-123', audioBlob: 'blob:super-secret-audio' } },
]
const wrapper = mount(VoiceTranscriptOverlay, {
props: {
status: 'sending',
transcript: 'hello hermes',
events,
debug: true,
},
})
const items = wrapper.findAll('[data-testid="voice-event-debug-item"]')
expect(items).toHaveLength(5)
expect(items.map(item => item.text())).toEqual([
'capture.started',
'capture.partial',
'transcript.done',
'turn.started',
'turn.ended',
])
expect(wrapper.text()).not.toContain('session.started')
expect(wrapper.text()).not.toContain('secret-api-key-123')
expect(wrapper.text()).not.toContain('blob:super-secret-audio')
})
it('does not treat error state as active controls', async () => {
const onStart = vi.fn()
const onStop = vi.fn()
const wrapper = mountControls({ status: 'error', transcript: '', error: 'boom', onStart, onStop })
expect(wrapper.get('[data-testid="voice-transcript-overlay"]').text()).toContain('boom')
expect(wrapper.get('[data-testid="voice-record-toggle"]').attributes('aria-pressed')).toBe('false')
expect(wrapper.get('[data-testid="voice-record-toggle"]').attributes('aria-label')).toBe('chat.voiceInput.startCapture')
expect(wrapper.find('[data-testid="voice-record-cancel"]').exists()).toBe(false)
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
expect(onStart).toHaveBeenCalledTimes(1)
expect(onStop).not.toHaveBeenCalled()
})
it('renders the transcript overlay as a floating anchored element', () => {
const wrapper = mountControls({ status: 'capturing', transcript: 'hello' })
const controls = wrapper.get('.voice-dialogue-controls')
const overlay = wrapper.get('[data-testid="voice-transcript-overlay"]')
expect(controls.classes()).toContain('voice-dialogue-controls--floating-overlay')
expect(overlay.classes()).toContain('voice-transcript-overlay--floating')
})
it('reflects the active state in aria-pressed', async () => {
const idleWrapper = mountControls({ status: 'idle' })
expect(idleWrapper.get('[data-testid="voice-record-toggle"]').attributes('aria-pressed')).toBe('false')
await idleWrapper.setProps({ status: 'sending' })
expect(idleWrapper.get('[data-testid="voice-record-toggle"]').attributes('aria-pressed')).toBe('true')
})
it('mounts the voice controls in ChatInput and preserves text send behavior', async () => {
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
expect(wrapper.find('[data-testid="voice-record-toggle"]').exists()).toBe(true)
await wrapper.get('textarea').setValue('typed hello')
await getButtonByText(wrapper, 'chat.send').trigger('click')
expect(chatStore.sendMessage).toHaveBeenCalledWith('typed hello', undefined)
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('')
})
it('keeps voice event debug diagnostics hidden in the normal ChatInput path', async () => {
const { wrapper } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
const overlay = wrapper.get('[data-testid="voice-transcript-overlay"]')
expect(overlay.text()).not.toContain('chat.voiceInput.recentEvents')
expect(overlay.text()).not.toContain('session.started')
expect(overlay.text()).not.toContain('capture.started')
})
it('keeps debug diagnostics hidden by default in ChatInput even when live voice events include sensitive payloads', async () => {
const liveEvents = [
{ id: 'voice-session:1', seq: 1, sessionId: 'voice-session', type: 'session.started', timestamp: '2026-06-06T00:00:00.000Z', payload: { apiKey: 'secret-api-key-123' } },
{ id: 'voice-session:2', seq: 2, sessionId: 'voice-session', type: 'capture.started', timestamp: '2026-06-06T00:00:01.000Z', payload: { audioBlob: 'blob:super-secret-audio' } },
{ id: 'voice-session:3', seq: 3, sessionId: 'voice-session', type: 'capture.stopped', timestamp: '2026-06-06T00:00:02.000Z' },
{ id: 'voice-session:4', seq: 4, sessionId: 'voice-session', type: 'transcript.done', timestamp: '2026-06-06T00:00:03.000Z', payload: { apiKey: 'secret-api-key-123' } },
{ id: 'voice-session:5', seq: 5, sessionId: 'voice-session', type: 'turn.started', timestamp: '2026-06-06T00:00:04.000Z', payload: { audioBlob: 'blob:super-secret-audio' } },
{ id: 'voice-session:6', seq: 6, sessionId: 'voice-session', type: 'turn.ended', timestamp: '2026-06-06T00:00:05.000Z', payload: { apiKey: 'secret-api-key-123', audioBlob: 'blob:super-secret-audio' } },
]
useVoiceDialogueOverride.value = () => ({
sessionId: 'voice-session',
events: { value: liveEvents },
status: { value: 'capturing' },
activeCaptureId: { value: 'capture-1' },
activeTurnId: { value: null },
transcript: { value: 'hello hermes' },
error: { value: null },
isBusy: { value: true },
beginCapture: vi.fn(),
transcribeAndSend: vi.fn(),
commitTranscript: vi.fn(),
cancelCapture: vi.fn(),
markOutputStarted: vi.fn(),
markOutputDone: vi.fn(),
})
const { wrapper } = mountChatInput()
await flushPromises()
const overlay = wrapper.get('[data-testid="voice-transcript-overlay"]')
expect(overlay.text()).toContain('chat.voiceInput.statusLabel')
expect(overlay.text()).not.toContain('chat.voiceInput.recentEvents')
expect(overlay.text()).not.toContain('session.started')
expect(overlay.text()).not.toContain('capture.started')
expect(overlay.text()).not.toContain('secret-api-key-123')
expect(overlay.text()).not.toContain('blob:super-secret-audio')
})
it('passes live voice dialogue events into VoiceDialogueControls while leaving debug disabled in ChatInput', async () => {
const liveEvents = [
{ id: 'voice-session:1', seq: 1, sessionId: 'voice-session', type: 'session.started', timestamp: '2026-06-06T00:00:00.000Z', payload: { apiKey: 'secret-api-key-123' } },
{ id: 'voice-session:2', seq: 2, sessionId: 'voice-session', type: 'capture.started', timestamp: '2026-06-06T00:00:01.000Z', payload: { audioBlob: 'blob:super-secret-audio' } },
{ id: 'voice-session:3', seq: 3, sessionId: 'voice-session', type: 'capture.stopped', timestamp: '2026-06-06T00:00:02.000Z' },
{ id: 'voice-session:4', seq: 4, sessionId: 'voice-session', type: 'transcript.done', timestamp: '2026-06-06T00:00:03.000Z', payload: { apiKey: 'secret-api-key-123' } },
{ id: 'voice-session:5', seq: 5, sessionId: 'voice-session', type: 'turn.started', timestamp: '2026-06-06T00:00:04.000Z', payload: { audioBlob: 'blob:super-secret-audio' } },
{ id: 'voice-session:6', seq: 6, sessionId: 'voice-session', type: 'turn.ended', timestamp: '2026-06-06T00:00:05.000Z', payload: { apiKey: 'secret-api-key-123', audioBlob: 'blob:super-secret-audio' } },
]
useVoiceDialogueOverride.value = () => ({
sessionId: 'voice-session',
events: { value: liveEvents },
status: { value: 'capturing' },
activeCaptureId: { value: 'capture-1' },
activeTurnId: { value: null },
transcript: { value: 'hello hermes' },
error: { value: null },
isBusy: { value: true },
beginCapture: vi.fn(),
transcribeAndSend: vi.fn(),
commitTranscript: vi.fn(),
cancelCapture: vi.fn(),
markOutputStarted: vi.fn(),
markOutputDone: vi.fn(),
})
const VoiceDialogueControlsStub = defineComponent({
props: {
debug: { type: Boolean, required: false },
events: { type: Array, required: false },
},
template: `
<div data-testid="voice-controls-props">
<span data-testid="voice-controls-debug">{{ String(debug ?? false) }}</span>
<span
v-for="event in (events ?? [])"
:key="event.id ?? event.type"
data-testid="voice-controls-event"
>
{{ event.type }}
</span>
</div>
`,
})
const { wrapper } = mountChatInput({
global: {
stubs: {
VoiceDialogueControls: VoiceDialogueControlsStub,
},
},
})
await flushPromises()
expect(wrapper.get('[data-testid="voice-controls-debug"]').text()).toBe('false')
expect(wrapper.findAll('[data-testid="voice-controls-event"]').map(item => item.text())).toEqual([
'session.started',
'capture.started',
'capture.stopped',
'transcript.done',
'turn.started',
'turn.ended',
])
expect(wrapper.text()).not.toContain('secret-api-key-123')
expect(wrapper.text()).not.toContain('blob:super-secret-audio')
})
it('stops output audio before starting mic capture from ChatInput', async () => {
const { wrapper } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(speechStopMock).toHaveBeenCalledWith(true)
expect(micStartMock).toHaveBeenCalledTimes(1)
expect(speechStopMock.mock.invocationCallOrder[0]).toBeLessThan(micStartMock.mock.invocationCallOrder[0])
})
it('records, transcribes with the selected backend provider, and stages the transcript for editing', async () => {
const audio = new Blob(['captured audio'], { type: 'audio/webm' })
micStopMock.mockResolvedValueOnce(audio)
const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
const sttSettings = useSttSettings()
sttSettings.setProvider('custom')
sttSettings.setCustomLanguage('ja')
sttSettings.setCustomPrompt('keep punctuation')
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(micStartMock).toHaveBeenCalledTimes(1)
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(micStopMock).toHaveBeenCalledTimes(1)
expect(transcribeSpeechMock).toHaveBeenCalledWith({
audio,
provider: 'custom',
language: 'ja',
prompt: 'keep punctuation',
})
expect(chatStore.sendMessage).not.toHaveBeenCalled()
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('hello hermes')
})
it('uses Doubao as a server-backed STT provider without client-side credentials', async () => {
const audio = new Blob(['captured audio'], { type: 'audio/webm' })
micStopMock.mockResolvedValueOnce(audio)
const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
useSttSettings().setProvider('doubao')
const { wrapper } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(transcribeSpeechMock).toHaveBeenCalledWith({
audio,
provider: 'doubao',
})
})
it('uses browser speech recognition when the browser provider is selected and stages the transcript', async () => {
browserStopMock.mockResolvedValueOnce('browser hello')
const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
const sttSettings = useSttSettings()
sttSettings.setProvider('browser')
sttSettings.setOpenaiLanguage('en-US')
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(browserStartMock).toHaveBeenCalledWith({ language: 'en-US' })
expect(micStartMock).not.toHaveBeenCalled()
expect(speechStopMock).toHaveBeenCalledWith(true)
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(browserStopMock).toHaveBeenCalledTimes(1)
expect(micStopMock).not.toHaveBeenCalled()
expect(transcribeSpeechMock).not.toHaveBeenCalled()
expect(chatStore.sendMessage).not.toHaveBeenCalled()
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('browser hello')
expect(wrapper.find('[data-testid="voice-transcript-overlay"]').exists()).toBe(false)
})
it('shows browser recognition errors only for the browser provider and clears stale browser errors before backend capture', async () => {
const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
const sttSettings = useSttSettings()
browserRecognitionError.value = new Error('Browser speech recognition failed.')
browserRecognitionStatus.value = 'error'
sttSettings.setProvider('browser')
const browserWrapper = mountChatInput().wrapper
await flushPromises()
expect(browserWrapper.get('[data-testid="voice-transcript-overlay"]').text()).toContain('Browser speech recognition failed.')
browserWrapper.unmount()
sttSettings.setProvider('openai')
const { wrapper } = mountChatInput()
await flushPromises()
expect(wrapper.find('[data-testid="voice-transcript-overlay"]').exists()).toBe(false)
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(browserClearErrorMock).toHaveBeenCalledTimes(1)
expect(browserRecognitionError.value).toBeNull()
expect(browserRecognitionStatus.value).toBe('idle')
expect(micStartMock).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-testid="voice-transcript-overlay"]').text()).not.toContain('Browser speech recognition failed.')
})
it('cancels browser speech recognition without transcribing or sending', async () => {
const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings')
useSttSettings().setProvider('browser')
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="voice-record-cancel"]').trigger('click')
await flushPromises()
expect(browserCancelMock).toHaveBeenCalledTimes(1)
expect(micCancelMock).not.toHaveBeenCalled()
expect(transcribeSpeechMock).not.toHaveBeenCalled()
expect(chatStore.sendMessage).not.toHaveBeenCalled()
})
it('stages a transcribed stop phrase for editing instead of routing it as a control intent', async () => {
transcribeSpeechMock.mockResolvedValueOnce({
text: 'stop',
provider: 'openai',
model: 'whisper-1',
durationMs: 1,
})
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(chatStore.stopStreaming).not.toHaveBeenCalled()
expect(chatStore.sendMessage).not.toHaveBeenCalled()
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('stop')
})
it('appends a transcribed status phrase to the editable draft without clearing attachments', async () => {
transcribeSpeechMock.mockResolvedValueOnce({
text: 'status',
provider: 'openai',
model: 'whisper-1',
durationMs: 1,
})
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
const textarea = wrapper.get('textarea').element as HTMLTextAreaElement
await wrapper.get('textarea').setValue('typed hello')
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
const attachmentInput = wrapper.get('input[type="file"]')
const file = new File(['notes'], 'voice-notes.txt', { type: 'text/plain' })
Object.defineProperty(attachmentInput.element, 'files', {
configurable: true,
value: [file],
})
await attachmentInput.trigger('change')
await flushPromises()
expect(wrapper.text()).toContain('voice-notes.txt')
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(chatStore.sendMessage).not.toHaveBeenCalled()
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('typed hello status')
expect(wrapper.text()).toContain('voice-notes.txt')
expect(wrapper.find('[data-testid="voice-record-cancel"]').exists()).toBe(false)
})
it('does not transcribe or send when recording is stopped during pending mic startup', async () => {
const startDeferred = createDeferred<void>()
micStartMock.mockImplementationOnce(() => {
micRecorderState.value = { status: 'requesting', error: null, startedAt: null, mimeType: 'audio/webm' }
return startDeferred.promise
})
micStopMock.mockResolvedValueOnce(new Blob([], { type: 'audio/webm' }))
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
const startClick = wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="voice-record-cancel"]').exists()).toBe(true)
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
expect(transcribeSpeechMock).not.toHaveBeenCalled()
expect(chatStore.sendMessage).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="voice-record-cancel"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="voice-record-toggle"]').attributes('aria-pressed')).toBe('false')
startDeferred.resolve()
await startClick
await flushPromises()
})
it('cancels an active voice capture from ChatInput', async () => {
const { wrapper, chatStore } = mountChatInput()
await flushPromises()
await wrapper.get('[data-testid="voice-record-toggle"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="voice-record-cancel"]').trigger('click')
await flushPromises()
expect(micCancelMock).toHaveBeenCalledTimes(1)
expect(transcribeSpeechMock).not.toHaveBeenCalled()
expect(chatStore.sendMessage).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="voice-record-cancel"]').exists()).toBe(false)
})
})