Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now renders above the approval bar, on every attached device, riding the same broadcast + late-joiner rails as the existing `gate` field. - src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview — Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null. Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines / 200 chars/line / 4KB (security limits, UTF-8-safe byte clamp). - src/types.ts: additive optional `preview?: ApprovalPreview` on the status ServerMessage + handleHookEvent (older clients ignore it). - src/server.ts /hook/permission: derive preview from tool_input, store on the PendingApproval entry, re-send to late joiners exactly like `gate`. - manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre> textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools / plan gates fall back to today's name-only bar. Attacker-influenced tool input → rendered via textContent/diff-renderer only, never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
886 lines
32 KiB
TypeScript
886 lines
32 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'
|
|
|
|
// Captures the handler passed to WebLinksAddon so a test can assert the hardened
|
|
// activation handler is wired (W1). vi.hoisted so the mock factory can see it.
|
|
const webLinksState = vi.hoisted(
|
|
() => ({ handler: undefined as ((e: MouseEvent, uri: string) => void) | undefined }),
|
|
)
|
|
|
|
// A link registered via the (mocked) provider — the shape terminal-session builds.
|
|
interface TestLink {
|
|
text: string
|
|
range: { start: { x: number; y: number }; end: { x: number; y: number } }
|
|
activate: (event: MouseEvent, text: string) => void
|
|
}
|
|
interface TestLinkProvider {
|
|
provideLinks: (bufferLineNumber: number, callback: (links: TestLink[] | undefined) => void) => void
|
|
}
|
|
|
|
// ── Stub xterm + addons (heavy DOM/canvas deps we don't need here) ────────────
|
|
class FakeTerminal {
|
|
options: Record<string, unknown> = {}
|
|
cols = 80
|
|
rows = 24
|
|
/** Text the fake buffer returns for any getLine() (drives the link provider). */
|
|
lineText = ''
|
|
/** The link provider registered by TerminalSession (W1). */
|
|
captured: TestLinkProvider | undefined = undefined
|
|
/** OSC handlers registered by TerminalSession, keyed by code (7 = cwd). */
|
|
oscHandlers: Record<number, (data: string) => boolean> = {}
|
|
parser = {
|
|
registerOscHandler: vi.fn((code: number, handler: (data: string) => boolean) => {
|
|
this.oscHandlers[code] = handler
|
|
}),
|
|
}
|
|
buffer = {
|
|
active: {
|
|
getLine: (_y: number) => ({ translateToString: (_trim?: boolean) => this.lineText }),
|
|
},
|
|
}
|
|
registerLinkProvider = vi.fn((p: TestLinkProvider) => {
|
|
this.captured = p
|
|
return { dispose: 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 {
|
|
constructor(handler?: (e: MouseEvent, uri: string) => void) {
|
|
webLinksState.handler = handler
|
|
}
|
|
},
|
|
}))
|
|
|
|
// ── 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, openWebLink } = await import('../public/terminal-session.js')
|
|
|
|
function term(s: unknown): FakeTerminal {
|
|
return (s as unknown as { term: FakeTerminal }).term
|
|
}
|
|
|
|
/** Drive the captured link provider for one buffer row, returning its links. */
|
|
function provideLinks(t: FakeTerminal, bufferLineNumber = 1): TestLink[] | undefined {
|
|
let result: TestLink[] | undefined
|
|
t.captured!.provideLinks(bufferLineNumber, (links) => {
|
|
result = links
|
|
})
|
|
return result
|
|
}
|
|
|
|
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('W1 approval preview on the status frame', () => {
|
|
beforeEach(() => setLocation('http:', 'lan:3000'))
|
|
|
|
function openSession(): { s: InstanceType<typeof TerminalSession>; ws: ReturnType<typeof MockWebSocket.last> } {
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
s.connect()
|
|
const ws = MockWebSocket.last()
|
|
ws.openIt()
|
|
return { s, ws }
|
|
}
|
|
|
|
it('sets pendingPreview from a pending status frame', () => {
|
|
const { s, ws } = openSession()
|
|
ws.message(
|
|
JSON.stringify({
|
|
type: 'status',
|
|
status: 'waiting',
|
|
detail: 'Bash',
|
|
pending: true,
|
|
gate: 'tool',
|
|
preview: { kind: 'command', text: 'ls -la' },
|
|
}),
|
|
)
|
|
expect(s.pendingPreview).toEqual({ kind: 'command', text: 'ls -la' })
|
|
})
|
|
|
|
it('clears pendingPreview when a follow-up status is not pending', () => {
|
|
const { s, ws } = openSession()
|
|
ws.message(
|
|
JSON.stringify({ type: 'status', status: 'waiting', pending: true, preview: { kind: 'command', text: 'ls' } }),
|
|
)
|
|
expect(s.pendingPreview).not.toBeNull()
|
|
ws.message(JSON.stringify({ type: 'status', status: 'working', pending: false }))
|
|
expect(s.pendingPreview).toBeNull()
|
|
})
|
|
|
|
it('leaves pendingPreview null when a pending status carries no preview', () => {
|
|
const { s, ws } = openSession()
|
|
ws.message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'WebFetch', pending: true, gate: 'tool' }))
|
|
expect(s.pendingApproval).toBe(true)
|
|
expect(s.pendingPreview).toBeNull()
|
|
})
|
|
|
|
it('approve() clears the held preview locally (resolved)', () => {
|
|
const { s, ws } = openSession()
|
|
ws.message(
|
|
JSON.stringify({ type: 'status', status: 'waiting', pending: true, preview: { kind: 'command', text: 'ls' } }),
|
|
)
|
|
s.approve()
|
|
expect(s.pendingPreview).toBeNull()
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
// ── W1 clickable file paths (custom link provider) ────────────────────────────
|
|
|
|
describe('clickable file paths (W1)', () => {
|
|
beforeEach(() => setLocation('http:', 'lan:3000'))
|
|
|
|
it('registers a link provider on construction', () => {
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
expect(term(s).registerLinkProvider).toHaveBeenCalledOnce()
|
|
expect(term(s).captured).toBeTruthy()
|
|
})
|
|
|
|
it('provideLinks yields one link matching findPathMatches (text + 1-based range)', () => {
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.lineText = 'see src/app.ts:42 for'
|
|
const links = provideLinks(t, 1)!
|
|
expect(links).toHaveLength(1)
|
|
expect(links[0]!.text).toBe('src/app.ts:42')
|
|
expect(links[0]!.range).toEqual({ start: { x: 5, y: 1 }, end: { x: 17, y: 1 } })
|
|
})
|
|
|
|
it('provideLinks returns undefined for a line with no paths', () => {
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.lineText = 'just some plain words'
|
|
expect(provideLinks(t, 1)).toBeUndefined()
|
|
})
|
|
|
|
it('provideLinks returns undefined when the buffer row is missing', () => {
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.buffer.active.getLine = () => undefined as never
|
|
expect(provideLinks(t, 999)).toBeUndefined()
|
|
})
|
|
|
|
it('activate() POSTs {file,line} for an absolute path (no cwd needed)', () => {
|
|
const fetchMock = vi.fn(async (_u: string, _o: RequestInit) => ({ ok: true }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.lineText = 'open /home/u/app.ts:42 now'
|
|
const links = provideLinks(t, 1)!
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
const call = fetchMock.mock.calls[0]!
|
|
expect(call[0]).toBe('/open-in-editor')
|
|
expect(call[1].method).toBe('POST')
|
|
expect(JSON.parse(String(call[1].body))).toEqual({ file: '/home/u/app.ts', line: 42 })
|
|
})
|
|
|
|
it('resolves a relative path against the OSC-7 cwd', () => {
|
|
const fetchMock = vi.fn(async (_u: string, _o: RequestInit) => ({ ok: true }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.oscHandlers[7]!('file:///work/here') // set cwd via the real OSC-7 handler
|
|
t.lineText = 'edit src/app.ts:42'
|
|
const links = provideLinks(t, 1)!
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]![1].body))).toEqual({
|
|
file: '/work/here/src/app.ts',
|
|
line: 42,
|
|
})
|
|
})
|
|
|
|
it('collapses ../ against the cwd', () => {
|
|
const fetchMock = vi.fn(async (_u: string, _o: RequestInit) => ({ ok: true }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.oscHandlers[7]!('file:///work/here')
|
|
t.lineText = 'see ../lib/util.ts:7'
|
|
const links = provideLinks(t, 1)!
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
expect(JSON.parse(String(fetchMock.mock.calls[0]![1].body))).toEqual({
|
|
file: '/work/lib/util.ts',
|
|
line: 7,
|
|
})
|
|
})
|
|
|
|
it('relative path with null cwd → no fetch, writes a status line', () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: true }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.lineText = 'edit src/app.ts:42'
|
|
const links = provideLinks(t, 1)!
|
|
t.write.mockClear()
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
expect(t.write).toHaveBeenCalled()
|
|
expect(String(t.write.mock.calls[0]![0])).toContain('working dir unknown')
|
|
})
|
|
|
|
it('in-flight guard: two rapid activate calls → one fetch', () => {
|
|
const fetchMock = vi.fn(() => new Promise<Response>(() => {})) // never resolves
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.lineText = 'open /a/b.ts:1'
|
|
const links = provideLinks(t, 1)!
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('writes a status line when the server rejects the open (non-ok)', async () => {
|
|
const fetchMock = vi.fn(async () => ({ ok: false, status: 404 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const t = term(s)
|
|
t.lineText = 'open /home/u/app.ts:42'
|
|
const links = provideLinks(t, 1)!
|
|
t.write.mockClear()
|
|
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
|
await new Promise((r) => setTimeout(r, 0)) // flush the fetch().then() chain
|
|
expect(t.write).toHaveBeenCalled()
|
|
expect(String(t.write.mock.calls[0]![0])).toContain('404')
|
|
})
|
|
|
|
it('disposes the link provider on dispose()', () => {
|
|
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
const disposable = term(s).registerLinkProvider.mock.results[0]!.value as {
|
|
dispose: ReturnType<typeof vi.fn>
|
|
}
|
|
s.dispose()
|
|
expect(disposable.dispose).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ── W1 URL activation hardening ───────────────────────────────────────────────
|
|
|
|
describe('openWebLink (W1 URL hardening)', () => {
|
|
it('passes a handler function to WebLinksAddon (not the default activation)', () => {
|
|
new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
|
expect(typeof webLinksState.handler).toBe('function')
|
|
})
|
|
|
|
it('opens http/https/mailto with noopener,noreferrer', () => {
|
|
const openSpy = vi.fn()
|
|
vi.stubGlobal('open', openSpy)
|
|
openWebLink('https://example.com/x')
|
|
expect(openSpy).toHaveBeenCalledWith('https://example.com/x', '_blank', 'noopener,noreferrer')
|
|
openWebLink('http://lan:3000')
|
|
openWebLink('mailto:a@b.com')
|
|
expect(openSpy).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('blocks javascript: / data: / file: URIs (no window.open)', () => {
|
|
const openSpy = vi.fn()
|
|
vi.stubGlobal('open', openSpy)
|
|
openWebLink('javascript:alert(1)')
|
|
openWebLink('data:text/html,<script>alert(1)</script>')
|
|
openWebLink('file:///etc/passwd')
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignores an unparseable URI without throwing', () => {
|
|
const openSpy = vi.fn()
|
|
vi.stubGlobal('open', openSpy)
|
|
expect(() => openWebLink('not a url')).not.toThrow()
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|