Files
web-terminal/test/voice.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00

448 lines
16 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')
})
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')
})
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')
})
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')
})
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()
})
})