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.
This commit is contained in:
447
test/voice.test.ts
Normal file
447
test/voice.test.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
// @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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user