Files
web-terminal/test/terminal-session.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00

614 lines
22 KiB
TypeScript

// @vitest-environment jsdom
/**
* test/terminal-session.test.ts — frontend TerminalSession unit tests.
*
* Runs under jsdom with a mock WebSocket and a stubbed xterm Terminal so we can
* exercise the reconnect state machine, buildWsUrl scheme selection, the
* disposed-guard on the initialInput timer (Phase 1#4 regression), hide()
* cleanup, and status handling — without a real browser or server.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ── Stub xterm + addons (heavy DOM/canvas deps we don't need here) ────────────
class FakeTerminal {
options: Record<string, unknown> = {}
cols = 80
rows = 24
parser = { registerOscHandler: vi.fn() }
loadAddon = vi.fn()
open = vi.fn()
write = vi.fn()
focus = vi.fn()
dispose = vi.fn()
private dataCbs: Array<(d: string) => void> = []
onData = (cb: (d: string) => void) => {
this.dataCbs.push(cb)
return { dispose: vi.fn() }
}
onTitleChange = vi.fn(() => ({ dispose: vi.fn() }))
emitData(d: string): void {
for (const cb of this.dataCbs) cb(d)
}
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: class {
fit = vi.fn()
},
}))
vi.mock('@xterm/addon-search', () => ({
SearchAddon: class {
findNext = vi.fn()
findPrevious = vi.fn()
clearDecorations = vi.fn()
},
}))
vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
// ── Mock WebSocket ────────────────────────────────────────────────────────────
class MockWebSocket {
static OPEN = 1
static instances: MockWebSocket[] = []
static last(): MockWebSocket {
return MockWebSocket.instances[MockWebSocket.instances.length - 1]!
}
url: string
readyState = 0 // CONNECTING
sent: string[] = []
private listeners: Record<string, Array<(e: unknown) => void>> = {}
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
addEventListener(type: string, cb: (e: unknown) => void): void {
;(this.listeners[type] ??= []).push(cb)
}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = 3
this.fire('close', {})
}
// test helpers
fire(type: string, e: unknown): void {
for (const cb of this.listeners[type] ?? []) cb(e)
}
openIt(): void {
this.readyState = MockWebSocket.OPEN
this.fire('open', {})
}
message(data: string): void {
this.fire('message', { data })
}
}
// ResizeObserver is absent in jsdom.
class FakeResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
constructor(_cb: () => void) {}
}
// ── Wire globals, then import the module under test ───────────────────────────
beforeEach(() => {
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
const { TerminalSession } = await import('../public/terminal-session.js')
function setLocation(protocol: string, host: string): void {
vi.stubGlobal('location', { protocol, host })
}
describe('buildWsUrl scheme selection (M6)', () => {
it('uses ws:// on http pages', () => {
setLocation('http:', 'lan:3000')
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
expect(MockWebSocket.last().url).toBe('ws://lan:3000/term')
})
it('uses wss:// on https pages (Tailscale/TLS, avoids mixed content)', () => {
setLocation('https:', 'host.ts.net')
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
expect(MockWebSocket.last().url).toBe('wss://host.ts.net/term')
})
})
describe('attach handshake', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('sends attach{sessionId:null} on open for a fresh session', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({ type: 'attach', sessionId: null })
})
it('persists the assigned id from the attached frame', () => {
const onSessionId = vi.fn()
const s = new TerminalSession({ sessionId: null, onSessionId })
s.connect()
MockWebSocket.last().openIt()
MockWebSocket.last().message(JSON.stringify({ type: 'attached', sessionId: 'sid-1' }))
expect(onSessionId).toHaveBeenCalledWith('sid-1')
expect(s.id).toBe('sid-1')
})
})
describe('status frame (H3 pending approval)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('sets pendingApproval / claudeStatus from a status frame', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
MockWebSocket.last().message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }))
expect(s.claudeStatus).toBe('waiting')
expect(s.pendingApproval).toBe(true)
expect(s.pendingTool).toBe('Bash')
})
})
describe('reconnect backoff', () => {
beforeEach(() => {
setLocation('http:', 'lan:3000')
vi.useFakeTimers()
})
it('doubles the delay each attempt and caps at 30s', () => {
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
s.connect()
const delays: number[] = []
const origSetTimeout = globalThis.setTimeout
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => {
delays.push(ms ?? 0)
return origSetTimeout(fn, 0)
}) as typeof setTimeout)
// Simulate repeated close→reconnect cycles.
for (let i = 0; i < 7; i++) {
MockWebSocket.last().fire('close', {})
vi.runOnlyPendingTimers()
}
// First delay 1000, then 2000, 4000 … capped at 30000.
expect(delays[0]).toBe(1000)
expect(delays[1]).toBe(2000)
expect(delays[2]).toBe(4000)
expect(Math.max(...delays)).toBeLessThanOrEqual(30_000)
expect(delays[delays.length - 1]).toBe(30_000)
})
})
describe('initialInput timer respects dispose (Phase 1#4 regression)', () => {
beforeEach(() => {
setLocation('http:', 'lan:3000')
vi.useFakeTimers()
})
it('does NOT send the initial command if disposed before the timer fires', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), initialInput: 'claude --resume x\r' })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'attached', sessionId: 'sid' }))
ws.sent.length = 0 // drop attach/resize frames
s.dispose() // dispose BEFORE the 700ms initial-input timer fires
vi.advanceTimersByTime(2000)
// No input frame should have been sent after dispose.
const inputs = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'input')
expect(inputs).toHaveLength(0)
})
it('DOES send the initial command when not disposed', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), initialInput: 'echo hi\r' })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'attached', sessionId: 'sid' }))
ws.sent.length = 0
vi.advanceTimersByTime(800)
const inputs = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'input')
expect(inputs).toContainEqual({ type: 'input', data: 'echo hi\r' })
})
})
describe('hide() does not send a blur frame (removed in latest-writer pivot)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('hide() sends nothing and never emits type:"blur"', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.sent.length = 0
s.hide()
const blur = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'blur')
expect(blur).toHaveLength(0)
})
})
describe('dispose() tears down cleanly', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('closes the WS and does not reconnect afterwards', () => {
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
const countBefore = MockWebSocket.instances.length
s.dispose()
// A close after dispose must NOT trigger a new connection.
ws.fire('close', {})
expect(MockWebSocket.instances.length).toBe(countBefore)
})
it('connect() is a no-op after dispose', () => {
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
s.dispose()
s.connect()
expect(MockWebSocket.instances).toHaveLength(0)
})
})
describe('output + activity + send paths', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected(opts: { onActivity?: () => void } = {}) {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), ...opts })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('writes output to the terminal and fires onActivity for a hidden pane', () => {
const onActivity = vi.fn()
const { s, ws } = connected({ onActivity })
// pane starts display:none (hidden) → output triggers activity
ws.message(JSON.stringify({ type: 'output', data: 'hello' }))
expect(onActivity).toHaveBeenCalled()
// show() flips display to block → no further activity on next output
s.show()
onActivity.mockClear()
ws.message(JSON.stringify({ type: 'output', data: 'more' }))
expect(onActivity).not.toHaveBeenCalled()
})
it('send() routes keyboard bytes as an input frame', () => {
const { s, ws } = connected()
ws.sent.length = 0
s.send('ls\r')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'input', data: 'ls\r' })
})
it('approve()/reject() send their frames and clear pendingApproval', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }))
ws.sent.length = 0
s.approve()
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve' })
expect(s.pendingApproval).toBe(false)
s.reject()
expect(JSON.parse(ws.sent[1]!)).toEqual({ type: 'reject' })
})
it('ignores malformed JSON frames without throwing', () => {
const { ws } = connected()
expect(() => ws.message('{not json')).not.toThrow()
})
})
describe('exit handling', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('sets status to exited and writes the exit line', () => {
const onStatus = vi.fn()
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), onStatus })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'exit', code: 0 }))
expect(s.status).toBe('exited')
expect(onStatus).toHaveBeenCalledWith('exited')
})
it('exit with a reason includes it in the rendered line', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
expect(() => ws.message(JSON.stringify({ type: 'exit', code: -1, reason: 'spawn failed' }))).not.toThrow()
})
})
describe('search + theme passthrough', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('findNext/findPrevious/clearSearch are callable', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(() => {
s.findNext('x')
s.findPrevious('x')
s.clearSearch()
}).not.toThrow()
})
it('applyTheme updates terminal options', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(() => s.applyTheme({ background: '#000' }, 14)).not.toThrow()
})
})
describe('attach with cwd (M6 "new tab here")', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('includes cwd in the attach frame for a fresh session', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), cwd: '/work/here' })
s.connect()
MockWebSocket.last().openIt()
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({ type: 'attach', sessionId: null, cwd: '/work/here' })
})
it('omits cwd on a reconnect to an existing sessionId', () => {
const s = new TerminalSession({ sessionId: '44444444-4444-4444-8444-444444444444', onSessionId: vi.fn(), cwd: '/x' })
s.connect()
MockWebSocket.last().openIt()
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({
type: 'attach',
sessionId: '44444444-4444-4444-8444-444444444444',
})
})
})
describe('exit → press Enter reconnects with a fresh session', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('Enter after exit closes the old WS and opens a new connection (sessionId reset)', () => {
const s = new TerminalSession({ sessionId: '55555555-5555-4555-8555-555555555555', onSessionId: vi.fn() })
s.connect()
const ws1 = MockWebSocket.last()
ws1.openIt()
ws1.message(JSON.stringify({ type: 'exit', code: 0 }))
const before = MockWebSocket.instances.length
// The exit handler registers a term.onData listener; pressing Enter ('\r')
// triggers reconnect. Drive it via the fake terminal's data callback.
;(s as unknown as { term: { emitData(d: string): void } }).term.emitData('\r')
expect(MockWebSocket.instances.length).toBe(before + 1)
expect(s.id).toBeNull() // fresh session id on reconnect
})
})
describe('show()/refit() re-fit when visible', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('show() makes the pane visible and focuses without throwing', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
expect(() => s.show()).not.toThrow()
expect(s.el.style.display).toBe('block')
})
it('refit() is a no-op while hidden, runs when visible', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
expect(() => s.refit()).not.toThrow() // hidden → no-op
s.show()
expect(() => s.refit()).not.toThrow() // visible → fits
})
it('safefit returns null on NaN dims → no resize frame sent (display:none guard, §9)', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
// Make the fake terminal report NaN cols (simulating fit() while hidden).
;(s as unknown as { term: { cols: number; rows: number } }).term.cols = NaN
ws.sent.length = 0
s.show()
const resizes = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'resize')
expect(resizes).toHaveLength(0)
})
it('status frame WITHOUT pending leaves pendingApproval false', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
MockWebSocket.last().message(JSON.stringify({ type: 'status', status: 'working' }))
expect(s.claudeStatus).toBe('working')
expect(s.pendingApproval).toBe(false)
expect(s.pendingTool).toBeUndefined()
})
it('send() while the WS is closed is a no-op (guarded)', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.readyState = 3 // CLOSED
ws.sent.length = 0
s.send('x')
expect(ws.sent).toHaveLength(0)
})
})
// ── B2 telemetry frame ────────────────────────────────────────────────────────
describe('telemetry frame (B2)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected(opts: { onTelemetry?: (t: unknown) => void } = {}) {
const s = new TerminalSession({
sessionId: null,
onSessionId: vi.fn(),
onTelemetry: opts.onTelemetry as Parameters<typeof TerminalSession>[0]['onTelemetry'],
})
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('telemetry getter starts as null before any telemetry frame', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(s.telemetry).toBeNull()
})
it('fires onTelemetry callback and updates the telemetry getter', () => {
const onTelemetry = vi.fn()
const { s, ws } = connected({ onTelemetry })
const tel = { at: 1000, contextUsedPct: 42, costUsd: 0.5, model: 'claude-opus-4' }
ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))
expect(onTelemetry).toHaveBeenCalledOnce()
expect(onTelemetry).toHaveBeenCalledWith(tel)
expect(s.telemetry).toEqual(tel)
})
it('works without onTelemetry (callback is optional — no throw)', () => {
const { ws } = connected() // no onTelemetry
const tel = { at: 2000, costUsd: 0.1 }
expect(() => ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))).not.toThrow()
})
it('updates telemetry on subsequent frames (latest wins)', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1000, costUsd: 0.1 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2000, costUsd: 0.9 } }))
expect(s.telemetry).toMatchObject({ at: 2000, costUsd: 0.9 })
})
it('onTelemetry is called once per frame (not debounced)', () => {
const onTelemetry = vi.fn()
const { ws } = connected({ onTelemetry })
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 3 } }))
expect(onTelemetry).toHaveBeenCalledTimes(3)
})
})
// ── B4 pendingGate from status frame ─────────────────────────────────────────
describe('pendingGate from status frame (B4)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected() {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('pendingGate starts as null', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(s.pendingGate).toBeNull()
})
it('records pendingGate="plan" from a status message with gate:"plan"', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
expect(s.pendingGate).toBe('plan')
})
it('records pendingGate="tool" from a status message with gate:"tool"', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'tool' }))
expect(s.pendingGate).toBe('tool')
})
it('sets pendingGate to null when gate is absent from status', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true }))
expect(s.pendingGate).toBeNull()
})
it('clears pendingGate to null on a subsequent status without gate', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
expect(s.pendingGate).toBe('plan')
ws.message(JSON.stringify({ type: 'status', status: 'working' }))
expect(s.pendingGate).toBeNull()
})
})
// ── B4 approve(mode?) with PermissionMode ────────────────────────────────────
describe('approve(mode?) — B4 permission-mode relay', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected() {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
ws.sent.length = 0
return { s, ws }
}
it('approve() without mode sends {type:"approve"} with NO mode field', () => {
const { s, ws } = connected()
s.approve()
const msg = JSON.parse(ws.sent[0]!)
expect(msg).toEqual({ type: 'approve' })
expect('mode' in msg).toBe(false)
})
it('approve("acceptEdits") sends {type:"approve", mode:"acceptEdits"}', () => {
const { s, ws } = connected()
s.approve('acceptEdits')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'acceptEdits' })
})
it('approve("plan") sends {type:"approve", mode:"plan"}', () => {
const { s, ws } = connected()
s.approve('plan')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'plan' })
})
it('approve("auto") sends {type:"approve", mode:"auto"}', () => {
const { s, ws } = connected()
s.approve('auto')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'auto' })
})
it('approve("default") sends {type:"approve", mode:"default"}', () => {
const { s, ws } = connected()
s.approve('default')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'default' })
})
it('approve() clears pendingApproval regardless of mode', () => {
const { s } = connected()
expect(s.pendingApproval).toBe(true)
s.approve('acceptEdits')
expect(s.pendingApproval).toBe(false)
})
})