// @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 = {} 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 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 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('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[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(() => {})) // 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 } 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,') 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() }) })