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.
169 lines
4.6 KiB
TypeScript
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)
|
|
})
|
|
})
|