Files
web-terminal/test/tabs.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

1035 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
/**
* test/tabs.test.ts — frontend TabApp unit tests.
*
* Mocks TerminalSession + the launcher + projects panel so we test TabApp's
* tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant,
* that addEntry builds a real (non-null) session before pushing the entry
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ── Mock TerminalSession ──────────────────────────────────────────────────────
let constructed = 0
class FakeTerminalSession {
el: HTMLDivElement
id: string | null
status = 'connecting'
claudeStatus = 'unknown'
cwd: string | null = null
pendingApproval = false
pendingTool: string | undefined = undefined
/** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
pendingGate: 'tool' | 'plan' | null = null
/** VC: nonce that counts false→true pendingApproval flips (stale-gate guard). */
pendingEpoch = 0
/** B2: latest statusLine telemetry (single source of truth). */
telemetry: unknown = null
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
initialInput: string | undefined = undefined
connect = vi.fn()
dispose = vi.fn()
show = vi.fn()
hide = vi.fn()
applyTheme = vi.fn()
refit = vi.fn()
send = vi.fn()
approve = vi.fn()
reject = vi.fn()
findNext = vi.fn()
findPrevious = vi.fn()
clearSearch = vi.fn()
// Captured callbacks so tests can drive status/title/activity events.
cbs: {
onClaudeStatus?: (s: string, d?: string) => void
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
}
static instances: FakeTerminalSession[] = []
constructor(opts: {
sessionId: string | null
initialInput?: string
onClaudeStatus?: (s: string, d?: string) => void
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
[key: string]: unknown
}) {
constructed += 1
this.id = opts.sessionId
this.initialInput = opts.initialInput
this.el = document.createElement('div')
this.cbs = opts
FakeTerminalSession.instances.push(this)
}
}
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))
// ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ──────
const renderTelemetryGauge = vi.fn()
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge }))
const mountPushToggle = vi.fn()
vi.mock('../public/push.js', () => ({ mountPushToggle }))
const quickReply: { onSend?: (d: string) => void } = {}
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
quickReply.onSend = opts.onSend
return { dispose: vi.fn() }
})
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
const timelineDispose = vi.fn()
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
const voice: {
onTranscript?: (t: string, confidence?: number) => void
onInterim?: (t: string) => void
} = {}
const voiceStart = vi.fn()
const voiceStop = vi.fn()
const createVoiceInput = vi.fn(
(
onTranscript: (t: string, confidence?: number) => void,
opts?: { onInterim?: (t: string) => void },
) => {
voice.onTranscript = onTranscript
voice.onInterim = opts?.onInterim
return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false }
},
)
const isSpeechSupported = vi.fn(() => true)
vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported }))
// ── Mock the launcher (records visibility) ────────────────────────────────────
const launcherVisible = { value: false }
const mountLauncher = vi.fn(() => ({
setVisible: (v: boolean) => {
launcherVisible.value = v
},
refresh: vi.fn(),
}))
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
// ── Mock the projects panel (records visibility) ──────────────────────────────
const projectsVisible = { value: false }
const mountProjects = vi.fn(() => ({
setVisible: (v: boolean) => {
projectsVisible.value = v
},
refresh: vi.fn(),
}))
vi.mock('../public/projects.js', () => ({ mountProjects }))
// settings.js is light but imports nothing heavy; let it load for real.
const { TabApp } = await import('../public/tabs.js')
function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } {
const paneHost = document.createElement('div')
const tabBar = document.createElement('div')
// #keybar must exist so the quick-reply row mounts directly above it (A3).
const keybar = document.createElement('div')
keybar.id = 'keybar'
document.body.append(paneHost, tabBar, keybar)
return { paneHost, tabBar }
}
/** Stub fetch('/config/ui') → { allowAutoMode }. */
function stubUiConfigFetch(allowAutoMode: boolean): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })),
)
}
beforeEach(() => {
constructed = 0
FakeTerminalSession.instances = []
launcherVisible.value = false
projectsVisible.value = false
document.body.replaceChildren()
localStorage.clear()
renderTelemetryGauge.mockClear()
mountPushToggle.mockClear()
mountQuickReply.mockClear()
mountTimeline.mockClear()
timelineDispose.mockClear()
createVoiceInput.mockClear()
voiceStart.mockClear()
voiceStop.mockClear()
quickReply.onSend = undefined
voice.onTranscript = undefined
voice.onInterim = undefined
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') }))
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('TabApp — v0.5 launcher chooser', () => {
it('starts with NO auto-created tab and shows the launcher', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
// No TerminalSession constructed on boot (no auto tab).
expect(constructed).toBe(0)
expect(launcherVisible.value).toBe(true)
})
it('newTab() creates a tab and hides the launcher', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(constructed).toBe(1)
expect(launcherVisible.value).toBe(false)
})
it('closing the last tab returns to the launcher (no auto-blank tab)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(launcherVisible.value).toBe(false)
app.closeTab(0)
// Back to the chooser, and no new tab was auto-created.
expect(launcherVisible.value).toBe(true)
expect(constructed).toBe(1)
})
})
describe('TabApp — addEntry has no null-session hole (Phase 1#5)', () => {
it('the constructed session is real and used immediately (connect called)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
// The entry's session must be the constructed FakeTerminalSession with connect() called.
const snap = app.snapshot()
expect(snap).toHaveLength(1)
// sendToActive routes to a real session (would throw if session were null).
expect(() => app.sendToActive('x')).not.toThrow()
})
it('openSession joins by id and focuses it', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession('11111111-1111-4111-8111-111111111111')
expect(constructed).toBe(1)
expect(app.activeSessionId()).toBe('11111111-1111-4111-8111-111111111111')
})
it('openSession re-focuses an already-open session instead of duplicating', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const id = '22222222-2222-4222-8222-222222222222'
app.openSession(id)
app.openSession(id)
expect(constructed).toBe(1) // not duplicated
})
})
describe('TabApp — multi-tab lifecycle', () => {
it('renders a tab bar with one .tab per tab plus the add button', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
expect(tabBar.querySelectorAll('.tab')).toHaveLength(2)
expect(tabBar.querySelector('.tab-add')).not.toBeNull()
})
it('activate() switches the active tab (show/hide on sessions)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
app.activate(0)
const snap = app.snapshot()
expect(snap.find((t) => t.active)?.idx).toBe(0)
})
it('closeTab on a middle tab keeps the others and re-activates a neighbor', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
app.newTab()
app.closeTab(1)
expect(app.snapshot()).toHaveLength(2)
})
it('applySettings re-themes every tab without throwing', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(() => app.applySettings({ theme: 'dark', fontSize: 15 })).not.toThrow()
})
it('findInActive / clearActiveSearch route to the active session', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(() => {
app.findInActive('q', 'next')
app.findInActive('q', 'prev')
app.clearActiveSearch()
app.refitActive()
}).not.toThrow()
})
it('newTabForResume opens a tab seeded with a resume command', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
expect(constructed).toBe(1)
expect(app.snapshot()).toHaveLength(1)
})
it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
const session = FakeTerminalSession.instances[0]!
expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r')
})
it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
const session = FakeTerminalSession.instances[0]!
expect(session.initialInput).toBe('claude\r')
})
it('snapshot reflects per-tab connection + claude status fields', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const snap = app.snapshot()
expect(snap[0]).toMatchObject({ idx: 0, conn: 'connecting', claude: 'unknown' })
})
})
describe('TabApp — DOM interactions on the tab bar', () => {
function pointerdown(elm: Element): void {
elm.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
}
it('pointerdown on a tab activates it', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const tabs = tabBar.querySelectorAll('.tab')
pointerdown(tabs[0]!)
expect(app.snapshot().find((t) => t.active)?.idx).toBe(0)
})
it('clicking the × close button closes that tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const close = tabBar.querySelector('.tab-close') as HTMLButtonElement
close.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(app.snapshot()).toHaveLength(1)
})
it('clicking the + add button opens a new tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const add = tabBar.querySelector('.tab-add') as HTMLButtonElement
add.click()
expect(app.snapshot()).toHaveLength(1)
})
it('middle-click (auxclick button 1) closes a tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const tab = tabBar.querySelector('.tab') as HTMLElement
tab.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 }))
expect(app.snapshot()).toHaveLength(1)
})
it('double-click the label enters rename mode, Enter commits a custom title', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const label = tabBar.querySelector('.tab-label') as HTMLElement
label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
expect(input).not.toBeNull()
input.value = 'My Tab'
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
expect(app.snapshot()[0]!.title).toBe('My Tab')
})
it('activate() out of range is ignored', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(() => {
app.activate(99)
app.activate(-1)
app.focusTab(0)
}).not.toThrow()
})
it('sendToActive / clearActiveSearch with no active tab do not throw', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
// No tab open (launcher visible).
expect(() => {
app.sendToActive('x')
app.clearActiveSearch()
app.refitActive()
}).not.toThrow()
expect(app.activeSessionId()).toBeNull()
})
it('Escape in rename mode cancels without committing', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const label = tabBar.querySelector('.tab-label') as HTMLElement
label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
input.value = 'discard me'
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
// Title falls back to the auto/default, not the typed value.
expect(app.snapshot()[0]!.title).not.toBe('discard me')
})
it('onTitle/onStatus/onActivity callbacks refresh the tab in place', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const fake = FakeTerminalSession.instances[0]!
// Drive the captured callbacks like the real TerminalSession would.
expect(() => {
fake.cbs.onTitle?.('myproj')
fake.cbs.onStatus?.('connected')
fake.cbs.onActivity?.()
}).not.toThrow()
})
it('onClaudeStatus "waiting" on a background tab notifies (H2/H4)', () => {
const NotificationMock = vi.fn()
;(NotificationMock as unknown as { permission: string }).permission = 'granted'
vi.stubGlobal('Notification', NotificationMock)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // tab 0 (active)
app.newTab() // tab 1 (active now)
// tab 0 is in the background; fire its onClaudeStatus 'waiting'.
const bg = FakeTerminalSession.instances[0]!
bg.claudeStatus = 'waiting'
bg.cbs.onClaudeStatus?.('waiting')
expect(NotificationMock).toHaveBeenCalled()
vi.unstubAllGlobals()
})
it('drag-and-drop reorders tabs', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const tabs = tabBar.querySelectorAll('.tab')
// jsdom has no DataTransfer; the handlers use optional chaining + this.dragIndex,
// so plain drag events (no dataTransfer) still drive the reorder path.
tabs[0]!.dispatchEvent(new Event('dragstart', { bubbles: true }))
tabs[1]!.dispatchEvent(new Event('dragover', { bubbles: true }))
tabs[1]!.dispatchEvent(new Event('drop', { bubbles: true }))
tabs[0]!.dispatchEvent(new Event('dragend', { bubbles: true }))
expect(app.snapshot()).toHaveLength(2)
})
})
describe('TabApp — P6 openProject + home segmented control', () => {
it('openProject creates a tab with the repo name as its displayed title', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
expect(app.snapshot()).toHaveLength(1)
expect(app.snapshot()[0]!.title).toBe('myrepo')
})
it('a second openProject with the same name gets a #2 suffix', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
app.openProject('/path/to/myrepo', 'myrepo')
const snap = app.snapshot()
expect(snap).toHaveLength(2)
expect(snap[0]!.title).toBe('myrepo')
expect(snap[1]!.title).toBe('myrepo #2')
})
it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'proj')
app.openProject('/p', 'proj')
app.openProject('/p', 'proj')
const snap = app.snapshot()
expect(snap[0]!.title).toBe('proj')
expect(snap[1]!.title).toBe('proj #2')
expect(snap[2]!.title).toBe('proj #3')
})
it('openProject hides the segmented control and both panels (tab open)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p/repo', 'repo')
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg).not.toBeNull()
expect(seg.style.display).toBe('none')
expect(launcherVisible.value).toBe(false)
expect(projectsVisible.value).toBe(false)
})
it('home segmented control shows both panels depending on selected view', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
// Initially: sessions view → launcher visible, projects hidden.
expect(launcherVisible.value).toBe(true)
expect(projectsVisible.value).toBe(false)
// Click the "Projects" button (second .home-seg-btn).
const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement
projBtn.click()
expect(launcherVisible.value).toBe(false)
expect(projectsVisible.value).toBe(true)
// Click the "Sessions" button (first .home-seg-btn).
const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement
sessBtn.click()
expect(launcherVisible.value).toBe(true)
expect(projectsVisible.value).toBe(false)
})
it('closing the last tab shows the segmented control and restores home view', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg.style.display).toBe('none') // hidden while tab open
app.closeTab(0)
expect(seg.style.display).not.toBe('none') // back to home
expect(launcherVisible.value).toBe(true) // sessions view by default
})
it('openProject with an empty repoPath still opens a tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
expect(() => app.openProject('', 'bare-repo')).not.toThrow()
expect(app.snapshot()).toHaveLength(1)
expect(app.snapshot()[0]!.title).toBe('bare-repo')
})
})
describe('TabApp — Home (⌂) button: reach the chooser from an open session', () => {
it('overlays the home chooser over an open tab, then toggles back', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const seg = paneHost.querySelector('.home-seg') as HTMLElement
const homeBtn = tabBar.querySelector('.tab-home') as HTMLButtonElement
expect(homeBtn).not.toBeNull()
expect(seg.style.display).toBe('none') // terminal owns the screen
homeBtn.click() // overlay the home chooser
expect(seg.style.display).toBe('flex')
expect(launcherVisible.value).toBe(true) // default sessions view
expect(homeBtn.classList.contains('active')).toBe(true)
homeBtn.click() // back to the terminal
expect(seg.style.display).toBe('none')
expect(launcherVisible.value).toBe(false)
expect(homeBtn.classList.contains('active')).toBe(false)
})
it('can switch to Projects from the overlay and open another project', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p/one', 'one')
;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click() // overlay home
;(paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement).click() // Projects
expect(projectsVisible.value).toBe(true)
app.openProject('/p/two', 'two') // opening dismisses the overlay
expect(app.snapshot()).toHaveLength(2)
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg.style.display).toBe('none')
expect(projectsVisible.value).toBe(false)
expect((tabBar.querySelector('.tab-home') as HTMLButtonElement).classList.contains('active')).toBe(false)
})
it('⌂ is a no-op when no tab is open (home already shown)', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg.style.display).toBe('flex')
;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click()
expect(seg.style.display).toBe('flex') // still shown, no crash
})
})
describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => {
it('mounts the 🔔 push toggle exactly once on construction (A1)', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
expect(mountPushToggle).toHaveBeenCalledTimes(1)
})
it('mounts the quick-reply row directly above #keybar (A3)', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
expect(mountQuickReply).toHaveBeenCalledTimes(1)
const qr = document.getElementById('quickreply')
expect(qr).not.toBeNull()
expect(qr?.nextElementSibling).toBe(document.getElementById('keybar'))
})
it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
quickReply.onSend?.('yes\r')
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r')
})
it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // rebuild
app.newTab() // rebuild again
expect(mountPushToggle).toHaveBeenCalledTimes(1)
expect(tabBar.querySelector('.push-host')).not.toBeNull()
})
})
describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => {
it('renders the telemetry gauge from the session.telemetry single source of truth', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 }
session.telemetry = tele
renderTelemetryGauge.mockClear()
session.cbs.onTelemetry?.(tele) // server pushed new telemetry
expect(renderTelemetryGauge).toHaveBeenCalled()
expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele)
})
it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // tab 0
app.newTab() // tab 1 active → tab 0 background
const bg = FakeTerminalSession.instances[0]!
bg.claudeStatus = 'stuck'
bg.cbs.onClaudeStatus?.('stuck')
const tab0 = tabBar.querySelectorAll('.tab')[0]!
expect(tab0.classList.contains('claude-stuck')).toBe(true)
expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠')
})
})
describe('TabApp — B4 plan-gate approval bar', () => {
function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances.at(-1)!
session.pendingApproval = true
session.pendingGate = gate
session.pendingTool = tool
session.cbs.onClaudeStatus?.('waiting')
return session
}
it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => {
openWithGate('tool', 'Bash')
const bar = document.getElementById('approvalbar')!
expect(bar.style.display).toBe('flex')
expect(bar.querySelectorAll('button')).toHaveLength(2)
})
it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => {
const session = openWithGate('plan')
const bar = document.getElementById('approvalbar')!
const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click()
expect(bar.querySelectorAll('button')).toHaveLength(3)
click(0) // Approve + Auto
expect(session.approve).toHaveBeenLastCalledWith('acceptEdits')
click(1) // Approve + Review
expect(session.approve).toHaveBeenLastCalledWith('default')
click(2) // Keep Planning
expect(session.reject).toHaveBeenCalled()
})
})
describe('TabApp — B4 permission-mode relay', () => {
it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'repo', 'claude\r', 'plan')
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe(
'claude --permission-mode plan\r',
)
})
it('openProject with default mode keeps the plain claude command (no regression)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'repo', 'claude\r', 'default')
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r')
})
it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false
expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan'])
})
it('availablePermissionModes includes "auto" once /config/ui allows it', async () => {
stubUiConfigFetch(true)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto'))
})
it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => {
localStorage.setItem('web-terminal:permission-mode', 'auto')
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false
expect(app.loadDefaultMode()).toBe('default')
})
it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.saveDefaultMode('plan')
expect(app.loadDefaultMode()).toBe('plan')
localStorage.setItem('web-terminal:permission-mode', 'garbage')
expect(app.loadDefaultMode()).toBe('default')
})
})
describe('TabApp — A4 timeline panel', () => {
const SID = '44444444-4444-4444-8444-444444444444'
it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession(SID)
const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement
expect(tlBtn).not.toBeNull()
tlBtn.click() // open
expect(mountTimeline).toHaveBeenCalledTimes(1)
expect(document.getElementById('timeline-panel')!.style.display).toBe('block')
tlBtn.click() // close
expect(timelineDispose).toHaveBeenCalled()
expect(document.getElementById('timeline-panel')!.style.display).toBe('none')
})
it('closing a tab with its timeline open disposes the polling handle', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession(SID)
;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open
expect(mountTimeline).toHaveBeenCalled()
app.closeTab(0)
expect(timelineDispose).toHaveBeenCalled()
})
})
describe('TabApp — A2 push-to-talk voice', () => {
it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.handleVoiceTrigger('start')
expect(createVoiceInput).toHaveBeenCalledTimes(1)
expect(voiceStart).toHaveBeenCalled()
expect(document.getElementById('voice-interim')!.style.display).toBe('block')
})
it('a final transcript is sent to the active session as raw input (A2-FR1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.handleVoiceTrigger('start')
voice.onTranscript?.('list the files')
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files')
})
it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.handleVoiceTrigger('start')
voice.onInterim?.('hello wor')
const overlay = document.getElementById('voice-interim')!
expect(overlay.textContent).toBe('hello wor')
app.handleVoiceTrigger('stop')
expect(voiceStop).toHaveBeenCalled()
expect(overlay.style.display).toBe('none')
})
it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => {
createVoiceInput.mockReturnValueOnce(null as never)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
expect(() => app.handleVoiceTrigger('start')).not.toThrow()
expect(voiceStart).not.toHaveBeenCalled()
})
})
describe('TabApp — VC voice command mapping (context-gated confirm/reject)', () => {
/** Open one tab, hold a tool-gate approval, start PTT, and return its session. */
function pendingToolSession(app: InstanceType<typeof TabApp>): FakeTerminalSession {
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connected'
session.pendingApproval = true
session.pendingGate = 'tool'
app.handleVoiceTrigger('start')
return session
}
afterEach(() => {
vi.useRealTimers()
})
it('a held tool-gate "confirm" arms the approve window, then commits once after it elapses (send NOT called)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
expect(session.approve).not.toHaveBeenCalled() // armed, not yet committed
vi.advanceTimersByTime(1500)
expect(session.approve).toHaveBeenCalledTimes(1)
expect(session.send).not.toHaveBeenCalled()
})
it('a held tool-gate "拒绝" rejects immediately (fail-safe, no confirm window)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('拒绝')
expect(session.reject).toHaveBeenCalledTimes(1)
expect(session.send).not.toHaveBeenCalled()
})
it('an unmatched phrase ("deploy to prod") falls through to dictation even while pending', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('deploy to prod')
expect(session.send).toHaveBeenCalledWith('deploy to prod')
expect(session.approve).not.toHaveBeenCalled()
expect(session.reject).not.toHaveBeenCalled()
})
it('pending=false: "yes" is ordinary dictation (decision 1 — context gate)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connected'
session.pendingApproval = false
app.handleVoiceTrigger('start')
voice.onTranscript?.('yes')
expect(session.send).toHaveBeenCalledWith('yes')
expect(session.approve).not.toHaveBeenCalled()
})
it('plan gate: "confirm" falls through to dictation (decision A — tool-gate only)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connected'
session.pendingApproval = true
session.pendingGate = 'plan'
app.handleVoiceTrigger('start')
voice.onTranscript?.('confirm')
expect(session.send).toHaveBeenCalledWith('confirm')
expect(session.approve).not.toHaveBeenCalled()
})
it('stale gate: an epoch flip after PTT-start downgrades an approve to dictation (safeguard 5)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app) // captures pendingEpoch=0 at PTT-start
session.pendingEpoch = 1 // a new pending flip landed mid-utterance
voice.onTranscript?.('confirm', 0.9)
expect(session.send).toHaveBeenCalledWith('confirm')
expect(session.approve).not.toHaveBeenCalled()
})
it('tab switch: activating a different tab after PTT-start downgrades an approve to dictation (safeguard 5)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session0 = pendingToolSession(app) // PTT started while tab 0 is active
app.newTab() // tab 1 becomes active mid-utterance
const session1 = FakeTerminalSession.instances[1]!
session1.status = 'connected'
session1.pendingApproval = true
session1.pendingGate = 'tool'
voice.onTranscript?.('confirm', 0.9)
expect(session1.send).toHaveBeenCalledWith('confirm')
expect(session1.approve).not.toHaveBeenCalled()
expect(session0.approve).not.toHaveBeenCalled()
})
it('WS not connected: approve is never sent and never throws; flashes "未连接" (decision C)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connecting' // not connected
session.pendingApproval = true
session.pendingGate = 'tool'
app.handleVoiceTrigger('start')
expect(() => voice.onTranscript?.('确认', 0.9)).not.toThrow()
expect(session.approve).not.toHaveBeenCalled()
const overlay = document.getElementById('voice-confirm')
expect(overlay?.textContent).toBe('未连接')
expect(overlay?.style.display).toBe('block')
})
it('WS not connected: reject is never sent and never throws (decision C)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'reconnecting' // not connected
session.pendingApproval = true
session.pendingGate = 'tool'
app.handleVoiceTrigger('start')
expect(() => voice.onTranscript?.('拒绝')).not.toThrow()
expect(session.reject).not.toHaveBeenCalled()
})
it('a confidence below the threshold downgrades an approve phrase to dictation (decision D)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.4)
expect(session.send).toHaveBeenCalledWith('confirm')
expect(session.approve).not.toHaveBeenCalled()
})
// ── confirm-window cancellation + commit-time re-validation (review fixes) ──
it('a "拒绝" spoken during the confirm window cancels the pending approve (reject wins)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9) // arm the window
voice.onTranscript?.('拒绝') // change of mind mid-window
expect(session.reject).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled() // stale timer must NOT commit
})
it('tapping the confirm overlay cancels the pending approve before it commits', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
document.getElementById('voice-confirm')!.click() // tap-to-cancel (§4)
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled()
expect(document.getElementById('voice-confirm')!.style.display).toBe('none')
})
it('a new dictation utterance during the window supersedes (cancels) the pending approve', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
voice.onTranscript?.('deploy to prod') // user moved on
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled()
expect(session.send).toHaveBeenCalledWith('deploy to prod')
})
it('an epoch flip DURING the confirm window drops the commit (TOCTOU re-validation)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app) // epoch 0 at PTT-start AND at dispatch
voice.onTranscript?.('confirm', 0.9) // arms against epoch 0
session.pendingEpoch = 1 // gate A resolved + gate B raised during the window
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled() // must not approve the new gate
})
it('a WS drop DURING the confirm window drops the commit (no local/server desync)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
session.status = 'reconnecting' // socket dropped mid-window
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled()
})
it('re-confirming within the window re-arms and commits exactly once', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
vi.advanceTimersByTime(500)
voice.onTranscript?.('confirm', 0.9) // supersede first, re-arm
vi.advanceTimersByTime(1500)
expect(session.approve).toHaveBeenCalledTimes(1)
})
})