While a tool-permission gate is held on the active tab, spoken confirm phrases resolve it via the existing approve/reject WS channel — no server change (byte-shuttle intact). Everything else stays ordinary dictation. Safety: whole-utterance-exact matching + leading-negation guard + reject precedence + confidence gate (approve only) + a cancellable 1.5s confirm window whose commit re-validates gate identity/epoch/connectivity (TOCTOU guard) + stale-gate epoch bound at PTT-start. New pure modules public/voice-commands.ts and public/voice-confirm.ts at 100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass, typecheck clean, build:web OK. Independent code + security reviews flagged a confirm-window TOCTOU and an unwired cancel path — both fixed and covered. Plan: docs/PLAN_VOICE_COMMANDS.md.
457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* test/voice.test.ts — Unit tests for voice.ts (N-voice, A2).
|
|
*
|
|
* Tests the Web Speech API wrapper (isSpeechSupported / createVoiceInput)
|
|
* and the keybar mic-button integration (mountKeybar opts.onVoiceTrigger).
|
|
* Runs under jsdom so window / document / TouchEvent are available; the real
|
|
* SpeechRecognition is replaced by a lightweight self-registering mock class.
|
|
*
|
|
* AC-A2.1 final transcript calls onTranscript
|
|
* AC-A2.2 touch preventDefault keeps soft keyboard hidden
|
|
* AC-A2.3 unsupported → hidden, no crash
|
|
* AC-A2.4 autoSend appends \r; off → no \r
|
|
* AC-A2.5 audio never reaches server (pure-FE — no server stub needed here)
|
|
* SEC-L2 disclosure via title attribute on mic button
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { isSpeechSupported, createVoiceInput } from '../public/voice.js'
|
|
import { mountKeybar } from '../public/keybar.js'
|
|
|
|
// ── Mock SpeechRecognition ────────────────────────────────────────────────────
|
|
// Using a self-registering class pattern: each `new MockRecognition()` stores
|
|
// itself in MockRecognition.last, so tests can reference the instance created
|
|
// by createVoiceInput() without needing vi.fn() as the constructor wrapper.
|
|
|
|
/** Minimal SpeechRecognitionEvent-like shape for test usage. */
|
|
interface MockResultEvent {
|
|
resultIndex: number
|
|
results: {
|
|
length: number
|
|
[index: number]: {
|
|
isFinal: boolean
|
|
length: number
|
|
[index: number]: { transcript: string; confidence: number }
|
|
}
|
|
}
|
|
}
|
|
|
|
class MockRecognition {
|
|
/** The most-recently constructed instance — set in the constructor. */
|
|
static last: MockRecognition | null = null
|
|
|
|
continuous = false
|
|
interimResults = false
|
|
lang = ''
|
|
onresult: ((ev: MockResultEvent) => void) | null = null
|
|
onend: (() => void) | null = null
|
|
onerror: ((ev: { error: string }) => void) | null = null
|
|
|
|
// Arrow-function class fields create fresh vi.fn() per instance
|
|
readonly start = vi.fn()
|
|
readonly stop = vi.fn()
|
|
readonly abort = vi.fn()
|
|
|
|
constructor() {
|
|
MockRecognition.last = this
|
|
}
|
|
}
|
|
|
|
/** Build a single-result event for testing. */
|
|
function makeResultEvent(transcript: string, isFinal: boolean, resultIndex = 0): MockResultEvent {
|
|
const alt = { transcript, confidence: 1.0 }
|
|
const result = Object.assign([alt], { isFinal, length: 1 })
|
|
const results = Object.assign([result], { length: 1 })
|
|
return { resultIndex, results } as unknown as MockResultEvent
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// isSpeechSupported
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('isSpeechSupported', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('returns false when SpeechRecognition is not in window', () => {
|
|
// jsdom does not provide SpeechRecognition by default
|
|
expect(isSpeechSupported()).toBe(false)
|
|
})
|
|
|
|
it('returns true when SpeechRecognition is present in window', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
expect(isSpeechSupported()).toBe(true)
|
|
})
|
|
|
|
it('returns true when webkitSpeechRecognition is present in window', () => {
|
|
vi.stubGlobal('webkitSpeechRecognition', MockRecognition)
|
|
expect(isSpeechSupported()).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// createVoiceInput
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('createVoiceInput', () => {
|
|
beforeEach(() => {
|
|
MockRecognition.last = null
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
// --- null when unsupported ---
|
|
|
|
it('returns null when speech is not supported', () => {
|
|
vi.unstubAllGlobals()
|
|
const voice = createVoiceInput(vi.fn())
|
|
expect(voice).toBeNull()
|
|
})
|
|
|
|
// --- object shape ---
|
|
|
|
it('returns a VoiceInput object with required methods', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
expect(voice).not.toBeNull()
|
|
expect(typeof voice?.start).toBe('function')
|
|
expect(typeof voice?.stop).toBe('function')
|
|
expect(typeof voice?.dispose).toBe('function')
|
|
expect(typeof voice?.isActive).toBe('function')
|
|
})
|
|
|
|
// --- isActive ---
|
|
|
|
it('starts in inactive state', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
expect(voice?.isActive()).toBe(false)
|
|
})
|
|
|
|
it('isActive returns true after start()', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
voice?.start()
|
|
expect(voice?.isActive()).toBe(true)
|
|
})
|
|
|
|
it('isActive returns false after stop()', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
voice?.start()
|
|
voice?.stop()
|
|
expect(voice?.isActive()).toBe(false)
|
|
})
|
|
|
|
it('isActive returns false after natural onend fires', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onend?.()
|
|
expect(voice?.isActive()).toBe(false)
|
|
})
|
|
|
|
it('isActive returns false after onerror fires', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onerror?.({ error: 'network' })
|
|
expect(voice?.isActive()).toBe(false)
|
|
})
|
|
|
|
// --- recognition.start / stop calls ---
|
|
|
|
it('calls recognition.start() on start()', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
expect(mock.start).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not call recognition.start() twice when already active', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
voice?.start()
|
|
expect(mock.start).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('calls recognition.stop() on stop() when active', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
voice?.stop()
|
|
expect(mock.stop).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not call recognition.stop() when not active', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.stop()
|
|
expect(mock.stop).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// --- transcript callbacks ---
|
|
|
|
it('calls onTranscript with final transcript text (AC-A2.1)', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript)
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('hello world', true))
|
|
expect(onTranscript).toHaveBeenCalledWith('hello world', expect.any(Number))
|
|
})
|
|
|
|
it('autoSend=true appends \\r to the transcript (AC-A2.4)', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript, { autoSend: true })
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('hello', true))
|
|
expect(onTranscript).toHaveBeenCalledWith('hello\r', expect.any(Number))
|
|
})
|
|
|
|
it('autoSend=false does not append \\r (AC-A2.4)', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript, { autoSend: false })
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('hello', true))
|
|
expect(onTranscript).toHaveBeenCalledWith('hello', expect.any(Number))
|
|
})
|
|
|
|
it('omitting autoSend does not append \\r', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript)
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('hello', true))
|
|
expect(onTranscript).toHaveBeenCalledWith('hello', expect.any(Number))
|
|
})
|
|
|
|
it('forwards the top alternative ASR confidence as a 2nd arg (VC decision D)', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript)
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('hello', true))
|
|
expect(onTranscript).toHaveBeenCalledWith('hello', 1.0)
|
|
})
|
|
|
|
it('calls onInterim with interim transcript text', () => {
|
|
const onInterim = vi.fn()
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript, { onInterim })
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('hel', false))
|
|
expect(onInterim).toHaveBeenCalledWith('hel')
|
|
expect(onTranscript).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not crash when interim fires but onInterim is not provided', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript)
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
expect(() => mock.onresult?.(makeResultEvent('par', false))).not.toThrow()
|
|
expect(onTranscript).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not call onTranscript for interim-only results', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript, { onInterim: vi.fn() })
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
mock.onresult?.(makeResultEvent('partial', false))
|
|
expect(onTranscript).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// --- dispose ---
|
|
|
|
it('does not fire onTranscript after dispose()', () => {
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript)
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
voice?.dispose()
|
|
mock.onresult?.(makeResultEvent('late transcript', true))
|
|
expect(onTranscript).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('calls recognition.abort() when disposed while active', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
voice?.dispose()
|
|
expect(mock.abort).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not call recognition.abort() when disposed while inactive', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.dispose()
|
|
expect(mock.abort).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('dispose() is idempotent — double dispose is safe', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.start()
|
|
expect(() => { voice?.dispose(); voice?.dispose() }).not.toThrow()
|
|
expect(mock.abort).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('start() after dispose() is a no-op', () => {
|
|
const voice = createVoiceInput(vi.fn())
|
|
const mock = MockRecognition.last!
|
|
voice?.dispose()
|
|
voice?.start()
|
|
expect(mock.start).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// --- configuration ---
|
|
|
|
it('sets recognition.lang to the provided lang option', () => {
|
|
createVoiceInput(vi.fn(), { lang: 'ja-JP' })
|
|
expect(MockRecognition.last?.lang).toBe('ja-JP')
|
|
})
|
|
|
|
it('uses navigator.language by default', () => {
|
|
createVoiceInput(vi.fn())
|
|
expect(MockRecognition.last?.lang).toBe(navigator.language)
|
|
})
|
|
|
|
it('sets interimResults=true when onInterim is provided', () => {
|
|
createVoiceInput(vi.fn(), { onInterim: vi.fn() })
|
|
expect(MockRecognition.last?.interimResults).toBe(true)
|
|
})
|
|
|
|
it('sets interimResults=false when onInterim is not provided', () => {
|
|
createVoiceInput(vi.fn())
|
|
expect(MockRecognition.last?.interimResults).toBe(false)
|
|
})
|
|
|
|
it('uses webkitSpeechRecognition when SpeechRecognition is absent', () => {
|
|
vi.unstubAllGlobals()
|
|
// Re-create MockRecognition.last tracking
|
|
class WebkitMockRecognition extends MockRecognition {}
|
|
vi.stubGlobal('webkitSpeechRecognition', WebkitMockRecognition)
|
|
|
|
const onTranscript = vi.fn()
|
|
const voice = createVoiceInput(onTranscript)
|
|
expect(voice).not.toBeNull()
|
|
voice?.start()
|
|
expect(MockRecognition.last?.start).toHaveBeenCalled()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// mountKeybar voice button (onVoiceTrigger integration)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('mountKeybar voice button', () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = '<div id="keybar"></div>'
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('does not append a mic button when speech is not supported (AC-A2.3)', () => {
|
|
// No SpeechRecognition in window by default in jsdom
|
|
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
|
|
expect(document.querySelector('[data-key="voice"]')).toBeNull()
|
|
})
|
|
|
|
it('does not append a mic button when onVoiceTrigger is not provided', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
mountKeybar(vi.fn())
|
|
expect(document.querySelector('[data-key="voice"]')).toBeNull()
|
|
})
|
|
|
|
it('appends a mic button when supported and onVoiceTrigger is provided', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
|
|
expect(document.querySelector('[data-key="voice"]')).not.toBeNull()
|
|
})
|
|
|
|
it('mic button displays 🎤 key label', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
|
|
const keyEl = document.querySelector('[data-key="voice"] .kb-key')
|
|
expect(keyEl?.textContent).toBe('🎤')
|
|
})
|
|
|
|
it('mic button has a caption label', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
|
|
const capEl = document.querySelector('[data-key="voice"] .kb-cap')
|
|
expect(capEl?.textContent).toBeTruthy()
|
|
})
|
|
|
|
it('touchstart calls onVoiceTrigger("start") and prevents default (AC-A2.2)', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
const onVoiceTrigger = vi.fn()
|
|
mountKeybar(vi.fn(), { onVoiceTrigger })
|
|
const btn = document.querySelector('[data-key="voice"]')!
|
|
const ev = new Event('touchstart', { cancelable: true, bubbles: true })
|
|
btn.dispatchEvent(ev)
|
|
expect(onVoiceTrigger).toHaveBeenCalledWith('start')
|
|
})
|
|
|
|
it('touchend calls onVoiceTrigger("stop") and prevents default', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
const onVoiceTrigger = vi.fn()
|
|
mountKeybar(vi.fn(), { onVoiceTrigger })
|
|
const btn = document.querySelector('[data-key="voice"]')!
|
|
const ev = new Event('touchend', { cancelable: true, bubbles: true })
|
|
btn.dispatchEvent(ev)
|
|
expect(onVoiceTrigger).toHaveBeenCalledWith('stop')
|
|
})
|
|
|
|
it('mousedown calls onVoiceTrigger("start") for desktop fallback', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
const onVoiceTrigger = vi.fn()
|
|
mountKeybar(vi.fn(), { onVoiceTrigger })
|
|
const btn = document.querySelector('[data-key="voice"]')!
|
|
btn.dispatchEvent(new MouseEvent('mousedown', { cancelable: true, bubbles: true }))
|
|
expect(onVoiceTrigger).toHaveBeenCalledWith('start')
|
|
})
|
|
|
|
it('mouseup calls onVoiceTrigger("stop") for desktop fallback', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
const onVoiceTrigger = vi.fn()
|
|
mountKeybar(vi.fn(), { onVoiceTrigger })
|
|
const btn = document.querySelector('[data-key="voice"]')!
|
|
btn.dispatchEvent(new MouseEvent('mouseup', { cancelable: true, bubbles: true }))
|
|
expect(onVoiceTrigger).toHaveBeenCalledWith('stop')
|
|
})
|
|
|
|
it('mic button title discloses audio privacy (SEC-L2)', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
|
|
const btn = document.querySelector<HTMLElement>('[data-key="voice"]')!
|
|
expect(btn.title.toLowerCase()).toMatch(/audio/)
|
|
})
|
|
|
|
it('existing key buttons are still rendered when voice opts provided', () => {
|
|
vi.stubGlobal('SpeechRecognition', MockRecognition)
|
|
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
|
|
expect(document.querySelector('[data-key="esc"]')).not.toBeNull()
|
|
})
|
|
|
|
it('mountKeybar works with no opts (backward compat)', () => {
|
|
expect(() => mountKeybar(vi.fn())).not.toThrow()
|
|
})
|
|
|
|
it('mountKeybar returns without error when #keybar element is absent', () => {
|
|
document.body.innerHTML = '' // no #keybar
|
|
expect(() => mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })).not.toThrow()
|
|
})
|
|
})
|