Files
Codeman/test/sw-precache-manifest.test.ts
T
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

90 lines
4.0 KiB
TypeScript

// Port: none (static source contract — no browser, no server).
//
// The service worker's precache list used to be maintained by hand with the
// PRE-hash filenames, while scripts/build.mjs renamed those same files to
// content-hashed names and rewrote only index.html. So in production every
// precache entry pointed at a file that no longer existed, and
// `cache.add(url).catch(() => {})` in the install handler swallowed all of it.
// Measured against a running instance: 15 of 23 entries 404'd.
//
// Nothing caught it because nothing could: the two lists lived in different
// files, in different languages, with no shared symbol. The fix is to derive
// the list from the build's own manifest — and this test pins the contract that
// makes that derivation possible, because the failure mode is silent in both
// directions. A renamed anchor in sw.js means the build throws (loud, fine). A
// build that stops rewriting means the worker precaches nothing while still
// looking correct (silent, not fine).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(import.meta.dirname, '..');
const sw = readFileSync(resolve(root, 'src/web/public/sw.js'), 'utf8');
const build = readFileSync(resolve(root, 'scripts/build.mjs'), 'utf8');
// The exact declarations scripts/build.mjs rewrites. They must appear EXACTLY
// once: the build asserts the same thing and throws otherwise, so a second
// occurrence (in a comment, say) fails the build rather than shipping stale.
const BUILD_ID_ANCHOR = "const BUILD_ID = 'dev';";
const HASHED_ASSETS_ANCHOR = 'const HASHED_ASSETS = [];';
describe('service worker precache contract', () => {
it('sw.js carries exactly one of each anchor the build rewrites', () => {
expect(sw.split(BUILD_ID_ANCHOR).length - 1).toBe(1);
expect(sw.split(HASHED_ASSETS_ANCHOR).length - 1).toBe(1);
});
it('build.mjs rewrites those exact anchors', () => {
expect(build).toContain(BUILD_ID_ANCHOR);
expect(build).toContain(HASHED_ASSETS_ANCHOR);
});
// The cache key must vary per build, or `activate`'s cleanup — which deletes
// every cache whose key is not the current one — never deletes anything, and
// hashed assets from every past release accumulate until the origin hits its
// storage quota. That is what the old constant 'codeman-v1' did.
it('derives the cache name from the build id rather than a constant', () => {
expect(sw).toContain('const CACHE_NAME = `codeman-${BUILD_ID}`;');
expect(sw).not.toMatch(/const CACHE_NAME = ['"]codeman-v\d+['"]/);
});
// The whole point of the rewrite: the shell is derived, not hand-listed.
it('builds the app shell from the hashed manifest', () => {
expect(sw).toContain("...HASHED_ASSETS.map((p) => '/' + p)");
});
// The regression itself. These are the pre-hash names the build renames, so
// any of them appearing in the shell list means someone hand-added an entry
// that will 404 in production.
it('never hand-lists a filename the build content-hashes', () => {
const shell = sw.slice(sw.indexOf('const APP_SHELL'), sw.indexOf('].map(B);'));
const hashedByBuild = [
'app.js',
'constants.js',
'terminal-ui.js',
'session-ui.js',
'settings-ui.js',
'panels-ui.js',
'styles.css',
'mobile.css',
'i18n.js',
'mobile-handlers.js',
'keyboard-accessory.js',
'notification-manager.js',
'voice-input.js',
'api-client.js',
'vendor/xterm-zerolag-input.js',
'vendor/xterm-predictive-echo.js',
];
for (const name of hashedByBuild) {
expect(shell, `APP_SHELL must not hand-list ${name} — the build renames it`).not.toContain(`'/${name}'`);
}
});
// Dev serves sw.js unrewritten, so the literals must be valid on their own:
// an empty precache plus the unhashed modules cached on first use.
it('is valid unrewritten, for dev', () => {
expect(() => new Function(sw.replace(/self\./g, 'globalThis.'))).not.toThrow();
});
});