Files
Codeman/test/history-truncation-notice.test.ts
Rounak DattaandClaude Opus 5 c0422c4e21 feat(terminal): renderer watchdog, atomic replay clear, fetch deadlines, reconnect recovery
Four ways the terminal can silently stop being correct — in each case the
buffer keeps updating, nothing throws, and the only recourse is a reload.

1. Renderer freeze after backgrounding. iOS DISCARDS scheduled rAF callbacks
   when a PWA backgrounds, and xterm's RenderDebouncer only clears its
   `_animationFrame` handle from inside that callback — so one drop leaves it
   permanently set and every later refresh() early-returns. Parsing is
   decoupled from rendering, so bytes keep filling the buffer correctly while
   nothing paints. Codeman has exactly ONE xterm for the whole page load, so a
   single backgrounding wedges it until a reload. Adds a 2s liveness poll and
   `_kickRenderer()`, which does what the dropped `_innerRefresh` would have.

2. Replay clears raced live output. xterm's write() is async-queued while
   reset() is synchronous and, per upstream, "does not clear input buffers and
   does not reset the parser" — so bytes queued before a reset are parsed after
   it and fuse into the snapshot. Verified against the real xterm 6 here:
   write('p8'); reset(); write('rmissions') renders "p8rmissions". The main
   path was already safe via a queued erase; the needsRefresh and clearTerminal
   paths were not. All three now share one queued `\x1bc` (RIS), which unlike
   3J/H/2J also resets modes, charsets, scroll regions and SGR state.

3. Output lost on WebSocket reconnect. Input frames carry seq+cid and are
   delivered exactly once; output frames carry nothing. ws.onopen re-sends dims
   and flushes queued input, and needsRefresh only fires on external-CLI
   startup and SSE backpressure drain — never on reconnect. Output produced
   while offline was simply absent afterwards. Interim fix: reaching onclose
   means the drop was unintentional, so the session is marked and the next open
   reconciles from the server buffer. Sequencing output is the follow-up.

4. Terminal captures had no deadline. No AbortController anywhere in the
   frontend, including `?full=1`, which the code itself calls "unbounded-ish
   work: at the default history limit it can be megabytes". Adds a budget that
   scales with full-vs-tail and with captures in flight, degrading to a plain
   fetch where AbortController is missing.

Also: the service-worker precache was dead — the build content-hashes assets
but sw.js listed pre-hash names, so 15 of 23 entries 404'd (verified against a
running instance) and cache.add().catch() hid it. Offline still worked via
runtime caching, but CACHE_NAME was a constant so activate's cleanup never
deleted anything and every past release's assets accumulated. Both are now
derived from the build manifest. Crash-trail entries are flattened and capped,
since they are joined with \n into one value and one call site interpolates a
server-controlled WS close reason.

The watchdog reads xterm privates — there is no public API. Every access is
optional-chained so a shape change degrades to a no-op. `_renderService` only
exists after open(), which needs a real DOM, so the gate cannot assert the
field path; test/xterm-private-api.test.ts pins the dependency range instead.

Tests: 23 new (terminal-resilience, sw-precache-manifest, xterm-private-api),
all pure/static so they run in the gate, which excludes the mobile suite. One
static source guard in history-truncation-notice updated for the renamed call;
the behaviour it pins is unchanged.

Not verified: no browser available, so no runtime reproduction of the freeze
and no real-device test of the reconnect path. Both warrant a device pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:24:52 +05:30

149 lines
6.4 KiB
TypeScript

// Port: none (pure helpers from constants.js in a vm context).
//
// Issue #258: terminal history is split across browser scrollback, the server
// byte buffer and tmux, and the only signal the user got was a grey line written
// INTO the terminal saying "earlier output truncated for performance". That line
// scrolls away with the output it describes, cannot be acted on, and says the
// same thing whether the rest is one click away or gone forever.
//
// computeHistoryTruncationNotice() is the pure core of the replacement banner.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function loadHelpers() {
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;globalThis.__helpers = { formatHistoryBytes, computeHistoryTruncationNotice };`,
context,
{ filename: 'constants.js' }
);
return (context as any).__helpers as {
formatHistoryBytes: (n: number) => string;
computeHistoryTruncationNotice: (s: Record<string, unknown>) => {
visible: boolean;
message: string;
canLoadMore: boolean;
};
};
}
describe('formatHistoryBytes', () => {
const { formatHistoryBytes } = loadHelpers();
it('reports sub-KB amounts as a range, not a byte count', () => {
expect(formatHistoryBytes(400)).toBe('less than 1 KB');
expect(formatHistoryBytes(0)).toBe('less than 1 KB');
});
it('scales to KB and MB', () => {
expect(formatHistoryBytes(2048)).toBe('2 KB');
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('survives junk input rather than printing NaN into the UI', () => {
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
expect(formatHistoryBytes(undefined as unknown as number)).toBe('less than 1 KB');
});
});
describe('computeHistoryTruncationNotice (issue #258)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
it('stays hidden when the replay was complete', () => {
const notice = computeHistoryTruncationNotice({ truncated: false, fullSize: 100, retainedBytes: 100 });
expect(notice.visible).toBe(false);
expect(notice.canLoadMore).toBe(false);
});
it('offers to load more after an intentional tail replay', () => {
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'tail',
source: 'history',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
});
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('1.0 MB');
expect(notice.message).toContain('more may still be retained');
});
it('promises nothing more once the FULL capture itself hit the ceiling', () => {
// This is the case the old boolean could not express: a full-history pull
// that was still capped means tmux has already given everything it has.
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'capped',
source: 'mux-full-history',
fullSize: 40 * 1024 * 1024,
retainedBytes: 2 * 1024 * 1024,
});
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(false);
expect(notice.message).toContain('cannot be recovered');
});
it('reports exhaustion when a full pull was refused as a downgrade', () => {
// _replayWouldShrinkBuffer refused: the browser holds MORE than tmux can
// return (a repaint-mode pane keeps no history), so offering "load more"
// would be offering to destroy history.
const notice = computeHistoryTruncationNotice({
truncated: true,
reason: 'tail',
source: 'history',
fullSize: 900000,
retainedBytes: 500000,
exhausted: true,
});
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(false);
expect(notice.message).toContain('no longer kept');
});
it('lets exhaustion outrank a would-be recoverable state', () => {
const recoverable = { truncated: true, reason: 'tail', source: 'history', fullSize: 900, retainedBytes: 100 };
expect(computeHistoryTruncationNotice(recoverable).canLoadMore).toBe(true);
expect(computeHistoryTruncationNotice({ ...recoverable, exhausted: true }).canLoadMore).toBe(false);
});
});
describe('the in-terminal truncation line is gone (static guard)', () => {
it('no longer writes the notice into terminal output', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
// The whole point of #258 is that this notice is no longer part of the
// scrollback it describes.
expect(app).not.toContain('earlier output truncated for performance');
});
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
// Every terminal capture now goes through _fetchTerminalCapture, which adds
// an abort deadline (a `?full=1` body can be megabytes and used to hang
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
// decision this guard exists to pin are unchanged.
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
expect(app).toContain("if (session?.mode === 'shell')");
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
});
it('renders the banner through textContent, never innerHTML', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('_renderHistoryTruncationBanner() {');
expect(start).toBeGreaterThan(-1);
const body = app.slice(start, app.indexOf('\n _shouldFocusTerminalForTabSwitch', start));
expect(body).not.toContain('innerHTML');
});
});