/** * @fileoverview TerminalTile input rides the app's exactly-once queue, and only * what a human typed is queued. * * Before, the split pane's second terminal sent every xterm `onData` chunk as a * bare `{t:'i', d}` frame: no seq, no ACK, dropped while the socket was down, * never acknowledged an idle alert. It now goes through `app._sendInputAsync`, * over the pane's own socket registered in the app's input-socket map. That * queue PERSISTS and REDELIVERS, so it must never hold what xterm generates on * its own: a query reply (DA/CPR/OSC) is dropped, exactly as the primary pane * drops it, and a focus or mouse report goes out once, ephemeral. * * The socket's lifecycle is pinned here too: a transient drop reconnects on the * primary pane's backoff and refreshes the buffer without leaving a stale * "disconnected" marker under a healthy pane; codes that cannot get better stop * the pane once (`onExit`); a late close from a REPLACED socket is ignored; and * destroy() cancels a pending reconnect. * * The last blocks pin the soft-keyboard controller every tile wires * (terminal-keycode229-recovery.js, the primary pane's #441/#541 fixes): an * Android autocorrect is sent as an edit, not a duplicated line, a character * committed in the same task as Enter goes out ahead of the \r, and the * controller is bound to THIS tile's textarea, composition helper and session. * * Real code under test: constants.js + terminal-keycode229-recovery.js + * app.js (the queue) + terminal-ui.js (the shared input predicates) + * terminal-tile.js, in one `vm` context. xterm, the fit addon and WebSocket are * fakes (test/mocks/terminal-tile-fakes.ts); `connect()` runs for real. */ import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js'; const fetchMock = vi.fn(); function loadContext() { const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); const windowStub: Record = { addEventListener: vi.fn(), removeEventListener: vi.fn(), CodemanBase: { base: '' }, // The keyCode-229 controller defaults its timers to window's. Late-bound, // like the context's own, so vi.useFakeTimers() reaches it; without them // its create() throws into the tile's catch and every controller test // would run against no controller at all. setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), }; const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn() }, performance, setInterval: vi.fn(), clearInterval: vi.fn(), // Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code // running inside this context. setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), requestAnimationFrame: vi.fn(), HTMLCanvasElement: class HTMLCanvasElement {}, WebSocket: FakeSocket, Terminal: FakeTerminal, FitAddon: { FitAddon: FakeFit }, fetch: (...args: unknown[]) => fetchMock(...args), location: { protocol: 'http:', host: 'codeman.test' }, document: { addEventListener: vi.fn(), documentElement: { dataset: {} } }, localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, window: windowStub, MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop', }, }); vm.runInContext( `${read('constants.js')}\n${read('terminal-keycode229-recovery.js')}\n${read('app.js')}\n` + `${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); return { CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp, windowStub, }; } const { CodemanApp, windowStub } = loadContext(); type App = Record & { _pendingDeliveries: Map>; markIdleAlertSeen: ReturnType; }; function makeApp(): App { const app = Object.create(CodemanApp.prototype) as App; app._clientId = 'c-test'; app._wsTabNonce = 'nonce-1'; app._seqCounters = new Map(); app._pendingDeliveries = new Map(); app._postDraining = new Set(); app._extraInputSockets = new Map(); app._persistReliableState = vi.fn(); app._persistReliableNow = vi.fn(); app._updateConnectionIndicator = vi.fn(); app.markIdleAlertSeen = vi.fn(); app.showToast = vi.fn(); // The key handler's chord gates read the shortcut registry, which reads these. app.loadAppSettingsFromStorage = () => ({}); app._ws = null; app._wsSessionId = null; app._estimateReplayRows = (text: string) => text.split('\n').length; return app; } type Tile = { connect(): Promise; destroy(): void; reconnectNow(): void; fit(opts?: { force?: boolean }): boolean; detachedSessions?: Set; ws: FakeSocket | null; _reconnectAttempts: number; }; const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile; /** * Every tile a test creates, destroyed after it. A tile closed with real timers * schedules a real reconnect, and one firing during a LATER test opens a socket * there (FakeSocket.instances is shared), which flaked under full-suite load. */ const liveTiles: Tile[] = []; async function connectTile(app: App, opts: Record = {}) { windowStub.app = app; const tile = new TerminalTile( 's-tile', { addEventListener: vi.fn(), removeEventListener: vi.fn() }, { mode: 'claude', ...opts } ); liveTiles.push(tile); await tile.connect(); const ws = FakeSocket.instances.at(-1)!; return { tile, ws, term: FakeTerminal.last! }; } afterEach(() => { for (const tile of liveTiles.splice(0)) tile.destroy(); vi.useRealTimers(); }); beforeEach(() => { FakeTerminal.coreFactory = null; FakeFit.proposed = { cols: 80, rows: 24 }; FakeSocket.instances = []; fetchMock.mockReset(); fetchMock.mockImplementation(async () => ({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: '' } }), })); }); describe('TerminalTile socket identity', () => { it('connects with the tab identity plus a :tile suffix, never the primary pane cid', async () => { const { ws } = await connectTile(makeApp()); const cid = new URL(ws.url).searchParams.get('cid'); expect(cid).toBe('c-test:nonce-1:tile'); }); }); describe('TerminalTile input through the exactly-once queue', () => { it('sends typed input as seq-tagged frames with the bare clientId once the socket opens', async () => { const app = makeApp(); const { ws, term } = await connectTile(app); ws.open(); term.type('h'); term.type('i'); expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([ ['h', 1, 'c-test'], ['i', 2, 'c-test'], ]); }); it('drops a record on its ACK and acknowledges the session idle alert', async () => { const app = makeApp(); const { ws, term } = await connectTile(app); ws.open(); term.type('x'); expect(app._pendingDeliveries.get('s-tile')).toHaveLength(1); ws.receive({ t: 'ia', seq: 1 }); expect(app._pendingDeliveries.get('s-tile')).toBeUndefined(); expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-tile'); }); it('flushes input typed before the socket opened, in order, once it does', async () => { const app = makeApp(); // POSTs fail, as they would while the server restarts, so the input waits. const { ws, term } = await connectTile(app); fetchMock.mockImplementation(async () => ({ ok: false, status: 503, json: async () => ({}) })); term.type('a'); term.type('b'); await new Promise((r) => setTimeout(r, 0)); expect(ws.inputFrames()).toEqual([]); ws.open(); expect(ws.inputFrames().map((f) => [f.d, f.seq])).toEqual([ ['a', 1], ['b', 2], ]); }); it('keeps queuing after the socket closes (HTTP fallback), instead of dropping keystrokes', async () => { const app = makeApp(); const { ws, term } = await connectTile(app); ws.open(); ws.readyState = 3; ws.onclose?.({ code: 1006 }); const posts: Array<{ input: string; seq: number }> = []; fetchMock.mockImplementation(async (_url: string, init?: { body?: string }) => { if (init?.body) posts.push(JSON.parse(init.body)); return { ok: true, status: 200, json: async () => ({}) }; }); term.type('z'); await new Promise((r) => setTimeout(r, 0)); expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]); expect(ws.inputFrames()).toEqual([]); }); }); describe('what xterm generates never enters the durable queue', () => { it('drops a DA query reply entirely', async () => { const app = makeApp(); const { ws, term } = await connectTile(app); ws.open(); term.type('\x1b[?1;2c'); expect(ws.inputFrames()).toEqual([]); expect(app._pendingDeliveries.get('s-tile')).toBeUndefined(); }); it('sends a mouse report once, without a seq, and never persists it', async () => { const app = makeApp(); const { ws, term } = await connectTile(app); ws.open(); term.type('\x1b[<0;10;5M'); expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[<0;10;5M' }]); expect(app._pendingDeliveries.get('s-tile')).toBeUndefined(); }); it('sends a focus report once, without a seq', async () => { const app = makeApp(); const { ws, term } = await connectTile(app); ws.open(); term.type('\x1b[I'); expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]); expect(app._pendingDeliveries.get('s-tile')).toBeUndefined(); }); }); describe('TerminalTile leaves the input-socket map', () => { it('on close, so a later keystroke cannot be written into a dead socket', async () => { const app = makeApp(); const { ws } = await connectTile(app); ws.open(); expect((app._extraInputSockets as Map).has('s-tile')).toBe(true); ws.onclose?.({ code: 1006 }); expect((app._extraInputSockets as Map).has('s-tile')).toBe(false); }); it('on destroy, while pending input stays queued for the HTTP sweep', async () => { const app = makeApp(); const { tile, ws, term } = await connectTile(app); ws.open(); term.type('q'); tile.destroy(); expect((app._extraInputSockets as Map).has('s-tile')).toBe(false); expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['q']); }); }); const isMarker = (data: string) => data.includes('[disconnected'); /** * Lets the async buffer refresh settle: the fetch, the body read, the chunked * write AND the load's finally block, which is where a stale marker would be * stamped. Too few turns here and that assertion passes vacuously. */ async function settle() { for (let i = 0; i < 50; i++) await Promise.resolve(); } describe('TerminalTile reconnects after a transient drop', () => { it('reopens on the backoff, refreshes the buffer, and leaves no stale marker', async () => { vi.useFakeTimers(); const app = makeApp(); const { tile, ws, term } = await connectTile(app); ws.open(); fetchMock.mockImplementation(async () => ({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'fresh screen' } }), })); ws.readyState = 3; ws.onclose?.({ code: 1006 }); expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining('[disconnected, reconnecting')]); expect(FakeSocket.instances).toHaveLength(1); await vi.advanceTimersByTimeAsync(300); expect(FakeSocket.instances).toHaveLength(2); const ws2 = FakeSocket.instances[1]; expect(ws2.url).toBe(ws.url); ws2.open(); await settle(); // The refresh reset the pane in-stream (never xterm's clear()) and replayed // the current screen, and nothing after that reset is a marker: the pane is // healthy again. expect(term.writes).not.toContain(''); const lastReset = term.writes.lastIndexOf('\x1bc'); expect(lastReset).toBeGreaterThan(-1); expect(term.writes.slice(lastReset)).toEqual(['\x1bc', 'fresh screen']); expect(term.writes.slice(lastReset).some(isMarker)).toBe(false); expect(tile._reconnectAttempts).toBe(0); expect(tile.ws).toBe(ws2); }); it('counts failed attempts and resets the count only on a successful open', async () => { vi.useFakeTimers(); const { tile, ws } = await connectTile(makeApp()); ws.open(); ws.onclose?.({ code: 1006 }); await vi.advanceTimersByTimeAsync(300); FakeSocket.instances[1].onclose?.({ code: 1006 }); // the retry fails too expect(tile._reconnectAttempts).toBe(2); await vi.advanceTimersByTimeAsync(1000); FakeSocket.instances[2].open(); expect(tile._reconnectAttempts).toBe(0); }); it('reconnects after the redelivery sweep force-closes a silent socket (1005)', async () => { vi.useFakeTimers(); const { ws } = await connectTile(makeApp()); ws.open(); ws.onclose?.({ code: 1005 }); await vi.advanceTimersByTimeAsync(300); expect(FakeSocket.instances).toHaveLength(2); }); it('reconnectNow() skips the backoff, but never replaces an open socket', async () => { vi.useFakeTimers(); const { tile, ws } = await connectTile(makeApp()); ws.open(); tile.reconnectNow(); expect(FakeSocket.instances).toHaveLength(1); ws.readyState = 3; ws.onclose?.({ code: 1006 }); tile.reconnectNow(); expect(FakeSocket.instances).toHaveLength(2); // The backoff timer it pre-empted must not open a third socket later. await vi.advanceTimersByTimeAsync(20_000); expect(FakeSocket.instances).toHaveLength(2); }); }); describe("the server's {t:'c'} frame refreshes the tile (a Claude pane's first prompt)", () => { // Session.startInteractive (session.ts) sends it once a fresh Claude pane // shows its prompt, meaning "refresh after startup"; the primary pane refetches // and replays (_onSessionClearTerminal). A tile used to run a bare xterm // clear(), which kept only the cursor's row: banner and transcript gone, and an // idle Claude never repaints them. it('refetches the capture and replays it, never a bare clear', async () => { const { ws, term } = await connectTile(makeApp()); ws.open(); fetchMock.mockClear(); fetchMock.mockImplementation(async () => ({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'Claude Code banner\r\n❯ ' } }), })); ws.receive({ t: 'o', d: 'banner painted live' }); ws.receive({ t: 'c' }); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1'); expect(term.writes.at(-1)).toBe('Claude Code banner\r\n❯ '); }); it('two clear frames during one refresh fetch once more, not twice', async () => { const { ws } = await connectTile(makeApp()); ws.open(); fetchMock.mockClear(); let release!: () => void; fetchMock.mockImplementationOnce( () => new Promise((resolveFetch) => { release = () => resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'a' } }) }); }) ); ws.receive({ t: 'c' }); ws.receive({ t: 'c' }); ws.receive({ t: 'c' }); expect(fetchMock).toHaveBeenCalledTimes(1); release(); await settle(); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe('TerminalTile stops for good on codes that cannot get better', () => { it.each([ [4004, 'the session ended'], [4009, 'the session ended'], [4003, 'the server refused this connection'], [4010, 'another connection took over this pane'], ])('close %i: no reconnect, onExit once, marker says why', async (code, reason) => { vi.useFakeTimers(); const onExit = vi.fn(); const { tile, ws, term } = await connectTile(makeApp(), { onExit }); ws.open(); ws.onclose?.({ code }); await vi.advanceTimersByTimeAsync(30_000); tile.reconnectNow(); expect(FakeSocket.instances).toHaveLength(1); expect(onExit).toHaveBeenCalledTimes(1); expect(onExit).toHaveBeenCalledWith(code); expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining(reason)]); }); }); describe('TerminalTile ignores a socket it already replaced', () => { it('a late close (4010) from the old socket neither stops the pane nor unregisters its successor', async () => { vi.useFakeTimers(); const onExit = vi.fn(); const app = makeApp(); const { ws, term } = await connectTile(app, { onExit }); ws.open(); const lateClose = ws.onclose!; ws.onclose?.({ code: 1006 }); await vi.advanceTimersByTimeAsync(300); const ws2 = FakeSocket.instances[1]; ws2.open(); // The server supersedes the old socket by cid; its close arrives late. lateClose({ code: 4010 }); expect(onExit).not.toHaveBeenCalled(); term.type('still typing'); expect(ws2.inputFrames().map((f) => f.d)).toEqual(['still typing']); }); }); describe('TerminalTile destroy()', () => { it('cancels a pending reconnect and never reports an exit afterwards', async () => { vi.useFakeTimers(); const onExit = vi.fn(); const { tile, ws } = await connectTile(makeApp(), { onExit }); ws.open(); ws.onclose?.({ code: 1006 }); tile.destroy(); await vi.advanceTimersByTimeAsync(30_000); expect(FakeSocket.instances).toHaveLength(1); expect(onExit).not.toHaveBeenCalled(); }); }); describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () => { const resizeFrames = (ws: FakeSocket) => ws.sent.filter((f) => f.t === 'z'); it('announces its size as a desktop viewer when the socket opens', async () => { const { ws } = await connectTile(makeApp()); ws.open(); expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]); }); it('does not resend an unchanged size, sends a changed one, and force resends', async () => { const { tile, ws } = await connectTile(makeApp()); ws.open(); expect(tile.fit()).toBe(false); // unchanged: nothing sent expect(resizeFrames(ws)).toHaveLength(1); FakeFit.proposed = { cols: 100, rows: 30 }; expect(tile.fit()).toBe(true); expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' }); expect(tile.fit({ force: true })).toBe(true); expect(resizeFrames(ws)).toHaveLength(3); // The primary pane's forced resize flag: without it Session.resize skips a // size equal to the one it last applied, and the forced resend did nothing. expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop', f: true }); }); it('force on a closed socket sends nothing and says so', async () => { const { tile, ws } = await connectTile(makeApp()); ws.open(); ws.drop(1006); expect(tile.fit({ force: true })).toBe(false); expect(resizeFrames(ws)).toHaveLength(1); // only the open's own announcement }); describe('Redraw (Ctrl+Shift+R, the header button) on a focused tile', () => { type RedrawApp = App & { restoreTerminalSize(): Promise; _noteFocusedTile(tile: unknown): void; detachedSessions?: Set; }; it('forces the resize through to the server and reports the size it sent', async () => { const app = makeApp() as RedrawApp; const { tile, ws } = await connectTile(app); ws.open(); app._noteFocusedTile(tile); await app.restoreTerminalSize(); expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 80, r: 24, v: 'desktop', f: true }); expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 80x24', 'success'); }); it('with the socket down, sends nothing and does not report a success', async () => { const app = makeApp() as RedrawApp; const { tile, ws } = await connectTile(app); ws.open(); app._noteFocusedTile(tile); ws.drop(1006); await app.restoreTerminalSize(); expect(resizeFrames(ws)).toHaveLength(1); expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success'); expect(app.showToast).toHaveBeenCalledWith( 'Terminal not connected: its size is sent when it reconnects', 'warning' ); }); it('for a session popped out to its own window, says that window sizes it', async () => { const app = makeApp() as RedrawApp; const detached = new Set(); app.detachedSessions = detached; const { tile, ws } = await connectTile(app, { detachedSessions: detached }); ws.open(); app._noteFocusedTile(tile); detached.add('s-tile'); // popped out after the tile opened await app.restoreTerminalSize(); expect(resizeFrames(ws)).toHaveLength(1); expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success'); expect(app.showToast).toHaveBeenCalledWith('This session is sized by its own window', 'warning'); }); }); it('re-announces an unchanged size on a reconnected socket', async () => { vi.useFakeTimers(); const { ws } = await connectTile(makeApp()); ws.open(); ws.onclose?.({ code: 1006 }); await vi.advanceTimersByTimeAsync(300); const ws2 = FakeSocket.instances[1]; ws2.open(); expect(resizeFrames(ws2)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]); }); it('applies no 40-column floor: a pane at the divider clamp gets its real width on both sides', async () => { const { tile, ws, term } = await connectTile(makeApp()); ws.open(); FakeFit.proposed = { cols: 28, rows: 30 }; tile.fit(); expect(term.cols).toBe(28); expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' }); }); it('paneStarted() resends an unchanged size: the first one went out before there was a PTY', async () => { const { tile, ws } = await connectTile(makeApp()); ws.open(); tile.paneStarted(); expect(resizeFrames(ws)).toEqual([ { t: 'z', c: 80, r: 24, v: 'desktop' }, { t: 'z', c: 80, r: 24, v: 'desktop' }, ]); // Only once: the size is recorded again, so a plain fit does not repeat it. tile.fit(); expect(resizeFrames(ws)).toHaveLength(2); }); it('paneStarted() on a hidden tile sends nothing, and its next fit sends the size', async () => { const { tile, ws } = await connectTile(makeApp()); ws.open(); FakeFit.proposed = { cols: NaN, rows: NaN }; tile.paneStarted(); expect(resizeFrames(ws)).toHaveLength(1); // Shown again (a zoom ends) at the same size it had: still sent. FakeFit.proposed = { cols: 80, rows: 24 }; tile.fit(); expect(resizeFrames(ws)).toHaveLength(2); }); it('paneStarted() before the socket opens sends nothing; the open sends the size once', async () => { const { tile, ws } = await connectTile(makeApp()); tile.paneStarted(); expect(resizeFrames(ws)).toHaveLength(0); ws.open(); expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]); }); it('reports nothing while hidden (the fit addon measures NaN)', async () => { const { tile, ws, term } = await connectTile(makeApp()); ws.open(); FakeFit.proposed = { cols: NaN, rows: NaN }; tile.fit(); expect(resizeFrames(ws)).toHaveLength(1); expect([term.cols, term.rows]).toEqual([80, 24]); }); it('stands aside for a session detached into its own window', async () => { const { tile, ws } = await connectTile(makeApp(), { detachedSessions: new Set(['s-tile']) }); ws.open(); FakeFit.proposed = { cols: 120, rows: 40 }; expect(tile.fit()).toBe(false); expect(resizeFrames(ws)).toEqual([]); }); it('adopts the column count the PTY reports, keeping its own rows', async () => { const { ws, term } = await connectTile(makeApp()); ws.open(); ws.receive({ t: 'zc', c: 132, r: 50 }); expect([term.cols, term.rows]).toEqual([132, 24]); }); it('leaves the pane alone when the PTY agrees on width', async () => { const { ws, term } = await connectTile(makeApp()); ws.open(); const before = term.resizes.length; ws.receive({ t: 'zc', c: 80, r: 60 }); expect(term.resizes.length).toBe(before); }); }); describe('TerminalTile links and paste follow THIS pane', () => { it('registers the shared link provider on its own terminal, resolving its own session', async () => { const app = makeApp(); const register = vi.fn(); app.registerFilePathLinkProvider = register; const { term } = await connectTile(app); expect(register).toHaveBeenCalledTimes(1); const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string }; expect(target.terminal).toBe(term); expect(target.getSessionId()).toBe('s-tile'); }); it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => { const app = makeApp(); const paste = vi.fn(); app._handleImagePaste = paste; const { term } = await connectTile(app); const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' }); expect(handled).toBe(false); expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' }); }); it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => { const app = makeApp(); const paste = vi.fn(); app._handleImagePaste = paste; const { term } = await connectTile(app); term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' }); expect(paste).not.toHaveBeenCalled(); }); }); describe("TerminalTile Ctrl+C copies through the primary pane's copy helpers", () => { const ctrlC = (extra: Record = {}) => ({ type: 'keydown', key: 'c', code: 'KeyC', ctrlKey: true, preventDefault: vi.fn(), ...extra, }); it("copies THIS pane's selection; a failed write keeps it and focus returns to this pane", async () => { const app = makeApp(); const copyText = vi.fn(async () => false); app._copyText = copyText; const { term } = await connectTile(app); term.selection = 'npm run build'; const ev = ctrlC(); expect(term.keyHandler!(ev)).toBe(false); await new Promise((r) => setTimeout(r, 0)); expect(ev.preventDefault).toHaveBeenCalled(); expect(copyText).toHaveBeenCalledWith('npm run build'); expect(app.showToast).toHaveBeenCalledWith('Failed to copy', 'error'); // As in the primary pane: nothing was copied, so the selection stays for a retry. expect(term.clearSelection).not.toHaveBeenCalled(); expect(term.selection).toBe('npm run build'); // The execCommand fallback focuses a temporary textarea; the keyboard comes back here. expect(term.focus).toHaveBeenCalled(); }); it('a successful write clears the selection (a second Ctrl+C interrupts) and refocuses this pane', async () => { const app = makeApp(); app._copyText = vi.fn(async () => true); const { term } = await connectTile(app); term.selection = 'npm run build'; expect(term.keyHandler!(ctrlC())).toBe(false); await new Promise((r) => setTimeout(r, 0)); expect(app.showToast).toHaveBeenCalledWith('Copied to clipboard', 'success'); expect(term.clearSelection).toHaveBeenCalled(); expect(term.focus).toHaveBeenCalled(); }); it('with nothing selected, Ctrl+C reaches the PTY and Ctrl+Shift+C does not', async () => { const app = makeApp(); const copyText = vi.fn(async () => true); app._copyText = copyText; const { term } = await connectTile(app); const plain = ctrlC(); expect(term.keyHandler!(plain)).toBe(true); expect(plain.preventDefault).not.toHaveBeenCalled(); const shifted = ctrlC({ key: 'C', shiftKey: true }); expect(term.keyHandler!(shifted)).toBe(false); expect(shifted.preventDefault).toHaveBeenCalled(); expect(copyText).not.toHaveBeenCalled(); }); }); describe('TerminalTile claims the keyboard for the app-level shortcuts', () => { it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => { const app = makeApp(); const { tile, term } = await connectTile(app); expect(app._focusedTile ?? null).toBeNull(); term.focusTextarea(); expect(app._focusedTile).toBe(tile); tile.destroy(); expect(app._focusedTile).toBeNull(); expect(term.focusListeners).toEqual([]); }); }); describe('TerminalTile live-output flow control: a flood cannot pile up in xterm', () => { // The server applies no backpressure, and a tile used to write every live // frame straight into xterm: a flood it could not parse as fast piled up in // xterm's own queue without bound, until xterm's WriteBuffer threw past 50M // code units and onmessage's catch silently lost the frames. Now unparsed // (and held) output is counted per tile; past the budget a frame is dropped, // the tile stops writing onto the hole, and one debounced, bounded refresh // recaptures the screen (the primary pane's _scheduleDroppedOutputRecovery). const TileStatics = TerminalTile as unknown as { LIVE_BACKLOG_BUDGET: number }; const defaultBudget = TileStatics.LIVE_BACKLOG_BUDGET; afterEach(() => { TileStatics.LIVE_BACKLOG_BUDGET = defaultBudget; delete windowStub.AbortController; }); const out = (ws: FakeSocket, d: string) => ws.receive({ t: 'o', d }); const inFlight = (tile: Tile) => (tile as unknown as { _liveInFlight: number })._liveInFlight; function serve(terminalBuffer: string) { fetchMock.mockClear(); fetchMock.mockImplementation(async () => ({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer } }), })); } it("the budget is a few MB, not the primary pane's 128 KB: a 1 MiB burst xterm has not parsed yet is still written", async () => { expect(defaultBudget).toBeGreaterThanOrEqual(2 * 1024 * 1024); expect(defaultBudget).toBeLessThanOrEqual(16 * 1024 * 1024); vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); serve(''); term.holdParse = true; out(ws, 'z'.repeat(1024 * 1024)); expect(term.writes.at(-1)).toHaveLength(1024 * 1024); await vi.advanceTimersByTimeAsync(10_000); expect(fetchMock).not.toHaveBeenCalled(); }); it('counts output until xterm has parsed it, so parsed bytes no longer hold the budget', async () => { TileStatics.LIVE_BACKLOG_BUDGET = 100; const { tile, ws, term } = await connectTile(makeApp()); ws.open(); term.holdParse = true; out(ws, 'a'.repeat(40)); out(ws, 'b'.repeat(40)); expect(inFlight(tile)).toBe(80); term.parse(); expect(inFlight(tile)).toBe(0); out(ws, 'c'.repeat(90)); expect(term.writes.slice(-3)).toEqual(['a'.repeat(40), 'b'.repeat(40), 'c'.repeat(90)]); }); it('stops writing past the budget, and ONE debounced refresh recaptures the screen', async () => { TileStatics.LIVE_BACKLOG_BUDGET = 100; vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); serve('recovered screen'); term.holdParse = true; // xterm falls behind out(ws, 'a'.repeat(60)); out(ws, 'b'.repeat(60)); // 120 unparsed: past the budget, dropped out(ws, 'c'); term.parse(); // xterm catches up, but the stream already has a hole: out(ws, 'd'); // nothing more is written onto it expect(term.writes.filter((w) => /^[abcd]/.test(w))).toEqual(['a'.repeat(60)]); expect(fetchMock).not.toHaveBeenCalled(); term.holdParse = false; await vi.advanceTimersByTimeAsync(1999); expect(fetchMock).not.toHaveBeenCalled(); // debounced, like the primary pane's await vi.advanceTimersByTimeAsync(1); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1'); expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']); out(ws, 'live again'); expect(term.writes.at(-1)).toBe('live again'); await vi.advanceTimersByTimeAsync(10_000); expect(fetchMock).toHaveBeenCalledTimes(1); // one recovery for the whole burst }); it("a write xterm refuses (its 50M throw) is a drop that schedules the same recovery, never a swallowed 'malformed frame'", async () => { vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); serve('recovered screen'); term.throwOnWrite = 'boom'; out(ws, 'boom'); out(ws, 'after the hole'); expect(term.writes).not.toContain('after the hole'); await vi.advanceTimersByTimeAsync(2000); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']); }); it('frames held behind a replay count against the budget too, and a hole there is recovered by another refresh', async () => { TileStatics.LIVE_BACKLOG_BUDGET = 100; vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); fetchMock.mockClear(); let answer!: (body: string) => void; fetchMock.mockImplementationOnce(async () => ({ ok: true, status: 200, json: () => new Promise((resolveBody) => { answer = (terminalBuffer) => resolveBody({ data: { terminalBuffer } }); }), })); fetchMock.mockImplementation(async () => ({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'second capture' } }), })); ws.receive({ t: 'r' }); await settle(); // the headers landed: from here frames are held out(ws, 'x'.repeat(60)); out(ws, 'y'.repeat(60)); // the held queue would pass the budget: dropped answer('first capture'); await settle(); // The capture predates the hole, so nothing after it is written onto it. expect(term.writes.slice(-2)).toEqual(['\x1bc', 'first capture']); expect(term.writes).not.toContain('y'.repeat(60)); await vi.advanceTimersByTimeAsync(2000); await settle(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(term.writes.slice(-2)).toEqual(['\x1bc', 'second capture']); out(ws, 'live again'); expect(term.writes.at(-1)).toBe('live again'); }); it('a recovery that keeps failing is retried a bounded number of times, then lets live output through again', async () => { vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); fetchMock.mockClear(); fetchMock.mockImplementation(async () => { throw new Error('offline'); }); term.throwOnWrite = 'boom'; out(ws, 'boom'); out(ws, 'held back'); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(2000); await settle(); } const { DROP_RECOVERY_MAX_ATTEMPTS } = windowStub.CodemanDroppedOutput as { DROP_RECOVERY_MAX_ATTEMPTS: number }; expect(fetchMock).toHaveBeenCalledTimes(DROP_RECOVERY_MAX_ATTEMPTS); expect(term.writes).not.toContain('held back'); out(ws, 'flowing'); expect(term.writes.at(-1)).toBe('flowing'); // never left frozen }); it('a recovery cut off at its deadline is not retried (a stalled link), and lets live output through again', async () => { windowStub.AbortController = AbortController; vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); fetchMock.mockClear(); fetchMock.mockImplementation( (_url: string, init?: { signal?: AbortSignal }) => new Promise((_resolveFetch, rejectFetch) => { init?.signal?.addEventListener('abort', () => rejectFetch(new Error('aborted'))); }) ); term.throwOnWrite = 'boom'; out(ws, 'boom'); await vi.advanceTimersByTimeAsync(2000); expect(fetchMock).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(45_000); // the full-capture budget runs out await settle(); out(ws, 'flowing'); expect(term.writes.at(-1)).toBe('flowing'); await vi.advanceTimersByTimeAsync(20_000); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('a reconnect starts the count over, and callbacks from before it count nothing', async () => { TileStatics.LIVE_BACKLOG_BUDGET = 100; vi.useFakeTimers(); const { tile, ws, term } = await connectTile(makeApp()); ws.open(); term.holdParse = true; out(ws, 'a'.repeat(60)); expect(inFlight(tile)).toBe(60); ws.drop(1006); await vi.advanceTimersByTimeAsync(300); FakeSocket.instances.at(-1)!.open(); // reconnected: its refresh replaces the screen expect(inFlight(tile)).toBe(0); term.parse(); // the old write's callback lands after the reset expect(inFlight(tile)).toBe(0); }); it('a reconnect drops a pending recovery: its own refresh replaces the screen', async () => { vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); serve(''); term.throwOnWrite = 'boom'; out(ws, 'boom'); ws.drop(1006); await vi.advanceTimersByTimeAsync(300); const ws2 = FakeSocket.instances.at(-1)!; ws2.open(); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); // the reconnect's refresh out(ws2, 'flowing'); expect(term.writes.at(-1)).toBe('flowing'); await vi.advanceTimersByTimeAsync(5000); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('destroy() cancels a pending recovery', async () => { vi.useFakeTimers(); const { tile, ws, term } = await connectTile(makeApp()); ws.open(); fetchMock.mockClear(); term.throwOnWrite = 'boom'; out(ws, 'boom'); tile.destroy(); await vi.advanceTimersByTimeAsync(5000); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe('the server coming back kicks Pane B', () => { it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => { // handleInit needs a whole app to run, so the wiring is pinned by source; // reconnectNow() itself is exercised above. const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); const start = appSource.indexOf('if (keepTerminal && restoreId === previousActiveId'); const branch = appSource.slice(start, appSource.indexOf('} else {', start)); expect(start).toBeGreaterThan(-1); expect(branch).toContain('this._splitPane?.reconnectNow?.();'); }); }); /** * xterm's CompositionHelper, reduced to what the keyCode-229 controller touches. * Its `_handleAnyTextareaChanges` is xterm's own append-only diff as shipped * (node_modules/@xterm/xterm/src/browser/input/CompositionHelper.ts), so a * control without the controller reproduces the device-log duplicate, and its * `triggerDataEvent` feeds the tile's onData, as xterm's core service does. */ type Helper = { _isComposing: boolean; _isSendingComposition: boolean; _dataAlreadySent: string; _coreService: { triggerDataEvent: (data: string, wasUserInput?: boolean) => void }; _handleAnyTextareaChanges: () => void; }; /** * Gives every FakeTerminal created from now on a composition helper. Returns * them in creation order, with xterm's own diff each one started with. */ function withCompositionHelpers() { const helpers: Helper[] = []; const originals: Array = []; FakeTerminal.coreFactory = (term) => { const helper: Helper = { _isComposing: false, _isSendingComposition: false, _dataAlreadySent: '', _coreService: { triggerDataEvent: (data: string) => term.type(data) }, _handleAnyTextareaChanges(this: Helper) { const oldValue = term.textarea.value; setTimeout(() => { if (this._isComposing) return; const newValue = term.textarea.value; const diff = newValue.replace(oldValue, ''); this._dataAlreadySent = diff; if (newValue.length > oldValue.length) this._coreService.triggerDataEvent(diff, true); else if (newValue.length < oldValue.length) this._coreService.triggerDataEvent('\x7f', true); else if (newValue !== oldValue) this._coreService.triggerDataEvent(newValue, true); }, 0); }, }; helpers.push(helper); originals.push(helper._handleAnyTextareaChanges); return { _compositionHelper: helper }; }; return Object.assign(helpers, { originals }); } /** * Drives a tile the way an Android soft keyboard drives xterm. The fake xterm * runs no CompositionHelper.keydown of its own, so `key229()` does what xterm * does, in xterm's order: the custom key handler first, then (keyCode 229, no * composition) the helper's `_handleAnyTextareaChanges()`, read off the helper * at call time so the controller's patch is what runs. */ function softKeyboard(term: FakeTerminal, helper: Helper) { const textarea = term.textarea; const key229 = () => { term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 }); helper._handleAnyTextareaChanges(); }; return { key229, /** One appended character, settled on its own timer before the next key. */ typeKeys(text: string) { for (const ch of text) { key229(); textarea.value += ch; vi.advanceTimersByTime(1); } }, /** The textarea now reads `value` (what the keyboard's input event left there). */ edit(value: string) { textarea.value = value; }, /** Enter: the custom handler, then xterm's own \r, then xterm clearing its textarea. */ enter() { const passed = term.keyHandler!({ type: 'keydown', key: 'Enter', keyCode: 13 }); if (passed) term.type('\r'); textarea.value = ''; }, }; } /** Every byte the tile sent as input, in order. */ const joinFrames = (frames: Array<{ d?: string }>) => frames.map((f) => f.d ?? '').join(''); const wireOf = (ws: FakeSocket) => joinFrames(ws.inputFrames()); /** The line a shell ends up with: every DEL erases the character before it. */ function lineOf(frames: Array<{ d?: string }>) { const out: string[] = []; for (const ch of joinFrames(frames)) { if (ch === '\x7f') out.pop(); else out.push(ch); } return out.join(''); } type ControllerTile = Tile & { _keyCode229Recovery: unknown }; describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => { it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => { const helpers = withCompositionHelpers(); const { tile, term } = await connectTile(makeApp()); const helper = helpers[0]; expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); // The controller patched this tile's helper (xterm's diff is no longer the one that runs) and // listens on this tile's textarea in the CAPTURE phase (see the module's measured table). expect(helper._handleAnyTextareaChanges).not.toBe(helpers.originals[0]); const captured = term.textareaListeners.filter((l) => l.capture === true).map((l) => l.type); expect(captured.sort()).toEqual(['compositionend', 'compositionstart', 'input']); tile.destroy(); expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); expect(helper._handleAnyTextareaChanges).toBe(helpers.originals[0]); expect(term.textareaListeners).toEqual([]); }); it('an autocorrect on space is sent as an edit, not a duplicated line', async () => { vi.useFakeTimers(); const helpers = withCompositionHelpers(); const app = makeApp(); app.activeSessionId = 'some-other-session'; const { tile, ws, term } = await connectTile(app); expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); ws.open(); const kb = softKeyboard(term, helpers[0]); kb.typeKeys('testing the peompt'); // The device log's shape: ONE keydown deleting five characters, a second inserting `rompt `, // both before any timer runs. kb.key229(); kb.edit('testing the p'); kb.key229(); kb.edit('testing the prompt '); vi.advanceTimersByTime(1); const frames = ws.inputFrames(); expect(lineOf(frames)).toBe('testing the prompt '); expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5); // Every byte went to THIS tile's session through the exactly-once queue, never the active one. expect(frames.every((f) => Number.isInteger(f.seq))).toBe(true); expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(frames.map((f) => f.d)); expect(app._pendingDeliveries.has('some-other-session')).toBe(false); }); it('control: without the controller, xterm alone duplicates the line exactly as the device did', async () => { vi.useFakeTimers(); const helpers = withCompositionHelpers(); const saved = windowStub.CodemanKeyCode229Recovery; delete windowStub.CodemanKeyCode229Recovery; try { const { tile, ws, term } = await connectTile(makeApp()); expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); ws.open(); const kb = softKeyboard(term, helpers[0]); kb.typeKeys('testing the peompt'); kb.key229(); kb.edit('testing the p'); kb.key229(); kb.edit('testing the prompt '); vi.advanceTimersByTime(1); expect(lineOf(ws.inputFrames())).toBe('testing the peompttesting the prompt rompt '); } finally { windowStub.CodemanKeyCode229Recovery = saved; } }); it('a 229 last character in the same task as Enter goes out ahead of the \\r (#441 + #541)', async () => { vi.useFakeTimers(); const helpers = withCompositionHelpers(); const { ws, term } = await connectTile(makeApp()); ws.open(); const kb = softKeyboard(term, helpers[0]); kb.typeKeys('hell'); // One task: the last character's keydown and edit, then Enter, no timer in between. kb.key229(); kb.edit('hello'); kb.enter(); // Settled synchronously at the Enter keydown, not by its timer. expect(wireOf(ws)).toBe('hello\r'); vi.advanceTimersByTime(1); const wire = wireOf(ws); expect(wire).toBe('hello\r'); expect(wire).not.toContain('\x7f'); }); it('an autocorrect plus Enter in one task submits the corrected line', async () => { vi.useFakeTimers(); const helpers = withCompositionHelpers(); const { ws, term } = await connectTile(makeApp()); ws.open(); const kb = softKeyboard(term, helpers[0]); kb.typeKeys('testing the peompt'); kb.key229(); kb.edit('testing the p'); kb.key229(); kb.edit('testing the prompt '); kb.enter(); vi.advanceTimersByTime(1); const frames = ws.inputFrames(); expect(lineOf(frames)).toBe('testing the prompt \r'); expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5); }); it('a 229 keystroke xterm diffed itself is delivered once, not again by the recovery', async () => { vi.useFakeTimers(); const helpers = withCompositionHelpers(); const { tile, ws, term } = await connectTile(makeApp()); expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); ws.open(); const kb = softKeyboard(term, helpers[0]); kb.key229(); kb.edit('y'); term.textarea.fire('input', { inputType: 'insertText', data: 'y', isComposing: false }); vi.advanceTimersByTime(1); expect(ws.inputFrames().map((f) => f.d)).toEqual(['y']); }); }); describe("the tile's onData tells the controller only about what a human typed", () => { /** A keystroke xterm refused: a keydown, then the committed `insertText` it did not forward. */ const orphan = (term: FakeTerminal, data: string) => { term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 }); term.textarea.fire('input', { inputType: 'insertText', data, isComposing: false }); }; it('recovers a refused insertText through a query reply and a focus report, to this tile', async () => { vi.useFakeTimers(); const app = makeApp(); const { tile, ws, term } = await connectTile(app); expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); ws.open(); orphan(term, 'x'); term.type('\x1b[?1;2c'); // a DA reply xterm answers on its own: dropped, and not "xterm spoke" term.type('\x1b[I'); // a focus report: sent ephemeral, and not "xterm spoke" either vi.advanceTimersByTime(1); const frames = ws.inputFrames(); expect(frames.map((f) => f.d)).toEqual(['\x1b[I', 'x']); const recovered = frames.find((f) => f.d === 'x')!; expect(Number.isInteger(recovered.seq)).toBe(true); expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['x']); }); it("never counts the controller's own recovered bytes as xterm's: two refused inserts after one keydown both arrive", async () => { // Pins WHERE the notify lives: in the onData lambda, not in _onTerminalData(), which the // recovered bytes also go through. Counted there, the first recovery would read as "xterm // spoke" for the second candidate, which shares its keydown snapshot, and drop it. vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 }); term.textarea.fire('input', { inputType: 'insertText', data: 'a', isComposing: false }); term.textarea.fire('input', { inputType: 'insertText', data: 'b', isComposing: false }); vi.advanceTimersByTime(1); expect(ws.inputFrames().map((f) => f.d)).toEqual(['a', 'b']); }); it('stands down when xterm really did deliver the keystroke', async () => { vi.useFakeTimers(); const { ws, term } = await connectTile(makeApp()); ws.open(); orphan(term, 'x'); term.type('x'); // xterm's own canonical emission for this keystroke vi.advanceTimersByTime(1); expect(ws.inputFrames().map((f) => f.d)).toEqual(['x']); }); }); describe('the controller can never break a tile', () => { type FakeController = { handleKeyEvent: ReturnType; notifyCanonicalData: ReturnType; destroy: ReturnType; }; let saved: unknown; beforeEach(() => { saved = windowStub.CodemanKeyCode229Recovery; }); afterEach(() => { windowStub.CodemanKeyCode229Recovery = saved; }); const fakeController = (overrides: Partial = {}): FakeController => ({ handleKeyEvent: vi.fn(), notifyCanonicalData: vi.fn(), destroy: vi.fn(), ...overrides, }); it('a create() that throws leaves the tile connected and typing', async () => { windowStub.CodemanKeyCode229Recovery = { create: () => { throw new Error('broken'); }, }; const { tile, ws, term } = await connectTile(makeApp()); expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); ws.open(); term.type('a'); expect(ws.inputFrames().map((f) => f.d)).toEqual(['a']); }); it("a handleKeyEvent that throws leaves every one of the tile's key gates working", async () => { const controller = fakeController({ handleKeyEvent: vi.fn(() => { throw new Error('broken'); }), }); windowStub.CodemanKeyCode229Recovery = { create: () => controller }; const { term } = await connectTile(makeApp()); expect(term.keyHandler!({ type: 'keydown', key: '1', code: 'Digit1', altKey: true })).toBe(false); expect(term.keyHandler!({ type: 'keydown', key: 'z', code: 'KeyZ', ctrlKey: true })).toBe(false); expect(term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 })).toBe(true); expect(term.keyHandler!({ type: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 })).toBe(true); // It still ran first, for every one of them. expect(controller.handleKeyEvent).toHaveBeenCalledTimes(4); }); it('a destroy() that throws still lets the tile dispose its xterm', async () => { const controller = fakeController({ destroy: vi.fn(() => { throw new Error('broken'); }), }); windowStub.CodemanKeyCode229Recovery = { create: () => controller }; const { tile, term } = await connectTile(makeApp()); const dispose = vi.spyOn(term, 'dispose'); tile.destroy(); expect(controller.destroy).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); }); it('each tile gets its own controller on its own textarea, and destroys only its own', async () => { const made: Array<{ options: { textarea: unknown }; controller: FakeController }> = []; windowStub.CodemanKeyCode229Recovery = { create: (options: { textarea: unknown }) => { const controller = fakeController(); made.push({ options, controller }); return controller; }, }; const app = makeApp(); const a = await connectTile(app); const b = await connectTile(app); expect(made).toHaveLength(2); expect(made[0].options.textarea).toBe(a.term.textarea); expect(made[1].options.textarea).toBe(b.term.textarea); a.tile.destroy(); expect(made[0].controller.destroy).toHaveBeenCalledTimes(1); expect(made[1].controller.destroy).not.toHaveBeenCalled(); }); }); describe('terminal-tile.js keeps the controller call where it works (source pin)', () => { const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8'); it('calls handleKeyEvent ABOVE the IME early return, so a 229 keydown reaches it', () => { const call = source.indexOf('this._keyCode229Recovery?.handleKeyEvent?.(ev)'); const earlyReturn = source.indexOf("ev.key === 'Process' || ev.keyCode === 229) return true"); expect(call).toBeGreaterThan(-1); expect(earlyReturn).toBeGreaterThan(-1); expect(call).toBeLessThan(earlyReturn); }); it("hands the controller this tile's own composition helper", () => { expect(source).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/); }); });