fix: address review report across security, architecture, quality, tests
Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review). typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage thresholds (80%) enforced in vitest.config.ts. Critical: - multi-device approval race: release held approval only when the last client detaches (closing one mirror no longer cancels another's prompt) - unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS), enforced in manager via the existing M4 exit(-1) path - signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close() - terminal-session initialInput timer tracked + cleared on dispose - tabs.addEntry null-as-cast type hole removed (build session before entry) Should-fix: - security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id] - history.ts converted to fs/promises (async /sessions handler) - removed dead clientDims map + blur protocol message end-to-end - per-connection WS message rate limit (Config.maxMsgsPerSec) - /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7) Tests: - new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites - extended history/config/manager/integration coverage incl. regressions Hygiene: - parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation - log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped - operational constants moved into Config - extracted public/preview-grid.ts (DRY launcher/manage) - doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
458
test/terminal-session.test.ts
Normal file
458
test/terminal-session.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
// @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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user