Files
Codeman/test/shell-scroll-history-pull.test.ts
T
Codeman maintainer 714050fe8a fix(terminal): merge-time fixes for #494
- Skip and latch a bounded Shell window once the browser is at xterm's
  scrollback cap (scrollback + rows): a 1 MiB window of short lines can carry
  more rows than the browser can ever hold, so it replayed and re-captured on
  every scroll-to-top with no 60 s back-off.
- Label a replayed bounded window 'tail' even when the capture was byte-capped,
  so the banner keeps offering Load full history instead of calling the rest
  unrecoverable.
- Pin GET /terminal?full=1&tail=<n> in the route tests: full-history source,
  truncationReason 'tail', and the closing relative cursor move survive the cut.
- Log the bounded skip via _logScrollRouting('repull-skipped-bounded').

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:40 +02:00

361 lines
18 KiB
TypeScript

/**
* @fileoverview A shell pane's scroll-up must reach the history tmux still holds.
*
* tmux repaints a burst of output instead of scrolling it, so after `cat` of a
* file longer than the screen the browser holds about one screen of scrollback
* while tmux holds all of it. Other modes recover it by re-pulling `?full=1`
* when the wheel reaches the top (`_maybeRefetchFullHistory`, issue #205). Shell
* declined that gesture outright to keep a multi-megabyte capture off xterm's
* main thread, leaving only the "Load full history" button, and that button
* renders only once a replay was truncated. A young shell tab therefore had no
* way to scroll back at all.
*
* The gesture now pulls a BOUNDED window (`?full=1&tail=TERMINAL_TAIL_SIZE`),
* the button stays the unbounded path, and a window the browser already holds
* in full is not rewritten. Neither is one a browser at xterm's scrollback cap
* could never hold, and a window cut from a byte-capped capture is still labelled
* recoverable, since Load full history can reach past it.
*
* ORDER MATTERS: that skip must run BEFORE the downgrade guard. The guard reads
* "smaller than the browser" as "tmux has nothing more to give", which is true of
* an unbounded capture and false of a window cut at the tail size, so a bounded
* window that reached it marked the session exhausted and took Load full history
* off the banner while tmux still held the rest. The second block below drives
* the real `_setHistoryTruncation` and the real `computeHistoryTruncationNotice`
* to pin what the user is actually told.
*
* The method is extracted from app.js and run in a `vm` against stubs (no jsdom
* on this box; see connection-indicator.test.ts), with the REAL row estimators
* from terminal-ui.js, which decide both the downgrade and the no-gain skip.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const TERMINAL_TAIL_SIZE = 1024 * 1024;
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
/** Real terminal-ui.js mixin, for `_estimateReplayRows` / `_replayWouldShrinkBuffer`. */
function loadTerminalMixin(): Record<string, unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function loadRefetch(): (this: unknown, opts?: { force?: boolean }) => Promise<void> {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = methodSource(app, '_maybeRefetchFullHistory');
const context = vm.createContext({ performance, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE: 32 * 1024 });
return vm.runInContext(`({ ${body} })._maybeRefetchFullHistory`, context);
}
/** The REAL `_setHistoryTruncation`, so the banner state a pull leaves behind is what production would hold. */
function loadSetHistoryTruncation(): (this: unknown, sessionId: string, payload?: Record<string, unknown>) => void {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = methodSource(app, '_setHistoryTruncation');
return vm.runInContext(`({ ${body} })._setHistoryTruncation`, vm.createContext({}));
}
/** The REAL banner decision from constants.js: what the user is told, and whether Load full history is offered. */
function loadNotice() {
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}\n;globalThis.__notice = computeHistoryTruncationNotice;`,
context,
{ filename: 'constants.js' }
);
return (context as { __notice: (s: Record<string, unknown>) => { visible: boolean; canLoadMore: boolean } }).__notice;
}
const mixin = loadTerminalMixin();
const refetch = loadRefetch();
const setHistoryTruncation = loadSetHistoryTruncation();
const computeNotice = loadNotice();
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\r\n');
/** A `?full=1&tail=` answer whose window was CUT at the tail size: tmux holds ~3 MiB, the window carries 1 MiB. */
const TAIL_CUT = {
truncated: true,
truncationReason: 'tail',
fullSize: 3 * 1024 * 1024,
retainedBytes: TERMINAL_TAIL_SIZE,
source: 'mux-full-history',
};
function makeApp(
mode: string,
{
bufferRows,
capture,
payload = {},
scrollback = 0,
}: { bufferRows: number; capture: string; payload?: Record<string, unknown>; scrollback?: number }
) {
const urls: string[] = [];
const app = {
activeSessionId: 's1',
sessions: new Map([['s1', { mode }]]),
detachedSessions: new Set<string>(),
_fullHistoryRepullInFlight: false,
_isLoadingBuffer: false,
_fullHistoryRepullAt: new Map<string, number>(),
_fullHistoryRepullUseless: new Set<string>(),
terminalBufferCache: new Map<string, string>(),
terminal: {
cols: 80,
rows: 30,
// xterm's scrollback option; 0 leaves the browser-cap check out of a test.
options: { scrollback },
buffer: { active: { length: bufferRows } },
scrollToLine: vi.fn(),
scrollToTop: vi.fn(),
},
_estimateReplayRows: mixin._estimateReplayRows,
_replayWouldShrinkBuffer: mixin._replayWouldShrinkBuffer,
_fetchTerminalCapture: vi.fn(async (url: string) => {
urls.push(url);
return {
headersAt: performance.now(),
headers: { get: () => '' },
json: { data: { terminalBuffer: capture, source: 'mux-full-history', ...payload } },
};
}),
_recordTerminalLoadTiming: vi.fn(),
_logScrollRouting: vi.fn(),
// The real method behind a spy, so a test sees both what it was called with
// and the banner state (`_historyTruncation`) it leaves behind.
_historyTruncation: new Map<string, unknown>(),
_renderHistoryTruncationBanner: vi.fn(),
_setHistoryTruncation: vi.fn((sessionId: string, p?: Record<string, unknown>): void => {
setHistoryTruncation.call(app, sessionId, p);
}),
_resetTerminalForReplay: vi.fn(),
_bufferLoadFinishOpts: vi.fn(() => ({})),
chunkedTerminalWrite: vi.fn(async () => ({ parsedAt: performance.now(), bufferLength: 400, completed: true })),
_syncStickyScrollBaseline: vi.fn(),
};
return { app, urls };
}
describe('shell scroll-up pulls a bounded window of tmux history', () => {
it('a shell scroll gesture requests full history bounded by the tail size', async () => {
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
await refetch.call(app);
expect(urls).toEqual([`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`]);
// It then actually replays the recovered history.
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
});
it('the Load full history button stays unbounded for a shell', async () => {
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
await refetch.call(app, { force: true });
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
});
it('other modes keep the unbounded scroll pull', async () => {
const { app, urls } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
await refetch.call(app);
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
});
it('a bounded window the browser already holds is not rewritten', async () => {
// Browser already has every row the window carries: resetting to rewrite
// it would jump the viewport on every scroll that outlasts the cooldown.
const { app } = makeApp('shell', { bufferRows: 320, capture: lines(300) });
await refetch.call(app);
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
// Not latched as useless: more output can put more history in tmux.
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
// Nothing was written, so the banner state is left exactly as it was.
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
// …but the skip is visible to someone diagnosing "scroll-to-top does nothing".
expect(app._logScrollRouting).toHaveBeenCalledWith('repull-skipped-bounded');
});
it("a browser at xterm's scrollback cap stops replaying a window it can never hold, and backs off", async () => {
// xterm keeps at most `scrollback + rows` rows while tmux keeps 100k lines, so
// a 1 MiB window of short lines can carry more rows than the browser ever will.
// `windowRows <= rows held` then never comes true, and every scroll-to-top
// past the cooldown reset and re-parsed the window. Untruncated on purpose:
// the back-off has to come from the full browser, not from `truncated`.
const { app } = makeApp('shell', { bufferRows: 40, capture: lines(2000), scrollback: 1000 });
// The first pull has room to grow, so it replays.
await refetch.call(app);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
// xterm kept only the last `scrollback + rows` of the 2000 rows written.
app.terminal.buffer.active.length = 1000 + 30;
// Past the 4 s cooldown: the browser is full, so nothing is replayed and the
// session backs off for a minute.
app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
await refetch.call(app);
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
expect(app._fullHistoryRepullUseless.has('s1')).toBe(true);
// So a scroll 10 s later does not even ask the server for another capture.
app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
await refetch.call(app);
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
});
it('a replayed window cut from a byte-capped capture still offers Load full history', async () => {
// The route keeps `truncationReason: 'capped'` through the tail cut when the
// full capture exceeded the byte cap. On a bounded window that is not "gone for
// good": the unbounded pull behind the button returns up to the cap itself.
const capped = { ...TAIL_CUT, truncationReason: 'capped', fullSize: 40 * 1024 * 1024 };
const { app } = makeApp('shell', { bufferRows: 40, capture: lines(300), payload: capped });
await refetch.call(app);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ truncationReason: 'tail' }));
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
// The button's own unbounded pull is the one place 'capped' is the truth.
const button = makeApp('shell', { bufferRows: 40, capture: lines(300), payload: capped });
await refetch.call(button.app, { force: true });
expect(computeNotice(button.app._historyTruncation.get('s1') as Record<string, unknown>).canLoadMore).toBe(false);
});
});
describe('a skipped bounded window never damages the Load full history banner', () => {
it('a tail-cut window smaller than the browser is not replayed and never marks the session exhausted', async () => {
// The browser holds far more rows than a 1 MiB window carries, and tmux holds
// ~3 MiB. The downgrade guard reads that as "tmux has nothing more to give",
// which is true of an unbounded capture and false of a window cut at the tail.
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
// The tab load that put this session on screen left it truncated and recoverable.
app._setHistoryTruncation('s1', TAIL_CUT);
app._setHistoryTruncation.mockClear();
await refetch.call(app);
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
// Not even a relabel: a skipped window writes nothing, banner state included.
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
expect(notice.visible).toBe(true);
// "Earlier output is no longer kept" would be a lie: tmux still holds ~2 MiB more.
expect(notice.canLoadMore).toBe(true);
});
it('a skip right after Load full history leaves the banner as that load set it', async () => {
// Load full history replayed everything, so nothing is truncated any more.
const afterLoadFullHistory = {
truncated: false,
fullSize: 3 * 1024 * 1024,
retainedBytes: 3 * 1024 * 1024,
source: 'mux-full-history',
};
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
app._setHistoryTruncation('s1', afterLoadFullHistory);
const before = structuredClone(app._historyTruncation.get('s1'));
app._setHistoryTruncation.mockClear();
await refetch.call(app);
// Relabelling it from the bounded payload would call a terminal that holds ALL
// of the history "the most recent 1.0 MB".
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
expect(app._historyTruncation.get('s1')).toEqual(before);
expect(computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>).visible).toBe(false);
});
it('backs off for a minute after a truncated skip, and keeps the 4 s cooldown after an untruncated one', async () => {
// Truncated: the gesture cannot reach anything older than the browser shows, and
// every ask costs the server a synchronous capture of the whole history.
// Only just larger than the window, so the downgrade guard does not fire here:
// the back-off has to come from the skip itself.
const cut = makeApp('shell', { bufferRows: 320, capture: lines(300), payload: TAIL_CUT });
await refetch.call(cut.app);
expect(cut.app._fullHistoryRepullUseless.has('s1')).toBe(true);
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
// Well past 4 s, still inside the minute: no second capture.
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
await refetch.call(cut.app);
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 61_000);
await refetch.call(cut.app);
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
// Untruncated: it IS all of tmux's history, and the next burst can add to it.
const whole = makeApp('shell', { bufferRows: 320, capture: lines(300) });
await refetch.call(whole.app);
expect(whole.app._fullHistoryRepullUseless.has('s1')).toBe(false);
whole.app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
await refetch.call(whole.app);
expect(whole.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
});
it('the downgrade guard still refuses an unbounded capture smaller than the browser, and still marks it exhausted', async () => {
// Reordering must not weaken the guard it moved above: a repaint-mode pane's
// capture really is one frame, and rewriting with it would destroy history.
const oneFrame = makeApp('claude', { bufferRows: 300, capture: lines(36) });
await refetch.call(oneFrame.app);
expect(oneFrame.app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(oneFrame.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
expect(oneFrame.app._fullHistoryRepullUseless.has('s1')).toBe(true);
// The button is unbounded too, so the same guard governs it for a shell.
const button = makeApp('shell', { bufferRows: 5000, capture: lines(300) });
await refetch.call(button.app, { force: true });
expect(button.app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(button.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
});
});
describe('_replayWouldShrinkBuffer takes rows the caller already estimated', () => {
const shrink = mixin._replayWouldShrinkBuffer as (this: unknown, capture: string, rows?: number) => boolean;
const make = () => ({
terminal: { cols: 80, rows: 30, buffer: { active: { length: 200 } } },
_estimateReplayRows: vi.fn(mixin._estimateReplayRows as (t: string, c: number) => number),
});
it('does not scan the capture again when handed the estimate', () => {
const ctx = make();
expect(shrink.call(ctx, lines(300), 300)).toBe(false);
expect(shrink.call(ctx, lines(300), 5)).toBe(true);
expect(ctx._estimateReplayRows).not.toHaveBeenCalled();
});
it('still estimates for itself when called the old way', () => {
const ctx = make();
expect(shrink.call(ctx, lines(300))).toBe(false);
expect(ctx._estimateReplayRows).toHaveBeenCalledTimes(1);
});
});