mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
The combined capture-pane and cursor query swallowed a thrown error and the caller then ran the same capture again, so a hung tmux blocked the event loop for two 5 s timeouts on every visible-frame capture, which serves every GET .../terminal load. A thrown error now reaches the capture's own catch; only output that splitFrameAndCursor cannot split falls back to the two separate calls. The capture runs through a captureRunner() seam, like inputRunner(), so the tests count the tmux calls each path makes.
355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
/**
|
||
* COD-47: full tmux scrollback replay on reload.
|
||
*
|
||
* Under VITEST, TmuxManager no-ops execSync (IS_TEST_MODE), so we can't drive
|
||
* real tmux. Instead we assert the capture-arg construction directly from
|
||
* source (same approach as tmux-capture-color.test.ts): a full-history capture
|
||
* must use `capture-pane -p -e -J -S -<N>` (bounded to the configured history
|
||
* limit, with an explicit exec maxBuffer) and skip the single-screen snapshot
|
||
* repaint, while the visible capture keeps `capture-pane -p -e`.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
formatCursorRestore,
|
||
formatPaneSnapshot,
|
||
hasVisibleContent,
|
||
queryPaneCursor,
|
||
splitFrameAndCursor,
|
||
TmuxManager,
|
||
} from '../src/tmux-manager.js';
|
||
|
||
describe('tmux full-history pane capture (COD-47)', () => {
|
||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||
// Bounded at the next method so `methodBody` really is one method: the
|
||
// ordering assertions below would otherwise be satisfiable by a neighbour.
|
||
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
||
const methodBody = source.slice(methodStart, methodEnd);
|
||
|
||
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
|
||
expect(methodStart).toBeGreaterThan(-1);
|
||
// The method signature must carry the opts channel...
|
||
expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions');
|
||
// ...and the body must branch on opts.fullHistory.
|
||
expect(methodBody).toContain('opts?.fullHistory === true');
|
||
});
|
||
|
||
it('full-history mode captures scrollback bounded to the configured history limit (-J -S -<N>)', () => {
|
||
// `-S -<N>` (not unbounded `-S -`) keeps tmux from serializing more
|
||
// scrollback than the configured history limit retains; `-J` re-joins
|
||
// lines hard-wrapped at the capture-time pane width.
|
||
expect(source).toContain('capture-pane -p -e -J -S -${historyLines}');
|
||
});
|
||
|
||
it('a full capture with a scrollbackOverride (remote history) reads the visible frame only (-J -S 0)', () => {
|
||
// The remote tmux supplied the history; reading local history too would
|
||
// splice two histories above one frame.
|
||
expect(methodBody).toContain('scrollbackOverride !== undefined');
|
||
expect(methodBody).toContain("'capture-pane -p -e -J -S 0'");
|
||
expect(methodBody).toContain('joinScrollbackAndFrame(');
|
||
});
|
||
|
||
it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => {
|
||
expect(methodBody).toContain('maxBuffer');
|
||
expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES');
|
||
});
|
||
|
||
it('still offers the visible single-screen capture for fast tab switches', () => {
|
||
expect(source).toContain("'capture-pane -p -e'");
|
||
});
|
||
|
||
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
|
||
// The fullHistory branch returns before the formatPaneSnapshot repaint,
|
||
// which is single-screen and would clip a multi-screen history.
|
||
const branch = methodBody.indexOf('if (fullHistory) {\n // Without geometry');
|
||
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||
expect(branch).toBeGreaterThan(-1);
|
||
expect(snapshot).toBeGreaterThan(-1);
|
||
expect(branch).toBeLessThan(snapshot);
|
||
// …and what it returns is normalized linear scrollback, not a repaint.
|
||
expect(methodBody.slice(branch, snapshot)).toContain('normalizeScrollbackEol(');
|
||
});
|
||
|
||
it('appends the pane cursor to the full-history capture', () => {
|
||
// A linear replay leaves the caret wherever the last character landed — the
|
||
// status line, for an agent CLI — and every cursor-relative update the CLI
|
||
// sends afterwards is then measured from the wrong row.
|
||
const restore = methodBody.indexOf('formatCursorRestore(geometry)');
|
||
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||
expect(restore).toBeGreaterThan(-1);
|
||
expect(restore).toBeLessThan(snapshot);
|
||
});
|
||
|
||
it('keeps the trailing rows only when a cursor move will follow', () => {
|
||
// Trailing blank rows are the bottom of the screen and the cursor move counts
|
||
// up from them, so the two decisions travel together: no geometry, no move,
|
||
// and the old trim applies instead.
|
||
expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')");
|
||
expect(methodBody).toContain("const trimmed = rawCapture.replace(/\\n+$/g, '')");
|
||
});
|
||
|
||
it('defers to the byte history when the pane holds nothing visible', () => {
|
||
expect(methodBody).toContain("if (!hasVisibleContent(trimmed)) return ''");
|
||
});
|
||
|
||
it('captureActivePaneBuffer forwards the capture options', () => {
|
||
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
|
||
expect(sig).toBeGreaterThan(-1);
|
||
const body = source.slice(sig, sig + 800);
|
||
expect(body).toContain('opts');
|
||
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
|
||
});
|
||
});
|
||
|
||
describe('full-history cursor restore', () => {
|
||
it('counts up from the last replayed row rather than down from the top', () => {
|
||
// Relative, not `CUP`: absolute row addressing is only correct while the
|
||
// browser's row count equals the pane's, and resizeWindow does not wait for
|
||
// tmux, so a capture can be taken before a requested resize has applied.
|
||
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 2, cursorY: 20 })).toBe('\x1b[3A\r\x1b[2C');
|
||
});
|
||
|
||
it('emits no row move when the caret is already on the last row', () => {
|
||
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 5, cursorY: 23 })).toBe('\r\x1b[5C');
|
||
});
|
||
|
||
it('emits no column move for column zero', () => {
|
||
expect(formatCursorRestore({ cols: 80, rows: 10, cursorX: 0, cursorY: 0 })).toBe('\x1b[9A\r');
|
||
});
|
||
});
|
||
|
||
describe('hasVisibleContent', () => {
|
||
it('is false for a pane of blank rows', () => {
|
||
expect(hasVisibleContent('\n'.repeat(23))).toBe(false);
|
||
});
|
||
|
||
it('is false for blank rows carrying only SGR attributes', () => {
|
||
// `capture-pane -e` styles every row, so an all-blank pane is not an empty
|
||
// string. Treating it as content would replace the byte history with a
|
||
// blank screen.
|
||
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m \x1b[0m')).toBe(false);
|
||
});
|
||
|
||
it('is true as soon as one row carries a character', () => {
|
||
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('the geometry a capture reports back', () => {
|
||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
||
const methodBody = source.slice(methodStart, methodEnd);
|
||
|
||
it('writes the pane size onto the caller options before either replay path returns', () => {
|
||
// IS_TEST_MODE no-ops execSync, so assert from source (same approach as the
|
||
// capture-flag tests above). The write must precede the fullHistory branch:
|
||
// both paths return from inside it, and a caller that got no geometry
|
||
// cannot tell a mismatched frame from a matching one.
|
||
const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }');
|
||
// Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only
|
||
// sizes the exec buffer.
|
||
const replayBranch = methodBody.indexOf('if (!geometry) {');
|
||
const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot(');
|
||
expect(write).toBeGreaterThan(-1);
|
||
expect(replayBranch).toBeGreaterThan(-1);
|
||
expect(visibleReturn).toBeGreaterThan(-1);
|
||
expect(write).toBeLessThan(replayBranch);
|
||
expect(write).toBeLessThan(visibleReturn);
|
||
});
|
||
|
||
it('reports nothing when the cursor query gave no geometry', () => {
|
||
// `queryPaneCursor` returns null on a failed or nonsensical query, and the
|
||
// snapshot repaint is skipped in that case. Reporting a size anyway would
|
||
// describe a frame that was never positioned.
|
||
expect(methodBody).toContain('if (opts && geometry)');
|
||
});
|
||
});
|
||
|
||
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
|
||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
|
||
|
||
it('reads it from the one cursor query the capture already makes', () => {
|
||
// No extra tmux call: every capture is a synchronous exec on the server.
|
||
expect(methodBody).toContain(
|
||
"'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"
|
||
);
|
||
expect(methodBody.match(/display-message/g)).toHaveLength(1);
|
||
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
|
||
});
|
||
|
||
it('parses the fifth field as historyLines', () => {
|
||
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
|
||
cursorX: 3,
|
||
cursorY: 5,
|
||
cols: 120,
|
||
rows: 40,
|
||
historyLines: 812,
|
||
});
|
||
// A pane in the alternate screen (fullscreen claude) holds none.
|
||
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
|
||
});
|
||
|
||
it('never lets a missing or bad history field void the caret', () => {
|
||
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
|
||
const geometry = queryPaneCursor(() => raw);
|
||
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
|
||
expect(geometry && 'historyLines' in geometry).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('still returns null when the geometry itself is bad', () => {
|
||
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
|
||
expect(
|
||
queryPaneCursor(() => {
|
||
throw new Error('no pane');
|
||
})
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('why a capture has to report its height', () => {
|
||
it('a snapshot addresses rows the receiving terminal may not have', () => {
|
||
// formatPaneSnapshot positions every row absolutely. A terminal shorter
|
||
// than the pane clamps each address past its own height onto its last
|
||
// line, so the overflow rows overwrite one another and the rows underneath
|
||
// are lost. Nothing in the escape sequence tells the client this happened —
|
||
// hence captureRows on the response.
|
||
const lines = Array.from({ length: 50 }, (_, i) => `row-${i + 1}`);
|
||
// cursorX 5 keeps the trailing cursor-restore move (`\x1b[50;6H`) out of the
|
||
// `;1H` row-paint match below, so the count is row paints alone.
|
||
const snapshot = formatPaneSnapshot(lines, { cols: 100, rows: 50, cursorX: 5, cursorY: 49 });
|
||
const addressed = [...snapshot.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
|
||
|
||
expect(Math.max(...addressed)).toBe(50);
|
||
// A 30-row terminal cannot honour 20 of those addresses.
|
||
expect(addressed.filter((row) => row > 30)).toHaveLength(20);
|
||
});
|
||
});
|
||
|
||
describe('queryPaneCursor history depth', () => {
|
||
it('reads tmux history_size as an optional fifth field', () => {
|
||
expect(queryPaneCursor(() => '4 22 133 24 25000\n')).toEqual({
|
||
cols: 133,
|
||
rows: 24,
|
||
cursorX: 4,
|
||
cursorY: 22,
|
||
historyLines: 25000,
|
||
});
|
||
});
|
||
|
||
it('keeps the geometry and omits the depth when the fifth field is missing or invalid', () => {
|
||
for (const raw of ['4 22 133 24', '4 22 133 24 abc', '4 22 133 24 -1']) {
|
||
const geometry = queryPaneCursor(() => raw);
|
||
expect(geometry, raw).toEqual({ cols: 133, rows: 24, cursorX: 4, cursorY: 22 });
|
||
}
|
||
});
|
||
|
||
it('reads tmux alternate_on as an optional sixth field', () => {
|
||
expect(queryPaneCursor(() => '4 22 133 24 29 1\n')).toEqual({
|
||
cols: 133,
|
||
rows: 24,
|
||
cursorX: 4,
|
||
cursorY: 22,
|
||
historyLines: 29,
|
||
alternateOn: true,
|
||
});
|
||
expect(queryPaneCursor(() => '4 22 133 24 29 0')?.alternateOn).toBe(false);
|
||
for (const raw of ['4 22 133 24 29', '4 22 133 24 29 2', '4 22 133 24 29 x']) {
|
||
expect(
|
||
queryPaneCursor(() => raw),
|
||
raw
|
||
).not.toHaveProperty('alternateOn');
|
||
}
|
||
});
|
||
|
||
it('asks tmux for the history depth and alternate screen in the same display-message as the cursor', () => {
|
||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
|
||
});
|
||
});
|
||
|
||
describe('the visible frame and its caret come from one tmux invocation', () => {
|
||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
|
||
|
||
it('chains the capture and the cursor query with `\;` on the visible path only', () => {
|
||
// Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms
|
||
// after input): the frame could predate the caret, and guarded stage refused it.
|
||
// Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`.
|
||
const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}';
|
||
expect(methodBody).toContain('exec(`' + chained + '`');
|
||
// Output it cannot split falls back to the two calls the capture always made.
|
||
expect(methodBody).toContain('combined?.frame ?? exec(captureCommand, execOpts)');
|
||
expect(methodBody).toContain('combined\n ? combined.cursor');
|
||
});
|
||
it('splits the combined output into the rows and the cursor line tmux prints last', () => {
|
||
expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({
|
||
frame: '› hello\n footer\n\n',
|
||
cursor: '7 0 100 20 0 0',
|
||
});
|
||
expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' });
|
||
});
|
||
it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => {
|
||
expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull();
|
||
expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull();
|
||
expect(splitFrameAndCursor('')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('how many tmux calls one visible-frame capture makes', () => {
|
||
type Run = (cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string;
|
||
class ScriptedTmux extends TmuxManager {
|
||
calls: string[] = [];
|
||
constructor(private readonly answer: (cmd: string) => string) {
|
||
super();
|
||
}
|
||
protected override captureRunner(): Run {
|
||
return (cmd) => {
|
||
this.calls.push(cmd.includes(' \\; ') ? 'combined' : cmd.includes('display-message') ? 'cursor' : 'capture');
|
||
return this.answer(cmd);
|
||
};
|
||
}
|
||
}
|
||
const quiet = () => vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
|
||
it('makes ONE call when the combined output splits', () => {
|
||
const tmux = new ScriptedTmux(() => '› hello\n footer\n7 0 100 20 0 0\n');
|
||
expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello');
|
||
expect(tmux.calls).toEqual(['combined']);
|
||
});
|
||
|
||
it('does not rerun a combined call that threw: a hung tmux costs one timeout, not two', () => {
|
||
const errors = quiet();
|
||
try {
|
||
const tmux = new ScriptedTmux(() => {
|
||
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' });
|
||
});
|
||
expect(tmux.capturePaneBuffer('codeman-12345678')).toBeNull();
|
||
expect(tmux.calls).toEqual(['combined']);
|
||
expect(errors).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
errors.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('falls back to the two separate calls only for output it cannot split', () => {
|
||
const tmux = new ScriptedTmux((cmd) =>
|
||
cmd.includes(' \\; ') ? '› hello\n footer\n' : cmd.includes('display-message') ? '7 0 100 20 0 0\n' : '› hello\n'
|
||
);
|
||
expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello');
|
||
expect(tmux.calls).toEqual(['combined', 'capture', 'cursor']);
|
||
});
|
||
|
||
it('keeps the full-history path on its two calls', () => {
|
||
const tmux = new ScriptedTmux((cmd) => (cmd.includes('display-message') ? '0 0 80 24 0 0\n' : 'row\n'));
|
||
tmux.capturePaneBuffer('codeman-12345678', undefined, { fullHistory: true });
|
||
expect(tmux.calls).toEqual(['capture', 'cursor']);
|
||
});
|
||
});
|