Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
1917 lines
76 KiB
TypeScript
1917 lines
76 KiB
TypeScript
// @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'
|
||
import type { ApprovalPreview } from '../src/types.js'
|
||
|
||
// ── 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
|
||
/** W1: bounded command/diff preview of the held tool (or null). */
|
||
pendingPreview: ApprovalPreview | 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
|
||
/** W2: pending inject-queue depth (tests set it before driving onQueue). */
|
||
queueLength = 0
|
||
/** 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
|
||
onQueue?: (n: number) => 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
|
||
onQueue?: (n: number) => 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 }))
|
||
|
||
// ── Mock the v3 cell-monitor (real one pulls in xterm; we assert wiring only) ──
|
||
const monitorHandles: Array<{ dispose: ReturnType<typeof vi.fn> }> = []
|
||
const mountCellMonitor = vi.fn((_host: HTMLElement, _id: string) => {
|
||
const handle = { dispose: vi.fn() }
|
||
monitorHandles.push(handle)
|
||
return handle
|
||
})
|
||
vi.mock('../public/cell-monitor.js', () => ({ mountCellMonitor }))
|
||
|
||
const mountPushToggle = vi.fn()
|
||
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
||
|
||
const quickReply: {
|
||
onSend?: (d: string) => void
|
||
onQueue?: (text: string, appendEnter: boolean) => void
|
||
} = {}
|
||
const mountQuickReply = vi.fn(
|
||
(
|
||
_c: HTMLElement,
|
||
opts: { onSend: (d: string) => void; onQueue?: (text: string, appendEnter: boolean) => void },
|
||
) => {
|
||
quickReply.onSend = opts.onSend
|
||
quickReply.onQueue = opts.onQueue
|
||
return { dispose: vi.fn() }
|
||
},
|
||
)
|
||
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
|
||
|
||
// ── Mock the W2 queue helper (assert enqueue routing without real fetch) ──────
|
||
const enqueueFollowupMock = vi.fn(async () => ({ ok: true, length: 1 }))
|
||
vi.mock('../public/queue.js', () => ({
|
||
enqueueFollowup: (...args: unknown[]) => enqueueFollowupMock(...(args as [])),
|
||
}))
|
||
|
||
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(),
|
||
}))
|
||
// W5: TabApp imports these worktree helpers from projects.js for launchFanout /
|
||
// keepFanoutWinner. Mocked so we drive them without real fetch and assert routing.
|
||
const createWorktreeReqMock = vi.fn(
|
||
async (_repoPath: string, branch: string) => ({ ok: true, path: `/wt/${branch}` }) as {
|
||
ok: boolean
|
||
path?: string
|
||
status?: number
|
||
error?: string
|
||
},
|
||
)
|
||
const removeWorktreeReqMock = vi.fn(
|
||
async () => ({ ok: true }) as { ok: boolean; status?: number; error?: string },
|
||
)
|
||
const validateBranchNameClientMock = vi.fn((_b: string): string | null => null)
|
||
vi.mock('../public/projects.js', () => ({
|
||
mountProjects,
|
||
createWorktreeReq: (...a: unknown[]) => createWorktreeReqMock(...(a as [string, string])),
|
||
removeWorktreeReq: (...a: unknown[]) => removeWorktreeReqMock(...(a as [])),
|
||
validateBranchNameClient: (...a: unknown[]) => validateBranchNameClientMock(...(a as [string])),
|
||
}))
|
||
|
||
// 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()
|
||
mountCellMonitor.mockClear()
|
||
monitorHandles.length = 0
|
||
mountPushToggle.mockClear()
|
||
mountQuickReply.mockClear()
|
||
mountTimeline.mockClear()
|
||
timelineDispose.mockClear()
|
||
createVoiceInput.mockClear()
|
||
voiceStart.mockClear()
|
||
voiceStop.mockClear()
|
||
quickReply.onSend = undefined
|
||
quickReply.onQueue = undefined
|
||
enqueueFollowupMock.mockClear()
|
||
// W5 fan-out mocks: reset to permissive defaults each test.
|
||
createWorktreeReqMock.mockReset()
|
||
createWorktreeReqMock.mockImplementation(async (_repoPath: string, branch: string) => ({
|
||
ok: true,
|
||
path: `/wt/${branch}`,
|
||
}))
|
||
removeWorktreeReqMock.mockReset()
|
||
removeWorktreeReqMock.mockResolvedValue({ ok: true })
|
||
validateBranchNameClientMock.mockReset()
|
||
validateBranchNameClientMock.mockReturnValue(null)
|
||
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()
|
||
vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests)
|
||
})
|
||
|
||
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 — W1 approval preview on the bar', () => {
|
||
/** Open one tab, hold a tool-gate approval carrying `preview`, render the bar. */
|
||
function openWithPreview(preview: ApprovalPreview | null, tool = 'Bash'): FakeTerminalSession {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.newTab()
|
||
const session = FakeTerminalSession.instances.at(-1)!
|
||
session.pendingApproval = true
|
||
session.pendingGate = 'tool'
|
||
session.pendingTool = tool
|
||
session.pendingPreview = preview
|
||
session.cbs.onClaudeStatus?.('waiting') // triggers updateApprovalBar
|
||
return session
|
||
}
|
||
|
||
it('renders a command preview as a .approval-cmd whose textContent is the command', () => {
|
||
openWithPreview({ kind: 'command', text: 'rm -rf /tmp/x' })
|
||
const bar = document.getElementById('approvalbar')!
|
||
const cmd = bar.querySelector('.approval-cmd')
|
||
expect(cmd).not.toBeNull()
|
||
expect(cmd!.textContent).toBe('rm -rf /tmp/x')
|
||
// Preview sits before the buttons; buttons still present (two for a tool gate).
|
||
expect(bar.querySelectorAll('button')).toHaveLength(2)
|
||
})
|
||
|
||
it('renders a diff preview via renderDiffFile (.df-file present, path shown)', () => {
|
||
const preview: ApprovalPreview = {
|
||
kind: 'diff',
|
||
file: {
|
||
oldPath: '/p/f.ts',
|
||
newPath: '/p/f.ts',
|
||
status: 'modified',
|
||
added: 1,
|
||
removed: 1,
|
||
binary: false,
|
||
hunks: [{ header: '', lines: [{ kind: 'removed', text: 'a' }, { kind: 'added', text: 'b' }] }],
|
||
},
|
||
}
|
||
openWithPreview(preview)
|
||
const bar = document.getElementById('approvalbar')!
|
||
expect(bar.querySelector('.approval-preview .df-file')).not.toBeNull()
|
||
expect(bar.querySelector('.df-path')!.textContent).toBe('/p/f.ts')
|
||
})
|
||
|
||
it('shows the "… truncated" note when preview.truncated is set', () => {
|
||
openWithPreview({ kind: 'command', text: 'x', truncated: true })
|
||
const bar = document.getElementById('approvalbar')!
|
||
expect(bar.querySelector('.approval-truncated')).not.toBeNull()
|
||
})
|
||
|
||
it('no preview → today\'s name-only bar (regression: label + 2 buttons, no preview)', () => {
|
||
openWithPreview(null, 'Bash')
|
||
const bar = document.getElementById('approvalbar')!
|
||
expect(bar.style.display).toBe('flex')
|
||
expect(bar.querySelector('.approval-preview')).toBeNull()
|
||
expect(bar.querySelectorAll('button')).toHaveLength(2)
|
||
expect(bar.querySelector('.approval-label')!.textContent).toBe('Claude wants to use Bash')
|
||
})
|
||
|
||
it('renders attacker-influenced content via textContent only (no HTML injection)', () => {
|
||
openWithPreview({ kind: 'command', text: '<img src=x onerror=alert(1)>' })
|
||
const bar = document.getElementById('approvalbar')!
|
||
const cmd = bar.querySelector('.approval-cmd')!
|
||
expect(cmd.textContent).toBe('<img src=x onerror=alert(1)>') // literal text
|
||
expect(cmd.querySelector('img')).toBeNull() // NOT parsed into an element
|
||
expect(bar.querySelector('img')).toBeNull()
|
||
})
|
||
})
|
||
|
||
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)
|
||
})
|
||
})
|
||
|
||
describe('TabApp — split-grid watch board (v1)', () => {
|
||
/** Open `n` tabs and return the TabApp + its paneHost/tabBar. */
|
||
function withTabs(n: number): {
|
||
app: InstanceType<typeof TabApp>
|
||
paneHost: HTMLElement
|
||
tabBar: HTMLElement
|
||
} {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
for (let i = 0; i < n; i++) app.newTab()
|
||
return { app, paneHost, tabBar }
|
||
}
|
||
const realCells = (host: HTMLElement): HTMLElement[] =>
|
||
[...host.querySelectorAll<HTMLElement>('.term-cell:not(.slot-empty)')]
|
||
const visibleReal = (host: HTMLElement): HTMLElement[] =>
|
||
realCells(host).filter((c) => c.style.display !== 'none')
|
||
|
||
it('defaults to single layout — one visible cell, no grid class, no placeholders', () => {
|
||
const { paneHost } = withTabs(3)
|
||
expect(paneHost.classList.contains('lay-single')).toBe(true)
|
||
expect(visibleReal(paneHost)).toHaveLength(1)
|
||
expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0)
|
||
})
|
||
|
||
it('setGridLayout("grid-4") applies the class, persists, and getGridLayout reflects it', () => {
|
||
const { app, paneHost } = withTabs(2)
|
||
app.setGridLayout('grid-4')
|
||
expect(app.getGridLayout()).toBe('grid-4')
|
||
expect(paneHost.classList.contains('lay-grid-4')).toBe(true)
|
||
expect(localStorage.getItem('web-terminal:grid-layout')).toBe('grid-4')
|
||
})
|
||
|
||
it('grid-4 with 2 tabs shows 2 live cells + 2 empty placeholders', () => {
|
||
const { app, paneHost } = withTabs(2)
|
||
app.setGridLayout('grid-4')
|
||
expect(visibleReal(paneHost)).toHaveLength(2)
|
||
expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(2)
|
||
})
|
||
|
||
it('grid-4 with 5 tabs shows exactly the first 4, none as placeholders', () => {
|
||
const { app, paneHost } = withTabs(5)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
expect(visibleReal(paneHost)).toHaveLength(4)
|
||
expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0)
|
||
})
|
||
|
||
it('exactly one visible cell carries the focus ring', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.setGridLayout('grid-4')
|
||
expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1)
|
||
})
|
||
|
||
it('clicking a quadrant (onFocus) moves the focus ring to it', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
// Simulate a pointerdown inside tab 2's pane.
|
||
const inst2 = FakeTerminalSession.instances[2]!
|
||
;(inst2.cbs as { onFocus?: () => void }).onFocus?.()
|
||
const focused = paneHost.querySelector<HTMLElement>('.term-cell.focused .cell-name')
|
||
expect(focused?.textContent).toBe('Term 3')
|
||
})
|
||
|
||
it('a non-focused pending tool-gate quadrant shows inline ✓/✗ that resolve it', () => {
|
||
const { app, paneHost } = withTabs(2)
|
||
app.focusTab(1) // focus tab 1; tab 0 is the background quadrant
|
||
app.setGridLayout('grid-4')
|
||
const bg = FakeTerminalSession.instances[0]!
|
||
bg.pendingApproval = true
|
||
bg.pendingGate = 'tool'
|
||
bg.pendingTool = 'Bash'
|
||
bg.claudeStatus = 'waiting'
|
||
bg.cbs.onClaudeStatus?.('waiting')
|
||
|
||
const cell0 = realCells(paneHost)[0]!
|
||
const approve = cell0.querySelector<HTMLButtonElement>('.cell-approve-yes')
|
||
const reject = cell0.querySelector<HTMLButtonElement>('.cell-approve-no')
|
||
expect(approve).not.toBeNull()
|
||
approve!.click()
|
||
expect(bg.approve).toHaveBeenCalledTimes(1)
|
||
reject!.click()
|
||
expect(bg.reject).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('the FOCUSED pending pane gets no inline footer (it uses the global bar)', () => {
|
||
const { app, paneHost } = withTabs(2)
|
||
app.setGridLayout('grid-4') // activeIndex = 1 (last opened)
|
||
const focused = FakeTerminalSession.instances[1]!
|
||
focused.pendingApproval = true
|
||
focused.pendingGate = 'tool'
|
||
focused.claudeStatus = 'waiting'
|
||
focused.cbs.onClaudeStatus?.('waiting')
|
||
const cell1 = realCells(paneHost)[1]!
|
||
expect(cell1.querySelector('.cell-approve')).toBeNull()
|
||
})
|
||
|
||
it('suppresses the OS notification for an on-board (visible) pending quadrant', () => {
|
||
const NotificationMock = vi.fn()
|
||
;(NotificationMock as unknown as { permission: string }).permission = 'granted'
|
||
vi.stubGlobal('Notification', NotificationMock)
|
||
const { app } = withTabs(2)
|
||
app.focusTab(1)
|
||
app.setGridLayout('grid-4')
|
||
const bg = FakeTerminalSession.instances[0]! // visible, non-focused
|
||
bg.claudeStatus = 'waiting'
|
||
bg.cbs.onClaudeStatus?.('waiting')
|
||
expect(NotificationMock).not.toHaveBeenCalled()
|
||
vi.unstubAllGlobals()
|
||
})
|
||
|
||
it('still notifies for an OFF-board tab that needs approval', () => {
|
||
const NotificationMock = vi.fn()
|
||
;(NotificationMock as unknown as { permission: string }).permission = 'granted'
|
||
vi.stubGlobal('Notification', NotificationMock)
|
||
const { app } = withTabs(5)
|
||
app.focusTab(0) // keep order; tab 4 stays off-board
|
||
app.setGridLayout('grid-4')
|
||
const off = FakeTerminalSession.instances[4]! // idx 4 — off the 2×2 board
|
||
off.claudeStatus = 'waiting'
|
||
off.cbs.onClaudeStatus?.('waiting')
|
||
expect(NotificationMock).toHaveBeenCalled()
|
||
vi.unstubAllGlobals()
|
||
})
|
||
|
||
it('a placeholder "+ New session" opens another tab', () => {
|
||
const { app, paneHost } = withTabs(2)
|
||
app.setGridLayout('grid-4')
|
||
expect(app.snapshot()).toHaveLength(2)
|
||
const slot = paneHost.querySelector<HTMLButtonElement>('.slot-empty .slot-new')!
|
||
slot.click()
|
||
expect(app.snapshot()).toHaveLength(3)
|
||
})
|
||
|
||
it('closing the focused quadrant removes its cell wrapper', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.setGridLayout('grid-4')
|
||
expect(realCells(paneHost)).toHaveLength(3)
|
||
app.closeTab(app.snapshot().findIndex((t) => t.active))
|
||
expect(realCells(paneHost)).toHaveLength(2)
|
||
})
|
||
|
||
it('narrowing back to single restores one visible cell and drops placeholders', () => {
|
||
const { app, paneHost } = withTabs(2)
|
||
app.setGridLayout('grid-4')
|
||
expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(2)
|
||
app.setGridLayout('single')
|
||
expect(paneHost.classList.contains('lay-single')).toBe(true)
|
||
expect(visibleReal(paneHost)).toHaveLength(1)
|
||
expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0)
|
||
})
|
||
|
||
// ── Board-aware focus (regression: activate() must never focus a hidden pane) ──
|
||
|
||
it('opening a new tab while the grid board is full keeps the focused pane visible', () => {
|
||
const { app, paneHost } = withTabs(4)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4') // board full (4/4)
|
||
app.newTab() // 5th tab — must be pulled onto the board, not left hidden
|
||
const focused = paneHost.querySelector<HTMLElement>('.term-cell.focused')
|
||
expect(focused).not.toBeNull()
|
||
expect(focused!.style.display).not.toBe('none')
|
||
expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1)
|
||
})
|
||
|
||
it('clicking an off-board tab in the tab bar pulls it onto the board and focuses it', () => {
|
||
const { app, tabBar } = withTabs(5)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4') // tab 4 is off the 2×2 board
|
||
const offSession = FakeTerminalSession.instances[4]! // identity survives reordering
|
||
tabBar.querySelectorAll<HTMLElement>('.tab')[4]!.dispatchEvent(
|
||
new Event('pointerdown', { bubbles: true }),
|
||
)
|
||
const cell = offSession.el.closest<HTMLElement>('.term-cell')!
|
||
expect(cell.classList.contains('focused')).toBe(true)
|
||
expect(cell.style.display).not.toBe('none')
|
||
})
|
||
|
||
it('shrinking the layout moves an off-board active pane back onto the board', () => {
|
||
const { app } = withTabs(4)
|
||
app.focusTab(3) // active index 3 (the 4th session)
|
||
const active = FakeTerminalSession.instances[3]!
|
||
app.setGridLayout('split-2') // cap 2 → index 3 is now off-board
|
||
expect(app.getGridLayout()).toBe('split-2')
|
||
const cell = active.el.closest<HTMLElement>('.term-cell')!
|
||
expect(cell.classList.contains('focused')).toBe(true)
|
||
expect(cell.style.display).not.toBe('none')
|
||
})
|
||
|
||
// ── refitVisible / show(focus) / home overlay ────────────────────────────────
|
||
|
||
it('refitVisible refits only the on-board panes', () => {
|
||
const { app } = withTabs(5)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4') // tabs 0-3 visible, tab 4 off-board
|
||
FakeTerminalSession.instances.forEach((i) => i.refit.mockClear())
|
||
app.refitVisible()
|
||
for (let i = 0; i < 4; i++) expect(FakeTerminalSession.instances[i]!.refit).toHaveBeenCalled()
|
||
expect(FakeTerminalSession.instances[4]!.refit).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('only the focused quadrant is shown with keyboard focus (focus: false for the rest)', () => {
|
||
const { app } = withTabs(3)
|
||
app.setGridLayout('grid-4') // active = tab 2 (last opened)
|
||
expect(FakeTerminalSession.instances[2]!.show.mock.lastCall).toEqual([{ focus: true }])
|
||
expect(FakeTerminalSession.instances[0]!.show.mock.lastCall).toEqual([{ focus: false }])
|
||
expect(FakeTerminalSession.instances[1]!.show.mock.lastCall).toEqual([{ focus: false }])
|
||
})
|
||
|
||
it('the ⌂ home overlay hides every grid pane, then restores the board on toggle back', () => {
|
||
const { app, paneHost, tabBar } = withTabs(3)
|
||
app.setGridLayout('grid-4')
|
||
expect(visibleReal(paneHost).length).toBeGreaterThan(0)
|
||
const home = tabBar.querySelector<HTMLButtonElement>('.tab-home')!
|
||
home.click() // overlay home
|
||
expect(visibleReal(paneHost)).toHaveLength(0)
|
||
expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0)
|
||
home.click() // back to the terminals
|
||
expect(visibleReal(paneHost)).toHaveLength(3)
|
||
expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1)
|
||
})
|
||
|
||
it('does NOT suppress the notification for a pending pane hidden behind the home overlay', () => {
|
||
const NotificationMock = vi.fn()
|
||
;(NotificationMock as unknown as { permission: string }).permission = 'granted'
|
||
vi.stubGlobal('Notification', NotificationMock)
|
||
const { app, tabBar } = withTabs(2)
|
||
app.setGridLayout('grid-4')
|
||
tabBar.querySelector<HTMLButtonElement>('.tab-home')!.click() // home overlay up
|
||
const bg = FakeTerminalSession.instances[0]! // on-board by layout, but behind home
|
||
bg.claudeStatus = 'waiting'
|
||
bg.cbs.onClaudeStatus?.('waiting')
|
||
expect(NotificationMock).toHaveBeenCalled()
|
||
vi.unstubAllGlobals()
|
||
})
|
||
|
||
// ── v2: higher-capacity layouts, focus cycling, maximize, drag-to-quadrant ────
|
||
|
||
it('grid-6 shows up to six live panes', () => {
|
||
const { app, paneHost } = withTabs(6)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-6')
|
||
expect(visibleReal(paneHost)).toHaveLength(6)
|
||
expect(paneHost.classList.contains('lay-grid-6')).toBe(true)
|
||
expect(paneHost.classList.contains('grid')).toBe(true)
|
||
})
|
||
|
||
it('row-3 shows exactly three panes', () => {
|
||
const { app, paneHost } = withTabs(5)
|
||
app.focusTab(0)
|
||
app.setGridLayout('row-3')
|
||
expect(visibleReal(paneHost)).toHaveLength(3)
|
||
})
|
||
|
||
it('cycleFocus moves the focus ring around the visible panes and wraps', () => {
|
||
const { app } = withTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4') // 3 visible, active 0
|
||
app.cycleFocus(1)
|
||
expect(app.snapshot().findIndex((t) => t.active)).toBe(1)
|
||
app.cycleFocus(1)
|
||
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
|
||
app.cycleFocus(1) // wrap
|
||
expect(app.snapshot().findIndex((t) => t.active)).toBe(0)
|
||
app.cycleFocus(-1) // reverse wrap
|
||
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
|
||
})
|
||
|
||
it('cycleFocus is a no-op in single mode', () => {
|
||
const { app } = withTabs(3) // single mode, active 2
|
||
app.cycleFocus(1)
|
||
expect(app.snapshot().findIndex((t) => t.active)).toBe(2)
|
||
})
|
||
|
||
it('toggleMaximize marks the focused cell and toggling off clears it', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.setGridLayout('grid-4')
|
||
app.toggleMaximize()
|
||
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
|
||
expect(paneHost.querySelector('.term-cell.maximized')!.classList.contains('focused')).toBe(true)
|
||
app.toggleMaximize()
|
||
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
|
||
})
|
||
|
||
it('maximize follows the focused pane as you cycle', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
app.toggleMaximize()
|
||
const first = FakeTerminalSession.instances[0]!.el.closest('.term-cell')
|
||
expect(first!.classList.contains('maximized')).toBe(true)
|
||
app.cycleFocus(1) // now tab 1 is focused
|
||
expect(first!.classList.contains('maximized')).toBe(false)
|
||
expect(FakeTerminalSession.instances[1]!.el.closest('.term-cell')!.classList.contains('maximized')).toBe(true)
|
||
})
|
||
|
||
it('changing layout exits maximize', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.setGridLayout('grid-4')
|
||
app.toggleMaximize()
|
||
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
|
||
app.setGridLayout('split-2')
|
||
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
|
||
})
|
||
|
||
it('dragging an off-board tab onto a quadrant assigns it there and focuses it', () => {
|
||
const { app, paneHost, tabBar } = withTabs(5)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4') // tab 4 off-board
|
||
const dragged = FakeTerminalSession.instances[4]!
|
||
const targetCell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.term-cell')!
|
||
// Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0.
|
||
tabBar.querySelectorAll<HTMLElement>('.tab')[4]!.dispatchEvent(
|
||
new Event('dragstart', { bubbles: true }),
|
||
)
|
||
targetCell.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||
targetCell.dispatchEvent(new Event('drop', { bubbles: true }))
|
||
const cell = dragged.el.closest<HTMLElement>('.term-cell')!
|
||
expect(cell.style.display).not.toBe('none') // now on-board
|
||
expect(cell.classList.contains('focused')).toBe(true) // and focused
|
||
})
|
||
|
||
it('closing a tab while a quadrant is maximized clears the maximized state', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
app.toggleMaximize()
|
||
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1)
|
||
app.closeTab(1) // close a different (non-focused) tab
|
||
expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0)
|
||
})
|
||
|
||
it('drag-to-quadrant is a no-op in single mode (no reorder, no drag-target)', () => {
|
||
const { app, paneHost, tabBar } = withTabs(3) // default single layout
|
||
const before = app.snapshot().map((t) => t.title)
|
||
tabBar.querySelectorAll<HTMLElement>('.tab')[1]!.dispatchEvent(
|
||
new Event('dragstart', { bubbles: true }),
|
||
)
|
||
const cell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.term-cell')!
|
||
cell.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||
cell.dispatchEvent(new Event('drop', { bubbles: true }))
|
||
expect(cell.classList.contains('drag-target')).toBe(false)
|
||
expect(app.snapshot().map((t) => t.title)).toEqual(before) // order unchanged
|
||
})
|
||
|
||
it('the ⛶ button focuses its quadrant and maximizes it in one click', () => {
|
||
const { app } = withTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
const cell2 = FakeTerminalSession.instances[2]!.el.closest<HTMLElement>('.term-cell')!
|
||
const maxBtn = cell2.querySelector<HTMLButtonElement>('.cell-max')!
|
||
expect(maxBtn.textContent).toBe('⛶')
|
||
maxBtn.click()
|
||
expect(cell2.classList.contains('focused')).toBe(true) // focused the clicked quadrant
|
||
expect(cell2.classList.contains('maximized')).toBe(true) // and maximized it
|
||
expect(maxBtn.textContent).toBe('⤡') // glyph flips to restore
|
||
})
|
||
|
||
// ── v3: read-only monitor quadrants ──────────────────────────────────────────
|
||
|
||
/** Open n tabs, give each a server id (as if attached), and return the app. */
|
||
function withAttachedTabs(n: number): {
|
||
app: InstanceType<typeof TabApp>
|
||
paneHost: HTMLElement
|
||
tabBar: HTMLElement
|
||
} {
|
||
const hosts = withTabs(n)
|
||
FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`))
|
||
return hosts
|
||
}
|
||
const monBtnOf = (paneHost: HTMLElement, i: number): HTMLButtonElement =>
|
||
FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-monitor-btn')!
|
||
|
||
it('the 👁 button switches a quadrant to a read-only monitor preview', () => {
|
||
const { app, paneHost } = withAttachedTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
const cell1 = FakeTerminalSession.instances[1]!.el.closest<HTMLElement>('.term-cell')!
|
||
monBtnOf(paneHost, 1).click()
|
||
expect(mountCellMonitor).toHaveBeenCalled()
|
||
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1') // polls that session
|
||
expect(cell1.querySelector('.cell-monitor')).not.toBeNull()
|
||
expect(monBtnOf(paneHost, 1).classList.contains('active')).toBe(true)
|
||
})
|
||
|
||
it('toggling monitor off disposes the preview and restores the live pane', () => {
|
||
const { app, paneHost } = withAttachedTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
monBtnOf(paneHost, 1).click() // on
|
||
expect(monitorHandles).toHaveLength(1)
|
||
monBtnOf(paneHost, 1).click() // off
|
||
expect(monitorHandles[0]!.dispose).toHaveBeenCalled()
|
||
const cell1 = FakeTerminalSession.instances[1]!.el.closest<HTMLElement>('.term-cell')!
|
||
expect(cell1.querySelector('.cell-monitor')).toBeNull()
|
||
expect(monBtnOf(paneHost, 1).classList.contains('active')).toBe(false)
|
||
})
|
||
|
||
it('closing a monitored quadrant disposes its monitor', () => {
|
||
const { app, paneHost } = withAttachedTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
monBtnOf(paneHost, 1).click()
|
||
app.closeTab(1)
|
||
expect(monitorHandles[0]!.dispose).toHaveBeenCalled()
|
||
})
|
||
|
||
it('leaving grid mode stops any monitor', () => {
|
||
const { app, paneHost } = withAttachedTabs(3)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
monBtnOf(paneHost, 1).click()
|
||
expect(monitorHandles).toHaveLength(1)
|
||
app.setGridLayout('single')
|
||
expect(monitorHandles[0]!.dispose).toHaveBeenCalled()
|
||
})
|
||
|
||
// ── v3: resizable splitters ──────────────────────────────────────────────────
|
||
|
||
it('grid-4 renders one column + one row splitter and sets an fr template', () => {
|
||
const { app, paneHost } = withTabs(4)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(1)
|
||
expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1)
|
||
expect(paneHost.style.gridTemplateColumns).toBe('1fr 1fr')
|
||
expect(paneHost.style.gridTemplateRows).toBe('1fr 1fr')
|
||
})
|
||
|
||
it('grid-6 renders two column + one row splitters', () => {
|
||
const { app, paneHost } = withTabs(6)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-6')
|
||
expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(2)
|
||
expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1)
|
||
})
|
||
|
||
it('single mode has no splitters and clears the inline grid template', () => {
|
||
const { app, paneHost } = withTabs(3)
|
||
app.setGridLayout('grid-4')
|
||
app.setGridLayout('single')
|
||
expect(paneHost.querySelectorAll('.grid-gutter')).toHaveLength(0)
|
||
expect(paneHost.style.gridTemplateColumns).toBe('')
|
||
})
|
||
|
||
it('a persisted custom split is applied to the grid template on load', () => {
|
||
localStorage.setItem(
|
||
'web-terminal:grid-splits',
|
||
JSON.stringify({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }),
|
||
)
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.newTab()
|
||
app.newTab()
|
||
app.setGridLayout('grid-4')
|
||
expect(paneHost.style.gridTemplateColumns).toBe('1.5fr 0.5fr')
|
||
})
|
||
|
||
it('dragging a column splitter re-templates the grid and persists on release', () => {
|
||
const { app, paneHost } = withTabs(4)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
// jsdom has no layout — give #term a measurable width for the drag math.
|
||
vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({
|
||
width: 800,
|
||
height: 600,
|
||
left: 0,
|
||
top: 0,
|
||
right: 800,
|
||
bottom: 600,
|
||
x: 0,
|
||
y: 0,
|
||
toJSON: () => ({}),
|
||
} as DOMRect)
|
||
const gutter = paneHost.querySelector<HTMLElement>('.grid-gutter-col')!
|
||
gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true }))
|
||
gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) // +0.2fr
|
||
gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||
expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr')
|
||
const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!)
|
||
expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2)
|
||
})
|
||
|
||
it('a re-render during a gutter drag does not destroy the dragged handle', () => {
|
||
const { app, paneHost } = withTabs(4)
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({
|
||
width: 800,
|
||
height: 600,
|
||
left: 0,
|
||
top: 0,
|
||
right: 800,
|
||
bottom: 600,
|
||
x: 0,
|
||
y: 0,
|
||
toJSON: () => ({}),
|
||
} as DOMRect)
|
||
const gutter = paneHost.querySelector<HTMLElement>('.grid-gutter-col')!
|
||
gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true }))
|
||
app.toggleMaximize() // triggers applyLayout → renderGutters (must skip while dragging)
|
||
expect(paneHost.contains(gutter)).toBe(true) // same handle survives
|
||
gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true }))
|
||
gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||
expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr')
|
||
})
|
||
|
||
it('a monitor toggled before attach engages once the session id arrives', () => {
|
||
const { app } = withTabs(3) // fresh sessions — ids are null until attached
|
||
app.focusTab(0)
|
||
app.setGridLayout('grid-4')
|
||
const inst = FakeTerminalSession.instances[1]!
|
||
inst.el.closest('.term-cell')!.querySelector<HTMLButtonElement>('.cell-monitor-btn')!.click()
|
||
expect(mountCellMonitor).not.toHaveBeenCalled() // id still null → not engaged
|
||
inst.id = 'sess-1' // simulate 'attached'
|
||
;(inst.cbs as { onSessionId?: (id: string) => void }).onSessionId?.('sess-1')
|
||
expect(mountCellMonitor).toHaveBeenCalled()
|
||
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1')
|
||
})
|
||
})
|
||
|
||
// ── W2: inject-queue badge + enqueue affordance ──────────────────────────────
|
||
describe('TabApp — W2 inject queue', () => {
|
||
it('a queue frame (onQueue) updates the active tab’s ⧗N badge in place', () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.newTab()
|
||
const inst = FakeTerminalSession.instances[0]!
|
||
|
||
// No queued items → empty badge.
|
||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
|
||
|
||
// Server broadcasts depth 3 → badge shows ⧗3.
|
||
inst.queueLength = 3
|
||
inst.cbs.onQueue?.(3)
|
||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('⧗3')
|
||
|
||
// Drains back to 0 → badge clears.
|
||
inst.queueLength = 0
|
||
inst.cbs.onQueue?.(0)
|
||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
|
||
})
|
||
|
||
it('enqueueToActive routes to enqueueFollowup with the active session id', () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.openSession('11111111-1111-4111-8111-111111111111')
|
||
|
||
app.enqueueToActive('run tests', true)
|
||
|
||
expect(enqueueFollowupMock).toHaveBeenCalledWith(
|
||
'11111111-1111-4111-8111-111111111111',
|
||
'run tests',
|
||
true,
|
||
)
|
||
})
|
||
|
||
it('the quick-reply Queue affordance enqueues for the active session', () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.openSession('22222222-2222-4222-8222-222222222222')
|
||
|
||
// The onQueue handler wired into mountQuickReply routes through enqueueToActive.
|
||
quickReply.onQueue?.('open a PR', true)
|
||
|
||
expect(enqueueFollowupMock).toHaveBeenCalledWith(
|
||
'22222222-2222-4222-8222-222222222222',
|
||
'open a PR',
|
||
true,
|
||
)
|
||
})
|
||
|
||
it('enqueueToActive is a no-op when no session has attached (id null)', () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.newTab() // fresh session → id null until server assigns
|
||
|
||
app.enqueueToActive('x', false)
|
||
expect(enqueueFollowupMock).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
// ── W5: fan-out board (launchFanout + keepFanoutWinner) ──────────────────────
|
||
describe('TabApp — W5 fan-out board', () => {
|
||
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
|
||
|
||
/** Fan out N lanes and stamp server ids on the resulting sessions. */
|
||
async function launched(
|
||
app: InstanceType<typeof TabApp>,
|
||
lanes: number,
|
||
branchBase = 'feat',
|
||
): Promise<void> {
|
||
await app.launchFanout('/repo', 'repo', { prompt: 'add feature', lanes, branchBase, mode: 'default' })
|
||
FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`))
|
||
}
|
||
|
||
const keepBtnOf = (i: number): HTMLButtonElement =>
|
||
FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-keep')!
|
||
|
||
it('launchFanout creates N worktrees SEQUENTIALLY, one tab per lane, on a grid-4 board', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
|
||
await app.launchFanout('/repo', 'repo', {
|
||
prompt: 'add feature',
|
||
lanes: 3,
|
||
branchBase: 'feat',
|
||
mode: 'default',
|
||
})
|
||
|
||
expect(app.snapshot()).toHaveLength(3)
|
||
expect(FakeTerminalSession.instances).toHaveLength(3)
|
||
// Sequential, in lane order.
|
||
expect(createWorktreeReqMock).toHaveBeenCalledTimes(3)
|
||
expect(createWorktreeReqMock.mock.calls.map((c) => c[1])).toEqual([
|
||
'feat-lane-1',
|
||
'feat-lane-2',
|
||
'feat-lane-3',
|
||
])
|
||
// Same shell-quoted launch command injected into EVERY lane.
|
||
for (const inst of FakeTerminalSession.instances) {
|
||
expect(inst.initialInput).toBe("claude 'add feature'\r")
|
||
}
|
||
// Each lane spawns in its own worktree cwd.
|
||
const cwds = FakeTerminalSession.instances.map(
|
||
(i) => (i.cbs as Record<string, unknown>)['cwd'],
|
||
)
|
||
expect(cwds).toEqual(['/wt/feat-lane-1', '/wt/feat-lane-2', '/wt/feat-lane-3'])
|
||
expect(app.getGridLayout()).toBe('grid-4')
|
||
})
|
||
|
||
it('launchFanout uses grid-6 for more than four lanes', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await launched(app, 5)
|
||
expect(app.snapshot()).toHaveLength(5)
|
||
expect(app.getGridLayout()).toBe('grid-6')
|
||
})
|
||
|
||
it('launchFanout refuses an invalid branch base (no worktrees, banner shown)', async () => {
|
||
validateBranchNameClientMock.mockReturnValue('bad branch')
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: '-bad', mode: 'default' })
|
||
expect(createWorktreeReqMock).not.toHaveBeenCalled()
|
||
expect(app.snapshot()).toHaveLength(0)
|
||
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
|
||
})
|
||
|
||
it('launchFanout tolerates a partial failure — records it in the banner, never throws', async () => {
|
||
createWorktreeReqMock.mockImplementation(async (_repo: string, branch: string) =>
|
||
branch.endsWith('-2')
|
||
? { ok: false, status: 500, error: 'boom' }
|
||
: { ok: true, path: `/wt/${branch}` },
|
||
)
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await expect(
|
||
app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' }),
|
||
).resolves.toBeUndefined()
|
||
expect(app.snapshot()).toHaveLength(2) // lanes 1 and 3 created
|
||
const banner = document.getElementById('fanout-banner')!
|
||
expect(banner.style.display).toBe('block')
|
||
expect(banner.textContent).toContain('Started 2 of 3')
|
||
})
|
||
|
||
it('every created lane cell shows a 🏆 Keep button (fan-out lanes only)', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await launched(app, 3)
|
||
for (let i = 0; i < 3; i++) expect(keepBtnOf(i).style.display).not.toBe('none')
|
||
})
|
||
|
||
it('a plain (non-fan-out) tab has no visible Keep button', () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
app.newTab()
|
||
const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')!
|
||
expect((cell.querySelector('.cell-keep') as HTMLElement).style.display).toBe('none')
|
||
})
|
||
|
||
it('keepFanoutWinner keeps the winner, closes losers, removes their worktrees, layout → single', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await launched(app, 3)
|
||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||
|
||
keepBtnOf(0).click() // keep sess-0 (feat-lane-1)
|
||
await flush()
|
||
|
||
expect(app.snapshot()).toHaveLength(1) // only the winner remains
|
||
expect(app.activeSessionId()).toBe('sess-0')
|
||
// One DELETE per loser, force=false, with the loser's worktree path.
|
||
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
|
||
const paths = removeWorktreeReqMock.mock.calls.map((c) => (c as unknown[])[1]).sort()
|
||
expect(paths).toEqual(['/wt/feat-lane-2', '/wt/feat-lane-3'])
|
||
expect(removeWorktreeReqMock.mock.calls.every((c) => (c as unknown[])[2] === false)).toBe(true)
|
||
expect(app.getGridLayout()).toBe('single')
|
||
})
|
||
|
||
it('keepFanoutWinner force-removes a dirty (409) loser without a second confirm', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await launched(app, 2)
|
||
removeWorktreeReqMock
|
||
.mockResolvedValueOnce({ ok: false, status: 409, error: 'dirty' })
|
||
.mockResolvedValueOnce({ ok: true })
|
||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||
|
||
keepBtnOf(0).click() // keep sess-0; one loser (sess-1)
|
||
await flush()
|
||
|
||
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
|
||
expect((removeWorktreeReqMock.mock.calls[0] as unknown[])[2]).toBe(false) // first try
|
||
expect((removeWorktreeReqMock.mock.calls[1] as unknown[])[2]).toBe(true) // forced retry
|
||
expect(confirmSpy).toHaveBeenCalledTimes(1) // only the batch confirm
|
||
})
|
||
|
||
it('keepFanoutWinner cancelled → no tabs closed, no worktree removed', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
await launched(app, 3)
|
||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||
|
||
keepBtnOf(0).click()
|
||
await flush()
|
||
|
||
expect(app.snapshot()).toHaveLength(3) // nothing closed
|
||
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keepFanoutWinner refuses when the winner has not attached (null id) — deletes nothing', async () => {
|
||
const { paneHost, tabBar } = makeHosts()
|
||
const app = new TabApp(paneHost, tabBar)
|
||
// Fan out but do NOT stamp ids (sessions still null).
|
||
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' })
|
||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||
|
||
keepBtnOf(0).click() // entry.session.id is null → winnerSessionId ''
|
||
await flush()
|
||
|
||
expect(confirmSpy).not.toHaveBeenCalled() // refused before the confirm
|
||
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
|
||
expect(app.snapshot()).toHaveLength(3)
|
||
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
|
||
})
|
||
})
|