import { describe, expect, it } from 'vitest' import { mountTerminalView, type TerminalLike } from '../src/terminal-view' import { decodeServerMessage, type ClientMessage } from '../src/protocol' import type { TerminalTransport } from '../src/ws-transport' const decoder = new TextDecoder() function decodeClient(bytes: Uint8Array): ClientMessage { return JSON.parse(decoder.decode(bytes)) as ClientMessage } /** In-memory transport capturing sent frames and exposing a way to push incoming bytes. */ function fakeTransport(): TerminalTransport & { sent: ClientMessage[]; push(m: object): void; closed: boolean } { let msgCb: ((b: Uint8Array) => void) | null = null const sent: ClientMessage[] = [] return { sent, closed: false, open: async () => {}, send: (bytes) => sent.push(decodeClient(bytes)), onMessage: (cb) => { msgCb = cb }, onClose: () => {}, close() { this.closed = true }, push(m: object) { msgCb?.(new TextEncoder().encode(JSON.stringify(m))) }, } } /** Mock xterm; `fitCalls` records fit() invocations, `dataCb` fires simulated keystrokes. */ function fakeTerminal(): TerminalLike & { written: string[] fitCalls: number typeInto(data: string): void } { let onDataCb: ((d: string) => void) | null = null return { cols: 80, rows: 24, written: [], fitCalls: 0, open: () => {}, write(data: string) { this.written.push(data) }, onData: (cb) => { onDataCb = cb }, fit() { this.fitCalls++ }, dispose: () => {}, typeInto(data: string) { onDataCb?.(data) }, } } function realDims(el: HTMLElement): void { Object.defineProperty(el, 'clientWidth', { value: 640, configurable: true }) Object.defineProperty(el, 'clientHeight', { value: 480, configurable: true }) } describe('mountTerminalView (T4)', () => { it('sends attach FIRST, then a resize frame once the container has real dimensions', () => { const root = document.createElement('div') realDims(root) const transport = fakeTransport() const term = fakeTerminal() mountTerminalView(root, transport, { createTerminal: () => term }) expect(transport.sent[0]).toEqual({ type: 'attach', sessionId: null }) const resize = transport.sent.find((m) => m.type === 'resize') expect(resize).toEqual({ type: 'resize', cols: 80, rows: 24 }) expect(term.fitCalls).toBeGreaterThan(0) }) it('keypress → input frame; Enter carries "\\r" verbatim (not "\\n")', () => { const root = document.createElement('div') realDims(root) const transport = fakeTransport() const term = fakeTerminal() mountTerminalView(root, transport, { createTerminal: () => term }) term.typeInto('\r') const input = transport.sent.find((m) => m.type === 'input') expect(input).toEqual({ type: 'input', data: '\r' }) expect(JSON.stringify(input)).not.toContain('\\n') }) it('incoming output frame → xterm.write; attached/exit are not written as output', () => { const root = document.createElement('div') realDims(root) const transport = fakeTransport() const term = fakeTerminal() mountTerminalView(root, transport, { createTerminal: () => term }) transport.push({ type: 'output', data: 'hello' }) transport.push({ type: 'attached', sessionId: 'x' }) expect(term.written).toEqual(['hello']) }) it('does NOT call fit() while the container is display:none (guards NaN dims)', () => { const root = document.createElement('div') // clientWidth/Height default to 0 in jsdom const transport = fakeTransport() const term = fakeTerminal() mountTerminalView(root, transport, { createTerminal: () => term }) expect(term.fitCalls).toBe(0) expect(transport.sent.some((m) => m.type === 'resize')).toBe(false) }) it('the same seam mounts identically over any TerminalTransport (drop-in proof)', () => { const root = document.createElement('div') realDims(root) const transport = fakeTransport() const view = mountTerminalView(root, transport, { createTerminal: fakeTerminal }) view.dispose() expect(transport.closed).toBe(true) }) it('server messages decode via the shared codec (round-trip sanity)', () => { const bytes = new TextEncoder().encode(JSON.stringify({ type: 'output', data: 'z' })) expect(decodeServerMessage(bytes)).toEqual({ type: 'output', data: 'z' }) expect(decodeServerMessage(new TextEncoder().encode('not json'))).toBeNull() }) })