Merge pull request #435

fix(terminal): replay a pane capture at the geometry it was taken at
This commit is contained in:
Ark0N
2026-09-19 12:18:03 +02:00
committed by GitHub
9 changed files with 963 additions and 35 deletions
+533
View File
@@ -0,0 +1,533 @@
/**
* @fileoverview A capture drawn for a bigger pane makes the client replay once.
*
* A visible-frame capture repaints each row at an absolute position, counting
* up to the PANE's height and out to the PANE's width. A terminal shorter than
* that clamps every address past its own height onto its last line, so the
* overflow rows overwrite one another and the rows underneath are lost. A
* narrower terminal wraps every painted row, and the wrap on the last one
* scrolls the whole frame up by one. The client cannot see either from the
* escape sequence, so the terminal response reports the geometry the capture
* was taken at (`captureCols`/`captureRows`) and `selectSession` replays once
* at the size that stuck.
*
* The comparison runs on a `mux-visible` response ONLY. The other two sources
* position no rows absolutely, so a size mismatch damages neither and a replay
* repairs neither, and the last case here pins that the expensive one is left
* alone.
*
* These drive the REAL client in chromium and stub only the terminal endpoint,
* because the mismatch itself needs two viewports to stage against live tmux.
* Without the fix the first assertion below sees one fetch instead of two.
*
* Port: 3252 (capture geometry retry)
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3252;
const BASE_URL = `http://localhost:${PORT}`;
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 60_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
/** A visible-frame capture: one absolutely-addressed paint per row. */
function paneSnapshot(rows: number): string {
const parts: string[] = [];
for (let row = 1; row <= rows; row++) parts.push(`\x1b[${row};1Hprobe-row-${row}`);
parts.push(`\x1b[${rows};6H`);
return parts.join('');
}
/**
* Serve every terminal fetch from a stub reporting `captureRows`, counting the
* fetches. The real route needs live tmux to produce a mismatched frame.
*
* `source` is DERIVED from the request the way the real route derives it: a
* `full=1` request whose capture came back is `mux-full-history`, and every
* other one is `mux-visible`. The route cannot answer `full=1` with
* `mux-visible`, so a stub that did would stage a combination production never
* produces, and a test resting on it would prove nothing about production. A
* test that needs some other source passes it explicitly and says why.
*/
async function stubTerminal(
page: Page,
captureRows: number,
counter: { n: number; urls: string[] },
options: { source?: string; captureCols?: number } = {}
) {
const captureCols = options.captureCols ?? 200;
await page.route('**/api/sessions/*/terminal*', async (route) => {
const url = route.request().url();
counter.n += 1;
counter.urls.push(url);
const source = options.source ?? (url.includes('full=1') ? 'mux-full-history' : 'mux-visible');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: paneSnapshot(captureRows),
status: 'idle',
fullSize: 1024,
retainedBytes: 1024,
truncated: false,
truncationReason: null,
source,
captureCols,
captureRows,
},
}),
});
});
}
/**
* As `stubTerminal`, but reading its geometry from a holder the test can change
* between selects. That is what lets one case watch a pane stop fitting and
* start fitting again, which a stub fixed at construction cannot show.
*/
async function stubTerminalDynamic(
page: Page,
counter: { n: number; urls: string[] },
state: { captureRows: number; captureCols: number }
) {
await page.route('**/api/sessions/*/terminal*', async (route) => {
const url = route.request().url();
counter.n += 1;
counter.urls.push(url);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: paneSnapshot(state.captureRows),
status: 'idle',
fullSize: 1024,
retainedBytes: 1024,
truncated: false,
truncationReason: null,
source: url.includes('full=1') ? 'mux-full-history' : 'mux-visible',
captureCols: state.captureCols,
captureRows: state.captureRows,
},
}),
});
});
}
/**
* Answer every fetch with the geometry the client itself is asking for, read
* live from the page. That is the clamp signature: `getTerminalDimensions()`
* floors at 40x10 while `fitAddon.fit()` does not, so a small enough viewport
* makes the pane permanently bigger than the terminal at a size the client
* requested itself.
*/
async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; urls: string[] }) {
await page.route('**/api/sessions/*/terminal*', async (route) => {
counter.n += 1;
counter.urls.push(route.request().url());
const dims = await page.evaluate(
() =>
(
window as unknown as { app: { getTerminalDimensions?: () => { cols: number; rows: number } | null } }
).app.getTerminalDimensions?.() ?? null
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: paneSnapshot(dims?.rows ?? 10),
status: 'idle',
fullSize: 1024,
retainedBytes: 1024,
truncated: false,
truncationReason: null,
source: 'mux-visible',
captureCols: dims?.cols,
captureRows: dims?.rows,
},
}),
});
});
}
/** The widest terminal this suite's 1280px viewport can produce, with margin. */
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
async function openSession(page: Page): Promise<string> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
// Without it `app.terminal.rows` reads 0 and every height comparison below
// would pass vacuously.
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
timeout: 30_000,
});
return page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-geometry-test' }),
});
const body = await res.json();
return body.data?.session?.id ?? body.data?.id ?? body.id;
});
}
/** The terminal is sized by the first select, so this only reads after one. */
async function terminalRows(page: Page): Promise<number> {
return page.evaluate(() => (window as unknown as { app: { terminal?: { rows: number } } }).app.terminal?.rows ?? 0);
}
/** As above, for the width half of the comparison. */
async function terminalCols(page: Page): Promise<number> {
return page.evaluate(() => (window as unknown as { app: { terminal?: { cols: number } } }).app.terminal?.cols ?? 0);
}
async function select(page: Page, sessionId: string, options: object = {}): Promise<void> {
await page.evaluate(
async ({ sid, opts }) => {
const app = (window as unknown as { app: { selectSession: (id: string, o?: object) => Promise<void> } }).app;
await app.selectSession(sid, opts);
},
{ sid: sessionId, opts: options }
);
await page.waitForTimeout(1500);
}
/**
* Spend the per-page full-history allowance and forget what it cost. Every
* geometry comparison below runs on a `mux-visible` response, and the route
* only produces one for a request sent WITHOUT `full=1`, so reaching that shape
* means not being the first select of the page — which is what a tab switch is.
*/
async function consumeFullHistory(
page: Page,
sessionId: string,
counter: { n: number; urls: string[] }
): Promise<void> {
await select(page, sessionId);
counter.n = 0;
counter.urls.length = 0;
}
async function closeSession(page: Page, sessionId: string): Promise<void> {
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
}
describe('a capture bigger than the terminal', () => {
let context: BrowserContext;
let page: Page;
afterAll(async () => {
await context?.close();
});
it('replays once when the captured pane is taller, and stops at one retry', async () => {
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
expect(sessionId).toBeTruthy();
// 200 rows is taller than any terminal this viewport can produce, so the
// trigger is the captured height alone and not a size that moved.
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 200, fetches);
// A tab switch is where a visible-frame response arrives, so that is what
// this measures. The first select of the page takes the full-history path
// and is covered by its own case below.
await consumeFullHistory(page, sessionId, fetches);
await select(page, sessionId, { forceReload: true });
// The terminal is sized by that select, so the premise is checkable now.
expect(await terminalRows(page)).toBeLessThan(200);
// One original load plus exactly one retry. `resizeRetry` caps it there:
// the retry's own response reports the same mismatch, so an uncapped
// implementation would loop.
expect(fetches.n).toBe(2);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('retries at the same scope the first pass used, not a wider one', async () => {
// The retry re-arms the full-history flag only when the pass that ran had
// consumed it. A tab switch takes the bounded tail, so its retry must take
// the tail too; clearing the flag unconditionally would upgrade it into a
// fresh multi-megabyte scrollback capture the user never asked for.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 200, fetches);
// First select: a fresh session, so this one pulls full history. It does
// NOT retry, because the geometry comparison runs on a visible-frame
// response and a `full=1` request cannot produce one.
await select(page, sessionId);
expect(fetches.n).toBe(1);
expect(fetches.urls.filter((u) => u.includes('full=1'))).toHaveLength(1);
// Re-select the SAME session. `selectSession` early-returns on an already
// active session unless forceReload is set, and forceReload is the shape a
// tab switch back to this session takes: `_fullHistoryLoaded` still holds
// it, so neither this pass nor its retry asks for full history again.
await select(page, sessionId, { forceReload: true });
const tabSwitchUrls = fetches.urls.slice(1);
expect(tabSwitchUrls.length).toBe(2);
expect(tabSwitchUrls.filter((u) => u.includes('full=1'))).toHaveLength(0);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('does not replay when the captured pane fits the terminal', async () => {
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
// Five rows is shorter than any terminal this viewport can produce, so the
// frame fits, nothing is clamped, and nothing needs repeating. A retry here
// would double the work of every tab switch.
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 5, fetches, { captureCols: 40 });
await consumeFullHistory(page, sessionId, fetches);
await select(page, sessionId, { forceReload: true });
expect(await terminalRows(page)).toBeGreaterThan(5);
expect(fetches.n).toBe(1);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('replays once when the captured pane is wider', async () => {
// A pane wider than the terminal damages the same frame a second way.
// `formatPaneSnapshot` paints every row out to the PANE's width, so a
// narrower browser wraps each painted row, and the wrap on the last row
// scrolls the whole frame up by one. The height here fits deliberately, so
// the width is the only thing that can trigger the replay.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 5, fetches, { captureCols: WIDER_THAN_ANY_TERMINAL_COLS });
await consumeFullHistory(page, sessionId, fetches);
await select(page, sessionId, { forceReload: true });
expect(await terminalRows(page)).toBeGreaterThan(5);
expect(await terminalCols(page)).toBeLessThan(WIDER_THAN_ANY_TERMINAL_COLS);
expect(fetches.n).toBe(2);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('does not replay a full-history response, whatever geometry it reports', async () => {
// A `full=1` body is linear scrollback closed by a RELATIVE cursor move,
// which is relative precisely so the browser's row count need not match the
// pane's. A mismatch there is not damage and a replay cannot repair it, so
// the geometry comparison must not fire on it. This is the path that makes
// the gate worth having: `_fullHistoryLoaded` is empty on the first select
// of every non-shell session per page, so an ungated comparison would pull
// the entire tmux scrollback a second time on every page load and every
// first tab switch, for a session whose pane a desktop tab is holding too
// tall to ever fit.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 200, fetches, { captureCols: WIDER_THAN_ANY_TERMINAL_COLS });
await select(page, sessionId);
// Both dimensions are mismatched, so height alone is not what spares it.
expect(await terminalRows(page)).toBeLessThan(200);
expect(await terminalCols(page)).toBeLessThan(WIDER_THAN_ANY_TERMINAL_COLS);
expect(fetches.n).toBe(1);
expect(fetches.urls.filter((u) => u.includes('full=1'))).toHaveLength(1);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('replays once per session, not once per tab switch, when it cannot converge', async () => {
// `resizeRetry` caps the recursion inside ONE select and says nothing about
// the next one, so a pane this browser cannot size reported the same
// mismatch on every select and bought the same failed repair every time:
// two fetches per tab switch for the life of the page. That is the case the
// description calls "every time rather than occasionally", a phone whose
// resize is declined while a desktop claim is live, and it is not the only
// one — any pane Codeman cannot size lands there, a second tmux client
// attached to it included. Each wasted pass costs another `capture-pane`,
// which is `execSync` on the server's event loop, plus a reset and rewrite,
// a discarded snapshot, and a dropped and reopened WebSocket.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
const pane = { captureRows: 200, captureCols: 200 };
await stubTerminalDynamic(page, fetches, pane);
await consumeFullHistory(page, sessionId, fetches);
// First tab switch: one load, one replay, and the replay does not fit
// either, which is the proof that this pane ignores the size it is given.
await select(page, sessionId, { forceReload: true });
expect(fetches.n).toBe(2);
// Every switch after it pays once. Unlatched this reads 4 then 6.
await select(page, sessionId, { forceReload: true });
expect(fetches.n).toBe(3);
await select(page, sessionId, { forceReload: true });
expect(fetches.n).toBe(4);
// The memo has to lift when the pane becomes sizeable again, or closing the
// desktop tab that was holding it would leave this session permanently
// unrepaired. A frame that fits clears it...
pane.captureRows = 5;
pane.captureCols = 40;
await select(page, sessionId, { forceReload: true });
expect(fetches.n).toBe(5);
// ...so the next genuine mismatch is diagnosed again.
pane.captureRows = 200;
pane.captureCols = 200;
await select(page, sessionId, { forceReload: true });
expect(fetches.n).toBe(7);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('hands over text typed but not yet submitted before it replays', async () => {
// On a touch device the characters the user has typed live ONLY in the
// local-echo overlay until Enter; they have never reached the PTY. The
// replay re-enters `selectSession` with `forceReload` on the session that
// is still active, and that branch used to null `activeSessionId` before
// `_cleanupPreviousSession` ran, so the flush there saw no session and the
// unconditional `clear()` afterwards took the characters with it. Nothing
// the user did triggered that: the replay fires on its own the moment a
// tab switch finishes, which is exactly when someone typing into a
// still-loading terminal has text in the overlay.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 200, fetches);
await consumeFullHistory(page, sessionId, fetches);
// Headless chromium reports `isTouchDevice()` false even with `hasTouch`,
// so the overlay would stay off and the whole case would pass vacuously.
// The setting is what `_updateLocalEchoState()` reads, so it survives the
// recompute that every select runs; the flag is forced too, for the window
// before the next recompute. Record what crosses into the delivery layer,
// which is the seam the text failed to cross.
await page.evaluate(() => {
const w = window as unknown as {
app: {
_localEchoEnabled: boolean;
_sendInputAsync: (id: string, text: string, opts?: unknown) => void;
terminal?: { focus: () => void };
loadAppSettingsFromStorage: () => Record<string, unknown>;
};
__sentInputs: { id: string; text: string }[];
};
const settings = w.app.loadAppSettingsFromStorage();
settings.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(settings));
w.app._localEchoEnabled = true;
w.__sentInputs = [];
const original = w.app._sendInputAsync.bind(w.app);
w.app._sendInputAsync = (id: string, text: string, opts?: unknown) => {
w.__sentInputs.push({ id, text });
return original(id, text, opts);
};
w.app.terminal?.focus();
});
await page.keyboard.type('hello-unsent');
// The premise: the characters really are sitting in the overlay, unsent.
// Without this the case would pass on a build where typing goes straight
// to the PTY and there is nothing to lose.
const pendingBefore = await page.evaluate(
() =>
(window as unknown as { app: { _localEchoOverlay?: { pendingText: string } } }).app._localEchoOverlay
?.pendingText ?? ''
);
expect(pendingBefore).toBe('hello-unsent');
// The captured pane is taller than the terminal, so this select replays.
await select(page, sessionId, { forceReload: true });
expect(fetches.n).toBe(2);
const sent = await page.evaluate(
() => (window as unknown as { __sentInputs: { id: string; text: string }[] }).__sentInputs
);
expect(sent.map((s) => s.text)).toContain('hello-unsent');
expect(sent.find((s) => s.text === 'hello-unsent')?.id).toBe(sessionId);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('does not replay a pane already at the size the client asked for', async () => {
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
// not, so a viewport this small leaves the terminal shorter than the size
// the client itself requests, and the pane obligingly draws at the floored
// size. The captured height then exceeds the terminal's forever. A replay
// cannot converge, because it re-requests the same floored size and
// captures the same frame, so without the equality guard this retries on
// every tab switch for the life of the page.
context = await browser.newContext({ viewport: { width: 320, height: 200 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminalAtRequestedSize(page, fetches);
await consumeFullHistory(page, sessionId, fetches);
await select(page, sessionId, { forceReload: true });
// The premise: the floor really does bind here. Without this the case
// would pass on any viewport, proving nothing.
const requested = await page.evaluate(
() =>
(
window as unknown as { app: { getTerminalDimensions?: () => { cols: number; rows: number } | null } }
).app.getTerminalDimensions?.() ?? null
);
expect(requested).not.toBeNull();
expect(requested!.rows).toBeGreaterThan(await terminalRows(page));
expect(fetches.n).toBe(1);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
});
+99 -6
View File
@@ -775,7 +775,85 @@ describe('session-routes', () => {
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
// No ?full=1 → visible-frame capture (no fullHistory opts).
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
// ── The geometry a capture was taken at ──
//
// A visible-frame capture repaints each row at an absolute position
// (`\x1b[<row>;1H`). A terminal with fewer rows than the pane clamps every
// address past its own height onto its last line, so the overflow rows
// overwrite each other and the rows they land on are lost. The client can
// only notice that if the response says what height the frame was built
// for, which is what captureRows/captureCols carry.
it('reports the geometry the capture was really taken at', async () => {
harness.ctx._session.terminalBuffer = '';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedGeometry?: { cols: number; rows: number } }) => {
// Stand in for TmuxManager, which fills this from the pane itself.
if (opts) opts.capturedGeometry = { cols: 100, rows: 50 };
return 'visible frame';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('mux-visible');
expect(body.data.captureCols).toBe(100);
expect(body.data.captureRows).toBe(50);
});
it('omits the geometry when the capture reports none', async () => {
// The cursor query can fail, and a byte-history response never captures
// at all. Neither frame was positioned, so neither can be damaged by a
// terminal of the wrong size. Naming the session's own PTY size here
// would describe a geometry no frame was built for, and the client would
// read it as a mismatch worth replaying for.
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.captureCols).toBeUndefined();
expect(body.data.captureRows).toBeUndefined();
});
it('omits the geometry when the capture reported a size but returned nothing', async () => {
// A capture can report geometry and still hand back no frame. The
// full-history path writes `capturedGeometry` from the cursor query, then
// returns '' for a pane holding nothing visible, which drops the source
// to `history` with the geometry already recorded. Reporting it there
// would name a size for a body that is the byte stream.
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedGeometry?: { cols: number; rows: number } }) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 50 };
return '';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.captureCols).toBeUndefined();
expect(body.data.captureRows).toBeUndefined();
});
// ── COD-47: full tmux scrollback replay on full page reload ──
@@ -985,7 +1063,10 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Tail/tab-switch must NOT request fullHistory (undefined opts).
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(captureSpy).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
expect(body.data.terminalBuffer).toContain('visible frame only');
expect(body.data.terminalBuffer).not.toContain('FULL_HISTORY_SHOULD_NOT_APPEAR');
expect(body.data.source).toBe('mux-visible');
@@ -1045,7 +1126,10 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
@@ -1119,7 +1203,10 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
@@ -1138,7 +1225,10 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
expect(body.data.terminalBuffer).toContain('› current prompt');
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('returns error for unknown session', async () => {
@@ -1166,7 +1256,10 @@ describe('session-routes', () => {
expect(buf).toContain('\x1b[H\x1b[2J');
expect(buf).toContain('LIVE-PANE-FRAME');
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('falls back to the byte history when no live pane buffer is available', async () => {
+51 -1
View File
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatCursorRestore, hasVisibleContent } from '../src/tmux-manager.js';
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } 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');
@@ -121,3 +121,53 @@ describe('hasVisibleContent', () => {
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) return normalizeScrollbackEol(');
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('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);
});
});