// Tests for terminal.js — WebSocket + xterm.js integration import { createRequire } from 'node:module'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); // ─── Helpers ─────────────────────────────────────────────────────────────────── /** * Load a fresh copy of terminal.js with isolated module-level state. * Returns { window } after the script has executed. */ function loadTerminal() { // Delete from require cache so each test gets fresh module-level state const modulePath = join(__dirname, '..', 'terminal.js'); delete require.cache[require.resolve(modulePath)]; // terminal.js reads: location.protocol, location.host, document.getElementById, // window.Terminal, window.FitAddon, window.innerWidth let capturedCloseHandler = null; let capturedReconnectFn = null; let capturedWsProtocols = null; let capturedOnDataFn = null; let capturedOnResizeFn = null; let termWriteMessages = []; let lastWsInstance = null; let capturedWsUrl = null; let onDataCallCount = 0; let onResizeCallCount = 0; let focusCallCount = 0; const mockTerm = { cols: 80, rows: 24, open: () => {}, onData: (fn) => { onDataCallCount++; capturedOnDataFn = fn; }, onResize: (fn) => { onResizeCallCount++; capturedOnResizeFn = fn; }, loadAddon: () => {}, dispose: () => {}, write: (data) => { termWriteMessages.push(data); }, focus: () => { focusCallCount++; }, }; // Capture all messages sent via WebSocket.send() const sentMessages = []; // WebSocket mock — captures 'close' and 'open' handlers so we can fire them manually class MockWebSocket { constructor(_url, _protocols) { this.readyState = 1; // OPEN this.binaryType = ''; this._handlers = {}; lastWsInstance = this; } addEventListener(event, handler) { this._handlers[event] = handler; if (event === 'close') capturedCloseHandler = handler; } close() {} send(data) { sentMessages.push(data); } } MockWebSocket.OPEN = 1; // setTimeout mock: capture reconnect callback so we can fire it synchronously const origSetTimeout = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => { capturedReconnectFn = fn; return 0; }; globalThis.WebSocket = MockWebSocket; globalThis.location = { protocol: 'http:', host: 'localhost' }; globalThis.document = { getElementById: (id) => { if (id === 'terminal-container') return { appendChild: () => {} }; if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } }; return null; }, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {}, createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }), }; globalThis.window = { addEventListener: () => {}, location: { href: '' }, innerWidth: 1024, Terminal: function Terminal() { return mockTerm; }, FitAddon: { FitAddon: function FitAddon() { return { fit: () => {} }; }, }, _openTerminal: undefined, _closeTerminal: undefined, }; require(modulePath); // Restore setTimeout globalThis.setTimeout = origSetTimeout; // Find the most recently created MockWebSocket instance's open handler // by pulling it from the instance created during openTerminal() call. // We expose a fireOpen() helper so tests can simulate WebSocket connection. let lastOpenHandler = null; const OrigMockWS = globalThis.WebSocket; globalThis.WebSocket = function MockWSTracker(url, protocols) { capturedWsUrl = url; capturedWsProtocols = protocols; const inst = new OrigMockWS(url); const origAddListener = inst.addEventListener.bind(inst); inst.addEventListener = function(event, handler) { if (event === 'open') lastOpenHandler = handler; origAddListener(event, handler); }; lastWsInstance = inst; return inst; }; globalThis.WebSocket.OPEN = 1; return { openTerminal: globalThis.window._openTerminal, closeTerminal: globalThis.window._closeTerminal, get onDataCallCount() { return onDataCallCount; }, get onResizeCallCount() { return onResizeCallCount; }, get sentMessages() { return sentMessages; }, get capturedWsUrl() { return capturedWsUrl; }, get capturedWsProtocols() { return capturedWsProtocols; }, get capturedOnDataFn() { return capturedOnDataFn; }, get capturedOnResizeFn() { return capturedOnResizeFn; }, get termWriteMessages() { return termWriteMessages; }, get focusCallCount() { return focusCallCount; }, fireClose() { if (capturedCloseHandler) capturedCloseHandler(); }, fireOpen() { if (lastOpenHandler) lastOpenHandler(); }, fireMessage(data) { if (lastWsInstance && lastWsInstance._handlers['message']) { lastWsInstance._handlers['message']({ data }); } }, fireReconnect() { if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; } }, // Expose so we can re-patch setTimeout for the actual calls patchTimeout(fn) { const orig = globalThis.setTimeout; globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; }; fn(); globalThis.setTimeout = orig; }, }; } // ─── Tests ─────────────────────────────────────────────────────────────────────── test('onData is registered exactly once after initial connect (no reconnect)', () => { const t = loadTerminal(); // Patch setTimeout so reconnect callbacks are captured but not auto-run const orig = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => 0; t.openTerminal('my-session'); globalThis.setTimeout = orig; assert.strictEqual(t.onDataCallCount, 1, 'onData should be registered exactly once'); }); test('onResize is registered exactly once after initial connect (no reconnect)', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => 0; t.openTerminal('my-session'); globalThis.setTimeout = orig; assert.strictEqual(t.onResizeCallCount, 1, 'onResize should be registered exactly once'); }); test('onData is NOT re-registered after reconnect — count stays at 1', () => { let reconnectFn = null; const orig = globalThis.setTimeout; const t = loadTerminal(); // Patch setTimeout to capture reconnect callback globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; t.openTerminal('my-session'); // Simulate WebSocket dropping — triggers close handler which schedules reconnect t.fireClose(); // Fire the reconnect (calls connect() again) if (reconnectFn) reconnectFn(); globalThis.setTimeout = orig; assert.strictEqual( t.onDataCallCount, 1, 'onData should still be registered exactly once after a reconnect', ); }); test('onResize is NOT re-registered after reconnect — count stays at 1', () => { let reconnectFn = null; const orig = globalThis.setTimeout; const t = loadTerminal(); globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; t.openTerminal('my-session'); t.fireClose(); if (reconnectFn) reconnectFn(); globalThis.setTimeout = orig; assert.strictEqual( t.onResizeCallCount, 1, 'onResize should still be registered exactly once after a reconnect', ); }); test('onData count stays at 1 after multiple reconnects', () => { let reconnectFn = null; const orig = globalThis.setTimeout; const t = loadTerminal(); globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; t.openTerminal('my-session'); // Reconnect 3 times for (let i = 0; i < 3; i++) { t.fireClose(); if (reconnectFn) { reconnectFn(); reconnectFn = null; } } globalThis.setTimeout = orig; assert.strictEqual( t.onDataCallCount, 1, 'onData should be registered exactly once even after 3 reconnects', ); }); test('_fitAddon is nulled out when closeTerminal is called', () => { // This is a whitebox test: verify no crash on dispose + null const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => 0; t.openTerminal('my-session'); // Should not throw assert.doesNotThrow(() => t.closeTerminal(), 'closeTerminal should not throw'); globalThis.setTimeout = orig; }); test('initVisualViewport returns early without error when window.visualViewport is undefined', () => { // Guard test: non-mobile environments have no visualViewport — must not throw const t = loadTerminal(); // globalThis.window has no visualViewport (see loadTerminal setup) assert.strictEqual(globalThis.window.visualViewport, undefined, 'test pre-condition: window.visualViewport must be undefined'); const orig = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => 0; // openTerminal internally calls initVisualViewport — must not throw assert.doesNotThrow(() => t.openTerminal('test-session'), 'openTerminal (and initVisualViewport) should not throw when window.visualViewport is undefined'); globalThis.setTimeout = orig; }); // ─── ttyd protocol tests ────────────────────────────────────────────────────── // ttyd 1.7.7 requires: // 1. WebSocket subprotocol 'tty' — without it ttyd never starts the PTY // 2. First message on open: TEXT frame '{"AuthToken":""}' // 3. Second message on open: BINARY frame [0x31] + UTF-8({"columns":N,"rows":M}) // 4. Input keystrokes: BINARY [0x30] + UTF-8(keystroke) // 5. Resize: BINARY [0x31] + UTF-8({"columns":N,"rows":M}) // 6. Received frames: 1-byte type prefix — 0x30=output (write to xterm), 0x31/0x32=ignore test('connectWebSocket uses tty subprotocol', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); globalThis.setTimeout = orig; assert.deepStrictEqual( t.capturedWsProtocols, ['tty'], "WebSocket must be constructed with ['tty'] subprotocol — without it ttyd never starts the PTY", ); }); test('connectWebSocket sends text auth init as first message on open', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); t.fireOpen(); globalThis.setTimeout = orig; assert.ok(t.sentMessages.length >= 1, 'should have sent at least one message on open'); const firstMsg = t.sentMessages[0]; assert.strictEqual(typeof firstMsg, 'string', `first message must be a text string (auth frame), got ${Object.prototype.toString.call(firstMsg)}`); const parsed = JSON.parse(firstMsg); assert.strictEqual(parsed.AuthToken, '', 'AuthToken must be empty string'); assert.ok(!('columns' in parsed), 'auth-only TEXT frame should NOT contain columns'); assert.ok(!('rows' in parsed), 'auth-only TEXT frame should NOT contain rows'); }); test('connectWebSocket sends binary resize with 0x31 prefix as second message on open', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); t.fireOpen(); globalThis.setTimeout = orig; assert.ok(t.sentMessages.length >= 2, 'should have sent at least two messages on open (auth + resize)'); const resizeMsg = t.sentMessages[1]; assert.ok(resizeMsg instanceof Uint8Array, `resize message must be binary Uint8Array, got ${Object.prototype.toString.call(resizeMsg)}`); assert.strictEqual(resizeMsg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)'); const payload = JSON.parse(Buffer.from(resizeMsg.slice(1)).toString('utf-8')); assert.ok('columns' in payload, 'resize payload must contain columns'); assert.ok('rows' in payload, 'resize payload must contain rows'); assert.ok(typeof payload.columns === 'number' && payload.columns > 0, `columns must be a positive number, got ${payload.columns}`); assert.ok(typeof payload.rows === 'number' && payload.rows > 0, `rows must be a positive number, got ${payload.rows}`); }); test('onData sends input with 0x30 type prefix as binary frame', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); t.fireOpen(); const initCount = t.sentMessages.length; assert.ok(t.capturedOnDataFn, 'onData callback must have been registered'); t.capturedOnDataFn('a'); globalThis.setTimeout = orig; assert.strictEqual(t.sentMessages.length, initCount + 1, 'onData should send exactly one message'); const msg = t.sentMessages[initCount]; assert.ok(msg instanceof Uint8Array, 'keystroke message must be binary Uint8Array'); assert.strictEqual(msg[0], 0x30, 'first byte of input message must be 0x30 (input type)'); const text = Buffer.from(msg.slice(1)).toString('utf-8'); assert.strictEqual(text, 'a', 'payload after type byte must be the keystroke string'); }); test('onResize sends resize with 0x31 type prefix as binary frame', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); t.fireOpen(); const initCount = t.sentMessages.length; assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered'); t.capturedOnResizeFn({ cols: 100, rows: 30 }); globalThis.setTimeout = orig; assert.strictEqual(t.sentMessages.length, initCount + 1, 'onResize should send exactly one message'); const msg = t.sentMessages[initCount]; assert.ok(msg instanceof Uint8Array, 'resize message must be binary Uint8Array'); assert.strictEqual(msg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)'); const payload = JSON.parse(Buffer.from(msg.slice(1)).toString('utf-8')); assert.strictEqual(payload.columns, 100, 'columns must match the resize event cols'); assert.strictEqual(payload.rows, 30, 'rows must match the resize event rows'); }); test('message handler strips type byte and writes output for type 0x30', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); globalThis.setTimeout = orig; // Simulate receiving a terminal output frame: [0x30] + UTF-8('hello') const encoder = new TextEncoder(); const hello = encoder.encode('hello'); const msg = new Uint8Array(1 + hello.length); msg[0] = 0x30; msg.set(hello, 1); t.fireMessage(msg.buffer); // Pass as ArrayBuffer assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once'); const written = t.termWriteMessages[0]; assert.ok(written instanceof Uint8Array, 'data written to xterm must be a Uint8Array'); assert.strictEqual(written[0], 'h'.charCodeAt(0), 'first byte written must be "h" (0x68), not the type byte 0x30'); assert.strictEqual(written.length, hello.length, 'written data length must equal payload length (type byte stripped)'); }); test('message handler ignores title type (0x31) — does not call term.write', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); globalThis.setTimeout = orig; const encoder = new TextEncoder(); const title = encoder.encode('my session title'); const msg = new Uint8Array(1 + title.length); msg[0] = 0x31; msg.set(title, 1); t.fireMessage(msg.buffer); assert.strictEqual(t.termWriteMessages.length, 0, 'term.write must NOT be called for type 0x31 (window title)'); }); test('message handler ignores prefs type (0x32) — does not call term.write', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); globalThis.setTimeout = orig; const encoder = new TextEncoder(); const prefs = encoder.encode('{}'); const msg = new Uint8Array(1 + prefs.length); msg[0] = 0x32; msg.set(prefs, 1); t.fireMessage(msg.buffer); assert.strictEqual(t.termWriteMessages.length, 0, 'term.write must NOT be called for type 0x32 (preferences)'); }); test('connectWebSocket URL uses /terminal/ws path', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('my-session'); globalThis.setTimeout = orig; assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); assert.ok( t.capturedWsUrl.endsWith('/terminal/ws'), `WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`, ); }); test('initVisualViewport registers resize handler on window.visualViewport when present', () => { // RED test: stub does nothing; real impl must call addEventListener('resize', fn) const t = loadTerminal(); let addedEvent = null; globalThis.window.visualViewport = { addEventListener: (event, _fn) => { addedEvent = event; }, removeEventListener: (_event, _fn) => {}, }; const orig = globalThis.setTimeout; globalThis.setTimeout = (fn, _ms) => 0; t.openTerminal('test-session'); globalThis.setTimeout = orig; delete globalThis.window.visualViewport; assert.strictEqual(addedEvent, 'resize', '_vpHandler should be registered as a resize listener on window.visualViewport'); }); test('terminal is auto-focused when WebSocket opens', () => { const t = loadTerminal(); const orig = globalThis.setTimeout; globalThis.setTimeout = (_fn, _ms) => 0; t.openTerminal('test-session'); t.fireOpen(); globalThis.setTimeout = orig; assert.strictEqual(t.focusCallCount, 1, '_term.focus() should be called exactly once when the WebSocket open event fires'); });