// @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): 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) }) })