Files
web-terminal/test/voice-confirm.test.ts
Yaojia Wang e4c327e25e feat: voice command mapping — context-gated approve/reject over voice
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.
2026-07-01 10:38:32 +02:00

169 lines
4.6 KiB
TypeScript

/**
* test/voice-confirm.test.ts — Unit tests for voice-confirm.ts (VC / V1b).
*
* Pure timing-logic controller for the approve confirm window (PLAN_VOICE_COMMANDS §4,
* decision B). Uses vi.useFakeTimers — no DOM, no WS; setTimeout/clearTimeout only.
*
* Covered behaviours:
* - arm(onHeard, onCommit) fires onHeard synchronously/immediately.
* - onCommit fires once the windowMs timer elapses.
* - cancel() before the window elapses → onCommit is NEVER called.
* - re-arming clears the prior timer — only the latest arm's onCommit fires.
* - isArmed() reflects true while pending, false after commit/cancel/before-arm.
* - default windowMs is 1500 when not provided.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createApproveConfirm } from '../public/voice-confirm.js'
describe('voice-confirm', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('fires onHeard immediately when armed', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const onHeard = vi.fn()
const onCommit = vi.fn()
// Act
confirm.arm(onHeard, onCommit)
// Assert
expect(onHeard).toHaveBeenCalledTimes(1)
expect(onCommit).not.toHaveBeenCalled()
})
it('fires onCommit after windowMs elapses', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const onHeard = vi.fn()
const onCommit = vi.fn()
// Act
confirm.arm(onHeard, onCommit)
vi.advanceTimersByTime(1499)
expect(onCommit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
// Assert
expect(onCommit).toHaveBeenCalledTimes(1)
})
it('never calls onCommit when cancel() runs before the window elapses', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const onHeard = vi.fn()
const onCommit = vi.fn()
confirm.arm(onHeard, onCommit)
// Act
vi.advanceTimersByTime(500)
confirm.cancel()
vi.advanceTimersByTime(10_000)
// Assert
expect(onCommit).not.toHaveBeenCalled()
})
it('cancel() before any arm() is a no-op that does not throw', () => {
// Arrange
const confirm = createApproveConfirm(1500)
// Act / Assert
expect(() => confirm.cancel()).not.toThrow()
})
it('re-arming clears the prior timer so only the latest arm commits', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const firstHeard = vi.fn()
const firstCommit = vi.fn()
const secondHeard = vi.fn()
const secondCommit = vi.fn()
// Act
confirm.arm(firstHeard, firstCommit)
vi.advanceTimersByTime(1000)
confirm.arm(secondHeard, secondCommit)
// Enough time for the FIRST window to have elapsed (1000 + 1000 = 2000 > 1500)
// but not enough for the second window (armed at t=1000, needs t=2500).
vi.advanceTimersByTime(1000)
// Assert: first window's commit never fires, second hasn't fired yet either.
expect(firstCommit).not.toHaveBeenCalled()
expect(secondCommit).not.toHaveBeenCalled()
expect(secondHeard).toHaveBeenCalledTimes(1)
// Advance past the second window's own deadline.
vi.advanceTimersByTime(500)
expect(secondCommit).toHaveBeenCalledTimes(1)
expect(firstCommit).not.toHaveBeenCalled()
})
it('isArmed() is false before any arm()', () => {
// Arrange
const confirm = createApproveConfirm(1500)
// Assert
expect(confirm.isArmed()).toBe(false)
})
it('isArmed() is true immediately after arm() and while the window is pending', () => {
// Arrange
const confirm = createApproveConfirm(1500)
// Act
confirm.arm(vi.fn(), vi.fn())
// Assert
expect(confirm.isArmed()).toBe(true)
vi.advanceTimersByTime(500)
expect(confirm.isArmed()).toBe(true)
})
it('isArmed() is false after onCommit fires', () => {
// Arrange
const confirm = createApproveConfirm(1500)
confirm.arm(vi.fn(), vi.fn())
// Act
vi.advanceTimersByTime(1500)
// Assert
expect(confirm.isArmed()).toBe(false)
})
it('isArmed() is false after cancel()', () => {
// Arrange
const confirm = createApproveConfirm(1500)
confirm.arm(vi.fn(), vi.fn())
// Act
confirm.cancel()
// Assert
expect(confirm.isArmed()).toBe(false)
})
it('uses a default windowMs of 1500 when not provided', () => {
// Arrange
const confirm = createApproveConfirm()
const onCommit = vi.fn()
// Act
confirm.arm(vi.fn(), onCommit)
vi.advanceTimersByTime(1499)
expect(onCommit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
// Assert
expect(onCommit).toHaveBeenCalledTimes(1)
})
})