// test/split-pane-terminal-unit.test.ts // Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like // split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches). // // Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from // the final review of #453 that need no browser: destroy() nulling EVERY socket // handler (onclose used to survive it and fire its "disconnected" write into a // pane already torn down), and the `{t:'r'}` server-refresh path being // single-flight. Two refresh frames in a row used to start two concurrent // replays, each clearing the terminal under the other's chunked write; a // refresh arriving mid-replay is now coalesced into ONE trailing re-run rather // than dropped, because the in-flight fetch may predate the drop the new frame // reports and no further frame comes to correct stale content. // // The last block covers the scroll-to-top history pull: a burst of output leaves // a shell pane's xterm with about one screen of scrollback while tmux holds every // line, and Pane B (a separate xterm from the primary pane) never went back to // ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js. import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const TERMINAL_CHUNK_SIZE = 32 * 1024; const TERMINAL_TAIL_SIZE = 1024 * 1024; /** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */ let clock = 0; type FakeTerminal = { write: ReturnType; clear: ReturnType; dispose: ReturnType; scrollToLine: ReturnType; scrollToTop: ReturnType; cols: number; rows: number; options: { scrollback: number }; buffer: { active: { type: string; viewportY: number; length: number } }; }; type FakeSocket = { onopen: unknown; onmessage: unknown; onclose: unknown; onerror: unknown; close: ReturnType; }; type PaneUnderTest = { ws: FakeSocket | null; terminal: FakeTerminal | null; _destroyed: boolean; _bufferLoading: boolean; _bufferRefreshPending: boolean; _historyPullAt: number; _historyPullUseless: boolean; _liveQueue: unknown[] | null; _onWheel: unknown; _wsClosed: boolean; detachedSessions: Set | undefined; destroy(): void; _loadBuffer(): Promise; _refreshBuffer(): void; _maybeLoadMoreHistory(): void; _pullHistory(): Promise; _onLiveOutput(data: string): void; _onLiveClear(): void; _installWheelListener(): void; _writeDisconnectedMarker(): void; _onSocketClosed(): void; }; const fetchMock = vi.fn(); /** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */ const rafQueue: Array<() => void> = []; const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8'); function loadSplitTerminalPane() { const context = vm.createContext({ console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, // The primary pane's row estimator, reduced to a line count: the pull only // compares it with the pane's own row count. window: { app: { _estimateReplayRows: (text: string) => text.split('\n').length }, AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) }, }, performance: { now: () => clock }, fetch: (...args: unknown[]) => fetchMock(...args), requestAnimationFrame: (fn: () => void) => rafQueue.push(fn), // The constants.js globals the module reads at call time. TERMINAL_CHUNK_SIZE, TERMINAL_TAIL_SIZE, }); // The module's tail patches CodemanApp.prototype; nothing on it runs here. vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context); return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest }) .SplitTerminalPane; } const SplitTerminalPane = loadSplitTerminalPane(); function makePane( mode = 'claude', mount: unknown = {}, opts: { detachedSessions?: Set } = {} ): PaneUnderTest & { terminal: FakeTerminal } { const pane = new SplitTerminalPane('s1', mount, { mode, ...opts }); pane.terminal = { // xterm invokes a write's callback once everything before it is parsed. write: vi.fn((_data: string, done?: () => void) => done?.()), clear: vi.fn(), dispose: vi.fn(), scrollToLine: vi.fn(), scrollToTop: vi.fn(), cols: 80, rows: 30, // xterm keeps at most `scrollback + rows` rows; small here so a test can fill it. options: { scrollback: 1000 }, // A pane sitting at the top of a 40-row buffer on the normal screen. buffer: { active: { type: 'normal', viewportY: 0, length: 40 } }, }; return pane as PaneUnderTest & { terminal: FakeTerminal }; } const rowsOf = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n'); function jsonResponse(terminalBuffer: string, extra: Record = {}) { return { json: async () => ({ data: { terminalBuffer, ...extra } }) }; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected'); /** Lets every microtask the vm-side promise chain queued run. */ const settle = () => new Promise((r) => setTimeout(r, 0)); beforeEach(() => { fetchMock.mockReset(); rafQueue.length = 0; clock = 0; }); describe('SplitTerminalPane.destroy()', () => { it('nulls every WebSocket handler, onclose included, before closing the socket', () => { const pane = makePane(); const terminal = pane.terminal; const ws: FakeSocket = { onopen: vi.fn(), onmessage: vi.fn(), onclose: vi.fn(), onerror: vi.fn(), close: vi.fn() }; pane.ws = ws; pane.destroy(); // close() fires onclose asynchronously, so a handler left attached ran its // "disconnected" write against a pane whose terminal was already disposed. expect(ws.onopen).toBeNull(); expect(ws.onmessage).toBeNull(); expect(ws.onclose).toBeNull(); expect(ws.onerror).toBeNull(); expect(ws.close).toHaveBeenCalledTimes(1); expect(pane.ws).toBeNull(); expect(terminal.dispose).toHaveBeenCalledTimes(1); expect(pane.terminal).toBeNull(); expect(pane._destroyed).toBe(true); }); }); describe('SplitTerminalPane server-refresh single-flight', () => { it('a refresh with nothing in flight clears and fetches straight away', async () => { const pane = makePane(); fetchMock.mockResolvedValueOnce(jsonResponse('one')); pane._refreshBuffer(); await settle(); expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1'); expect(pane.terminal.write).toHaveBeenCalledWith('one'); expect(pane._bufferLoading).toBe(false); }); it('a shell pane asks for the bounded tail, matching connect()', async () => { const pane = makePane('shell'); fetchMock.mockResolvedValueOnce(jsonResponse('tail')); pane._refreshBuffer(); await settle(); expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`); }); it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => { const pane = makePane(); const first = deferred>(); const second = deferred>(); fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); pane._refreshBuffer(); expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); // Two more frames while the first replay is still in flight. pane._refreshBuffer(); pane._refreshBuffer(); expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); expect(pane._bufferRefreshPending).toBe(true); first.resolve(jsonResponse('replay-1')); await settle(); expect(pane.terminal.write).toHaveBeenCalledWith('replay-1'); // Exactly one trailing re-run for the two coalesced frames, not two. expect(pane.terminal.clear).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2); second.resolve(jsonResponse('replay-2')); await settle(); expect(pane.terminal.write).toHaveBeenLastCalledWith('replay-2'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(pane._bufferLoading).toBe(false); expect(pane._bufferRefreshPending).toBe(false); }); it('holds the flag across the chunked write, not just the fetch', async () => { const pane = makePane(); // Three chunks: two full ones plus a tail, so the last two are queued on // requestAnimationFrame and the replay is mid-write after the fetch lands. const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5); fetchMock.mockResolvedValueOnce(jsonResponse(big)); pane._refreshBuffer(); await settle(); expect(pane.terminal.write).toHaveBeenCalledTimes(1); expect(rafQueue).toHaveLength(1); expect(pane._bufferLoading).toBe(true); // A refresh mid-write must not clear the terminal under the chunks still // to come, nor start a second fetch. pane._refreshBuffer(); expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); fetchMock.mockResolvedValueOnce(jsonResponse('after')); rafQueue.shift()!(); rafQueue.shift()!(); await settle(); expect(pane.terminal.write).toHaveBeenCalledTimes(4); expect(pane.terminal.write).toHaveBeenLastCalledWith('after'); expect(pane.terminal.clear).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2); expect(pane._bufferLoading).toBe(false); }); it('a pending refresh is dropped once the pane is destroyed', async () => { const pane = makePane(); const first = deferred>(); fetchMock.mockReturnValueOnce(first.promise); pane._refreshBuffer(); pane._refreshBuffer(); pane.destroy(); first.resolve(jsonResponse('late')); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); expect(pane._bufferLoading).toBe(false); }); it('a failed fetch releases the flag so the next refresh can run', async () => { const pane = makePane(); fetchMock.mockRejectedValueOnce(new Error('offline')); pane._refreshBuffer(); await settle(); expect(pane._bufferLoading).toBe(false); fetchMock.mockResolvedValueOnce(jsonResponse('back')); pane._refreshBuffer(); await settle(); expect(pane.terminal.write).toHaveBeenCalledWith('back'); }); }); describe('SplitTerminalPane scroll-to-top history pull', () => { it('a shell pane at the top pulls a bounded window of full history and replays it', async () => { const pane = makePane('shell'); const term = pane.terminal; // The replay grows the buffer once xterm has parsed it (the empty write's callback). term.write.mockImplementation((data: string, done?: () => void) => { if (data === '' && done) term.buffer.active.length = 140; done?.(); }); fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); pane._maybeLoadMoreHistory(); await settle(); // With a deadline: live output is held for as long as the pull runs, so a // request that never answers would freeze the pane. expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, { signal: { timeoutMs: 10_000 }, }); expect(term.write).toHaveBeenCalledWith('\x1bc'); expect(term.write).toHaveBeenCalledWith(rowsOf(100)); // What was row 0 is now 100 rows down (140 - 40): the reader keeps their // place with the recovered history above it, instead of being dropped at the bottom. expect(term.scrollToLine).toHaveBeenCalledWith(100); expect(pane._bufferLoading).toBe(false); expect(pane._liveQueue).toBeNull(); }); it('does nothing away from the top, for other modes, or on the alternate screen', async () => { const midScroll = makePane('shell'); midScroll.terminal.buffer.active.viewportY = 12; midScroll._maybeLoadMoreHistory(); // A repaint-mode agent CLI keeps no tmux history to recover. makePane('claude')._maybeLoadMoreHistory(); // nano/vim/less own the wheel; their screen is not scrollback. const fullScreenApp = makePane('shell'); fullScreenApp.terminal.buffer.active.type = 'alternate'; fullScreenApp._maybeLoadMoreHistory(); await settle(); expect(fetchMock).not.toHaveBeenCalled(); }); it('stands aside for a detached session, mirroring _sendResize()', async () => { // A detached session's own window already owns its PTY size and // scrollback (buildSplitPickerSessions() already refuses to open one). const pane = makePane('shell', {}, { detachedSessions: new Set(['s1']) }); pane._maybeLoadMoreHistory(); await settle(); expect(fetchMock).not.toHaveBeenCalled(); }); it('a flick fires once: overlapping triggers are dropped, then the cooldown holds', async () => { const pane = makePane('shell'); const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); pane._maybeLoadMoreHistory(); const startedAt = pane._historyPullAt; // The cooldown is cleared between triggers on purpose, so that only the // in-flight guard can be what drops the overlapping ones. pane._historyPullAt = 0; pane._maybeLoadMoreHistory(); pane._historyPullAt = 0; pane._maybeLoadMoreHistory(); expect(fetchMock).toHaveBeenCalledTimes(1); pane._historyPullAt = startedAt; response.resolve(jsonResponse(rowsOf(100))); await settle(); expect(pane._bufferLoading).toBe(false); // Nothing in flight any more, so now it is the 4s cooldown alone. pane._maybeLoadMoreHistory(); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); // Once the cooldown lapses a later scroll-to-top may pull again. fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); pane._historyPullAt = Date.now() - 5000; pane._maybeLoadMoreHistory(); await settle(); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('a window the pane already holds in full is not rewritten, and is not latched as useless', async () => { const pane = makePane('shell'); fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); pane._maybeLoadMoreHistory(); await settle(); // A reset+rewrite here would jump the viewport for no new rows. expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); expect(pane.terminal.scrollToLine).not.toHaveBeenCalled(); expect(pane.terminal.scrollToTop).not.toHaveBeenCalled(); // The next burst can put more history in tmux than the pane has. expect(pane._historyPullUseless).toBe(false); expect(pane._bufferLoading).toBe(false); }); it('refuses a downgrade, keeping the 4s cooldown when the window is all of tmux history', async () => { const pane = makePane('shell'); pane.terminal.buffer.active.length = 500; fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5))); pane._maybeLoadMoreHistory(); await settle(); expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); // Untruncated: tmux has nothing older, but the next burst can add history. expect(pane._historyPullUseless).toBe(false); }); it('a truncated window that fits in the pane backs off for a minute', async () => { // Every ask costs the server a capture-pane of the WHOLE history (`tail` is // cut after the capture), and a window cut at the tail size can never reach // anything older than what the pane already shows. const pane = makePane('shell'); pane.terminal.buffer.active.length = 500; // Within a screen of what the pane holds, so the old downgrade guard never // latched it: only the truncated-skip rule can back this off. fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(480), { truncated: true, truncationReason: 'tail' })); pane._maybeLoadMoreHistory(); await settle(); expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); expect(pane._historyPullUseless).toBe(true); // Inside the 60s back-off, well past the normal 4s cooldown. pane._historyPullAt = Date.now() - 10_000; pane._maybeLoadMoreHistory(); await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('a pane already at its scrollback cap skips the window and backs off for a minute', async () => { // A 1 MiB window of short lines can carry more rows than xterm will ever hold // (`scrollback + rows`), so `incoming <= rows held` never comes true and every // scroll-to-top would reset and re-parse it. const pane = makePane('shell'); pane.terminal.buffer.active.length = 1030; fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5000))); pane._maybeLoadMoreHistory(); await settle(); expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc'); expect(pane.terminal.write).not.toHaveBeenCalledWith(rowsOf(5000)); expect(pane._historyPullUseless).toBe(true); }); it('a successful replay clears the one-minute back-off', async () => { const pane = makePane('shell'); pane._historyPullUseless = true; fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100), { truncated: true, truncationReason: 'tail' })); void pane._pullHistory(); await settle(); expect(pane.terminal.write).toHaveBeenCalledWith(rowsOf(100)); expect(pane._historyPullUseless).toBe(false); }); it('holds live output during the replay and replays only what arrived after the capture', async () => { const pane = makePane('shell'); const term = pane.terminal; const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); pane._maybeLoadMoreHistory(); expect(pane._liveQueue).toEqual([]); // Arrives before the response does: it is IN the capture already. clock = 1; pane._onLiveOutput('early'); expect(term.write).not.toHaveBeenCalledWith('early'); await settle(); // 200 rows (more than the pane holds, so it replays) of 400 columns each: // three chunks, which leaves the replay mid-write once the fetch lands. const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n'); expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2); clock = 2; // the response arrives: this is the cutoff response.resolve(jsonResponse(bigReplay)); await settle(); expect(rafQueue).toHaveLength(1); // Arrives while the snapshot is still being written: must not land under it. clock = 3; pane._onLiveOutput('late'); expect(term.write).not.toHaveBeenCalledWith('late'); rafQueue.shift()!(); rafQueue.shift()!(); await settle(); const written = term.write.mock.calls.map((call) => call[0]); expect(written).not.toContain('early'); expect(written.at(-1)).toBe('late'); expect(pane._liveQueue).toBeNull(); expect(pane._bufferLoading).toBe(false); }); it('writes every held frame when the pull ends without replaying', async () => { const pane = makePane('shell'); const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); pane._maybeLoadMoreHistory(); pane._onLiveOutput('held'); await settle(); response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay await settle(); // Nothing replaced the terminal, so the frame is news even though it // arrived before the response did. expect(pane.terminal.write).toHaveBeenCalledWith('held'); }); it('a failed fetch releases the flag and the queue, so live output flows again', async () => { const pane = makePane('shell'); fetchMock.mockRejectedValueOnce(new Error('offline')); pane._maybeLoadMoreHistory(); pane._onLiveOutput('held'); await settle(); expect(pane._bufferLoading).toBe(false); expect(pane._liveQueue).toBeNull(); expect(pane.terminal.write).toHaveBeenCalledWith('held'); pane._onLiveOutput('after'); expect(pane.terminal.write).toHaveBeenLastCalledWith('after'); }); it('a refresh frame during the pull runs once behind it', async () => { const pane = makePane('shell'); const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed')); pane._maybeLoadMoreHistory(); pane._refreshBuffer(); expect(pane.terminal.clear).not.toHaveBeenCalled(); expect(pane._bufferRefreshPending).toBe(true); response.resolve(jsonResponse(rowsOf(30))); await settle(); expect(pane.terminal.clear).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(2); expect(pane.terminal.write).toHaveBeenCalledWith('refreshed'); }); it('a clear frame during the pull is queued in order, never applied under the replay', async () => { const pane = makePane('shell'); const term = pane.terminal; const order: string[] = []; term.write.mockImplementation((data: string, done?: () => void) => { order.push(`write:${data}`); done?.(); }); term.clear.mockImplementation(() => order.push('clear')); const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); pane._maybeLoadMoreHistory(); pane._onLiveOutput('before'); pane._onLiveClear(); pane._onLiveOutput('after'); // Held: clearing now would wipe a half-written snapshot. expect(order).toEqual([]); response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay await settle(); expect(order).toEqual(['write:before', 'clear', 'write:after']); expect(pane._liveQueue).toBeNull(); // With nothing in flight a clear frame applies straight away. pane._onLiveClear(); expect(order.at(-1)).toBe('clear'); }); it('a clear that arrived before the capture is not replayed after it', async () => { const pane = makePane('shell'); const term = pane.terminal; const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); pane._maybeLoadMoreHistory(); clock = 1; pane._onLiveClear(); // already reflected in the capture clock = 2; response.resolve(jsonResponse(rowsOf(100))); await settle(); expect(term.write).toHaveBeenCalledWith('\x1bc'); expect(term.clear).not.toHaveBeenCalled(); }); it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => { const pane = makePane('shell'); const term = pane.terminal; const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); pane._maybeLoadMoreHistory(); pane._onLiveOutput('held'); pane.destroy(); response.resolve(jsonResponse(rowsOf(100))); await settle(); expect(pane._bufferLoading).toBe(false); expect(pane._liveQueue).toBeNull(); expect(pane.terminal).toBeNull(); expect(term.write).not.toHaveBeenCalledWith('\x1bc'); expect(term.write).not.toHaveBeenCalledWith('held'); }); it('a pull whose request is aborted (the deadline) frees the pane', async () => { const pane = makePane('shell'); fetchMock.mockRejectedValueOnce(new Error('The operation timed out')); pane._maybeLoadMoreHistory(); pane._onLiveOutput('held'); await settle(); expect(pane._bufferLoading).toBe(false); expect(pane._liveQueue).toBeNull(); expect(pane.terminal.write).toHaveBeenCalledWith('held'); }); it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => { const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; const pane = makePane('shell', mount); fetchMock.mockResolvedValue(jsonResponse(rowsOf(100))); pane._installWheelListener(); // Capture phase: xterm's own wheel handler stopPropagation()s the events it // consumes, so a bubbling listener would never fire while the pane still has // scrollback to scroll, and the pull would work only from the exact top row. const [type, listener, options] = mount.addEventListener.mock.calls[0]; expect(type).toBe('wheel'); expect(options).toEqual({ capture: true, passive: true }); listener({ deltaY: 120 }); // wheel down listener({ deltaY: 0 }); await settle(); expect(fetchMock).not.toHaveBeenCalled(); listener({ deltaY: -120 }); // wheel up, at the top await settle(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('destroy() detaches exactly the wheel listener it registered', () => { const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() }; const pane = makePane('shell', mount); pane._installWheelListener(); const registered = mount.addEventListener.mock.calls[0][1]; pane.destroy(); expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', registered, { capture: true }); expect(pane._onWheel).toBeNull(); }); it('connect() installs the wheel listener (static guard)', () => { // connect() needs a whole xterm to run, so its wiring is pinned by source // rather than executed; the listener's behaviour is exercised above. const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()')); expect(connect).toContain('this._installWheelListener();'); expect(connect).toContain('this._onLiveClear();'); expect(connect).not.toContain('this.terminal.clear();'); // The tests below drive the close through _onSocketClosed() directly. expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();'); }); it('a close with no pull running writes the marker straight away', () => { const pane = makePane('shell'); pane._onSocketClosed(); expect(pane._wsClosed).toBe(true); expect(pane.terminal.write).toHaveBeenCalledTimes(1); expect(isMarker(pane.terminal.write.mock.calls[0][0])).toBe(true); }); it('re-stamps the disconnected marker after a replay if the socket closed before the pull started', async () => { // onclose already wrote the marker once; a replay's own `\x1bc` would wipe // it and paint a fresh, current-looking history while onData keeps // silently dropping every keystroke on the dead socket. const pane = makePane('shell'); pane._wsClosed = true; fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); void pane._pullHistory(); await settle(); const marker = expect.stringContaining('Pane B disconnected'); const writes = pane.terminal.write.mock.calls.map((c) => c[0]); expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/)); expect(pane.terminal.write).toHaveBeenCalledWith(marker); }); it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => { // The close lands while the capture is in flight, so the HTTP pull still // succeeds (a Codeman restart drops the WS while the tmux session, and so // the pull, survives) and the replay that follows is what the marker must // end up below. const pane = makePane('shell'); const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); const pull = pane._pullHistory(); pane._onSocketClosed(); // the close arrives mid-fetch, before the response expect(pane.terminal.write).not.toHaveBeenCalled(); response.resolve(jsonResponse(rowsOf(100))); await pull; const writes = pane.terminal.write.mock.calls.map((c) => c[0]); expect(writes.filter(isMarker)).toHaveLength(1); expect(isMarker(writes.at(-1))).toBe(true); }); it.each([ ['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))], ['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))], ['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))], ])( 'a close mid-fetch that ends in %s writes the marker last, after the held frames', async (_label, rowsHeld, mockFetch) => { // No replay ever runs here, so nothing would wipe a marker written at the // close; written straight away it sat ABOVE the output the pull was still // holding, which the finally block then flushed underneath it. const pane = makePane('shell'); pane.terminal.buffer.active.length = rowsHeld; mockFetch(); const pull = pane._pullHistory(); pane._onLiveOutput('frame-A'); pane._onLiveOutput('frame-B'); pane._onSocketClosed(); expect(pane.terminal.write).not.toHaveBeenCalled(); await pull; const writes = pane.terminal.write.mock.calls.map((c) => c[0]); expect(writes.slice(0, 2)).toEqual(['frame-A', 'frame-B']); expect(writes).toHaveLength(3); expect(isMarker(writes[2])).toBe(true); expect(pane._liveQueue).toBeNull(); } ); it('a close during the chunked replay writes exactly one marker, at the end', async () => { const pane = makePane('shell'); const response = deferred>(); fetchMock.mockReturnValueOnce(response.promise); const pull = pane._pullHistory(); // Three chunks, so the replay is still mid-write once the fetch lands. const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n'); response.resolve(jsonResponse(bigReplay)); await settle(); expect(rafQueue).toHaveLength(1); // Written now, the marker would land between two chunks of recovered history. pane._onSocketClosed(); rafQueue.shift()!(); rafQueue.shift()!(); await pull; const writes = pane.terminal.write.mock.calls.map((c) => c[0]); expect(writes[0]).toBe('\x1bc'); expect(writes.filter(isMarker)).toHaveLength(1); expect(isMarker(writes.at(-1))).toBe(true); }); it('does not re-stamp the marker when the socket is still open', async () => { const pane = makePane('shell'); fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100))); void pane._pullHistory(); await settle(); for (const call of pane.terminal.write.mock.calls) { expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/)); } }); it('does not re-stamp the marker when the pull never replayed (skip/downgrade path)', async () => { // Nothing erased the marker in this path, so re-stamping it would be a // second, redundant write. const pane = makePane('shell'); pane._wsClosed = true; fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); // held in full already: no replay void pane._pullHistory(); await settle(); expect(pane.terminal.write).not.toHaveBeenCalled(); }); });