Files
mokosh/tests/background/no-test-hooks-in-prod-bundle.test.ts
Mark 81d9935b65 feat(04-08): video-file MediaStream + sync-install/lazy-first-frame + explicit WAR — methodology reframe per debug session-2 + iter-2 BLOCKER fixes
Task 1 of Plan 04-08 (methodology reframe of ROADMAP SC #1):

- Bundle 1.9 MB VP9 WebM fixture at tests/uat/fixtures/synthetic-display-source.webm (copy of internal Plan 01-07 fixture; CC0-equivalent project-owned)
- Add globals.d.ts ambient `*.webm?url` module decl (mirrors Plan 01-10 `*.svg?url`)
- Add manifest.json web_accessible_resources entry for `assets/*.webm` (iter-2 BLOCKER 1 — pre-decided to avoid executor improvisation; inert in production where dist/ has zero *.webm)
- Rewrite installFakeDisplayMedia() at src/test-hooks/offscreen-hooks.ts:
  * Replace canvas.captureStream(30) with HTMLVideoElement.captureStream(30) — bypasses Chrome bug 653548 invisible-canvas throttling (debug session-2 root cause)
  * Function signature remains SYNCHRONOUS (`: void`; iter-2 BLOCKER 2 — eager-install contract preserved at lines 528-537)
  * Video element creation + DOM append + monkey-patch assignment execute synchronously
  * canplay wait + .play() deferred INTO fakeGetDisplayMedia closure (lazy first-frame pattern)
  * fakeVideoReadyPromise kicked off at install time so first call observes resolved Promise
  * WARNING 1 (autoplay reject): explicit error class identifier 'autoplay-blocked or codec-unsupported in headless context'
  * displaySurface monkey-patch preserved verbatim
  * A23 lastGetDisplayMediaConstraints capture preserved
  * uninstallFakeDisplayMedia teardown adapted for videoEl (pauses + removes + nulls)
  * All 6 bridge ops UNCHANGED in their sync return-false form
- Add Tier-2 production-bundle filename-leak gate at tests/background/no-test-hooks-in-prod-bundle.test.ts (iter-2 WARNING 5 — synthetic-display-source string must be 0 hits in dist/)

Verification:
- npx tsc --noEmit: exit 0
- npm run build: dist/ produced; 0 *.webm files; 0 synthetic-display-source hits
- npm run build:test: dist-test/assets/synthetic-display-source-mbtR1t3u.webm emitted (1.9 MB; Vite ?url asset)
- Code-only grep (comment-filtered) on offscreen-hooks.ts: 0 canvas refs; 15 video refs
- installFakeDisplayMedia signature unchanged: `: void` 2x; `: Promise` 0x; `await installFakeDisplayMedia` 0x
- Architectural invariant unchanged: `let segments: Blob[] = []` at recorder.ts:91 (1 hit; grep gate enforces)
- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 entries
- Tier-2 vitest gate PASSES: 14/14 GREEN under SKIP_BUILD=1 (12 Tier-1 + 1 build verify + 1 Tier-2)

Per iter-3 checker advisory 1: the wrong-display-surface throw lives at recorder.ts:313-321 (not line 294 as plan text states; off by ~25 lines but unambiguous).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:33:04 +02:00

353 lines
16 KiB
TypeScript

// tests/background/no-test-hooks-in-prod-bundle.test.ts
//
// Tier-1 hook-leak gate (Plan 01-11 Task 1) — sibling of
// `sw-bundle-import.test.ts`. Both gates inspect the BUILT `dist/`
// artifact for an invariant the SOURCE alone cannot prove.
//
// What this gate enforces — the security-critical invariant T-1-11-01:
//
// Plan 01-11 introduces test-only "hook" surfaces under `src/test-hooks/`
// that expose internal SW + offscreen state (captured chrome.* handler
// refs, MediaStream getter, simulated user-stopped-sharing trigger) to
// the Puppeteer harness via a global named `__mokoshTest`. The hooks
// ship in the TEST bundle (`dist-test/`) and MUST NOT ship in the
// PRODUCTION bundle (`dist/`) — leaking them would expose Bug B's
// `simulateUserStop` path + the captured `onStartup` handler ref to
// any page that can `eval` against the extension's SW.
//
// The leak is prevented by a Vite mode gate: each hook import in
// src/background/index.ts + src/offscreen/recorder.ts is wrapped in
// `if (import.meta.env.MODE === 'test') { await import('../test-hooks/...'); }`.
// Vite statically replaces `import.meta.env.MODE` at build time
// (production mode → `'production'`); the `'production' === 'test'`
// comparison is a static dead branch and Rollup tree-shakes the
// `await import` away entirely. That tree-shake is what THIS GATE
// verifies — by greping the built artifact tree for the hook surface
// strings and asserting they are absent.
//
// Why a unit-level gate IN ADDITION TO the harness's assertion 0:
// The harness's assertion 0 runs only when the harness runs (`npm run
// test:uat`), which requires a Chrome download + ~90s wall clock. The
// unit gate runs as part of the regular `npm test` pass — every
// developer's pre-push hook + every CI vitest job catches the leak in
// <15s. Belt + suspenders per Plan 01-11 RESEARCH §6 + the orchestrator-
// loaded `feedback-pre-checkpoint-bundle-gates.md` memory: any future
// plan executor whose work surfaces a SW build MUST keep this gate
// GREEN before any operator-empirical checkpoint.
//
// Polarity note: the gate is GREEN today (no hooks land until Plan 01-11
// Task 2) AND must STAY GREEN after Task 2 lands them. The test is
// committed BEFORE the hooks ship so the invariant is asserted from day
// one — eliminating any window-of-vulnerability where the production
// bundle could carry leaked hooks unnoticed.
//
// Surface inventory enforced (each MUST be absent from any file under
// dist/). Plan 01-13 Wave 0 updated this list for the Approach-B
// architecture (extension-internal harness page + offscreen-side
// synthetic stream + chrome.runtime.sendMessage bridge), replacing the
// 01-11 Approach-A SW-side instrumentation surface. The 01-11 entries
// `simulateUserStop` (renamed to `dispatchEndedOnTrack` to match the
// W3C dispatchEvent semantics per RESEARCH §7 BLOCKER) is dropped.
//
// - `__mokoshTest` — the global surface name itself
// - `setCurrentStream` — Plan 01-11 Task 2 offscreen wire (retained)
// - `setSegmentCountGetter` — Plan 01-11 Task 7 offscreen wire (retained)
// - `installFakeDisplayMedia` — 01-13 synthetic getDisplayMedia install
// - `uninstallFakeDisplayMedia` — 01-13 synthetic getDisplayMedia teardown
// - `dispatchEndedOnTrack` — 01-13 Bug B simulate via dispatchEvent
// (replaces Approach-A `simulateUserStop`)
// - `getSegmentCount` — Plan 01-11 Task 7 segments-count getter (retained)
// - `__mokoshOffscreenQuery` — 01-13 page→offscreen bridge message type
// - `get-display-surface` — 01-13 Wave 3A bridge op string (A3 contract)
// - `get-segment-count` — 01-13 Wave 3D bridge op string (A11 contract)
// - `lastGetDisplayMediaConstraints` — 01-14 A23 offscreen-side cell name
// - `get-last-getDisplayMedia-constraints` — 01-14 A23 bridge op string
//
// Total: 12 surface strings. Each MUST be absent from EVERY file under
// `dist/` post-build. The list is mirrored by the harness's A0
// assertion (tests/uat/harness.test.ts in Wave 3A; Plan 01-14 lockstep
// extension) so the same invariant is enforced at unit-test time
// (fast, every CI run) AND at UAT-harness time (belt+suspenders per
// the orchestrator-loaded `feedback-pre-checkpoint-bundle-gates.md`
// memory).
//
// Implementation mirrors `sw-bundle-import.test.ts`'s execFile pattern:
// - Spawn `npm run build` via execFile so the build is reproducible
// and the gate runs against a known-clean artifact.
// - Skip the build if `process.env.SKIP_BUILD === '1'` — developer
// escape hatch when iterating on the gate itself.
// - Recursively walk `dist/` reading files synchronously (the tree is
// small; ~10 chunks; ~500 KB total).
// - For each forbidden string, count total occurrences and report the
// offending file paths on failure.
//
// References:
// - Vite mode + `import.meta.env.MODE`:
// https://vite.dev/guide/env-and-mode.html
// - Rollup tree-shaking + dead-branch elimination:
// https://rollupjs.org/configuration-options/#treeshake
// - Node `child_process.execFile`:
// https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
// - Node `fs.readdirSync` + `withFileTypes`:
// https://nodejs.org/api/fs.html#fsreaddirsyncpath-options
import { execFile } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';
const execFileAsync = promisify(execFile);
/**
* Surface strings the gate forbids in any file under `dist/`. Order is
* preserved in failure diagnostics so the report is stable across runs.
* Each entry's rationale lives in the file header above.
*/
const FORBIDDEN_HOOK_STRINGS: ReadonlyArray<string> = [
'__mokoshTest',
'setCurrentStream',
'setSegmentCountGetter',
'installFakeDisplayMedia',
'uninstallFakeDisplayMedia',
'dispatchEndedOnTrack',
'getSegmentCount',
'__mokoshOffscreenQuery',
'get-display-surface',
'get-segment-count',
// Plan 01-14 A23 surface — lockstep with UAT A0 inventory at
// tests/uat/harness.test.ts:85. Verifies the `__MOKOSH_UAT__` Vite
// define-token dead-branch tree-shake correctly elides the new
// `lastGetDisplayMediaConstraints` cell + the `get-last-getDisplayMedia-constraints`
// bridge op from production `dist/` (T-01-14-04 mitigation).
'lastGetDisplayMediaConstraints',
'get-last-getDisplayMedia-constraints',
];
/** How long the build child has to finish (`npm run build` is ~10s).
* Generous cap; if it blows past this something else is wrong. */
const BUILD_TIMEOUT_MS = 60_000;
/** Absolute path to the production output directory. */
const DIST_DIR = resolvePath(process.cwd(), 'dist');
/**
* One match in one file. Held in a flat array per forbidden string so
* the failure message can enumerate every (file, count) pair.
*/
interface ForbiddenMatch {
readonly filePath: string;
readonly count: number;
}
/**
* Recursively collect every regular file under `root`. Returns absolute
* paths. Skips symlinks defensively (none expected in the Vite output
* tree, but cheap to guard against).
*
* @param root - Absolute directory path to walk.
* @returns Sorted list of absolute file paths under `root`.
*/
function listAllFilesRecursive(root: string): ReadonlyArray<string> {
const accumulator: string[] = [];
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop()!;
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = resolvePath(dir, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
accumulator.push(fullPath);
}
}
}
return accumulator.sort();
}
/**
* Count occurrences of `needle` inside the given file's text content.
* Returns 0 when the file is binary-ish (no occurrences of a likely-text
* sentinel character class). Vite emits JS/CSS/HTML/JSON — all UTF-8 —
* plus copies of PNG icons. We skip files whose extensions clearly mark
* them as binary so readFileSync('utf8') does not return mojibake that
* could accidentally match `needle`.
*
* @param filePath - Absolute file path to scan.
* @param needle - Literal substring to count.
* @returns Total occurrences of `needle` in the file's text.
*/
function countOccurrencesInFile(filePath: string, needle: string): number {
const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.woff', '.woff2', '.ttf', '.otf']);
const dotIdx = filePath.lastIndexOf('.');
const ext = dotIdx >= 0 ? filePath.substring(dotIdx).toLowerCase() : '';
if (binaryExtensions.has(ext)) {
return 0;
}
const stat = statSync(filePath);
if (stat.size === 0) {
return 0;
}
const text = readFileSync(filePath, 'utf8');
let count = 0;
let from = 0;
for (;;) {
const idx = text.indexOf(needle, from);
if (idx < 0) {
break;
}
count += 1;
from = idx + needle.length;
}
return count;
}
/**
* Walk `dist/` and find every file containing `needle`. Returns an
* array of (file, count) pairs sorted by file path. Empty when the
* needle is absent — that is the GREEN-gate condition.
*
* @param needle - Literal substring to grep for.
* @returns List of matches; empty array on absence.
*/
function findMatchesInDist(needle: string): ReadonlyArray<ForbiddenMatch> {
const files = listAllFilesRecursive(DIST_DIR);
const matches: ForbiddenMatch[] = [];
for (const filePath of files) {
const count = countOccurrencesInFile(filePath, needle);
if (count > 0) {
matches.push({ filePath, count });
}
}
return matches;
}
/**
* Spawn `npm run build` in a child process; reject on non-zero exit OR
* on timeout. Inherits parent env (we want the same `NODE_OPTIONS` etc.
* the developer set), but suppresses Node experimental warnings to
* keep vitest's failure output readable.
*
* @returns void on success; throws with build stderr captured on failure.
*/
async function runProductionBuild(): Promise<void> {
await execFileAsync('npm', ['run', 'build'], {
timeout: BUILD_TIMEOUT_MS,
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, NODE_NO_WARNINGS: '1' },
});
}
describe('production bundle has no test-hook leaks (Tier-1 gate — T-1-11-01)', () => {
// Plan 01-10 closure: `npm run build` slowed from ~2.88s → ~5.28s after
// the welcome page assets landed (welcome HTML + SVG ?url import + 8 WOFF2
// fonts shipped in d48a715 / 49f087f). Vitest's default 5000ms it() ceiling
// now races the build child. The EXEC-level `BUILD_TIMEOUT_MS = 60_000`
// bound on the child process still applies; this it()-level 30000ms
// ceiling is the surrounding-block companion. 30s is generously above the
// observed 5.28s + npm overhead and well below the 60s exec bound.
it('npm run build completes and dist/ exists with at least one chunk', async () => {
if (process.env.SKIP_BUILD !== '1') {
await runProductionBuild();
}
expect(
existsSync(DIST_DIR),
`dist/ missing at ${DIST_DIR}. Either npm run build failed or SKIP_BUILD=1 was set ` +
`without a pre-existing build. The hook-leak gate cannot run without a built artifact.`,
).toBe(true);
const files = listAllFilesRecursive(DIST_DIR);
expect(
files.length,
`dist/ is empty after npm run build — the build produced no output, which is a different ` +
`regression class than a hook leak. Investigate before proceeding to the hook-leak assertion.`,
).toBeGreaterThan(0);
}, 30_000);
for (const needle of FORBIDDEN_HOOK_STRINGS) {
it(`production bundle does not contain '${needle}' (T-1-11-01 surface)`, () => {
// If the build did not run in the previous test (SKIP_BUILD=1) AND
// dist/ is missing, surface a clear diagnostic instead of letting
// the recursive walk throw an obscure ENOENT.
if (!existsSync(DIST_DIR)) {
throw new Error(
`dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 is set but no prior build artifact exists).`,
);
}
const matches = findMatchesInDist(needle);
expect(
matches.length,
matches.length === 0
? 'unreachable'
: `Production bundle contains '${needle}' in ${matches.length} file(s) — this would leak ` +
`the Plan 01-11 test-hook surface to production. The Vite MODE-gate on the dynamic ` +
`import has regressed (verify the literal-comparison branch in src/background/index.ts ` +
`or src/offscreen/recorder.ts is still on the static-replacement path). Offending files:\n` +
matches
.map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`)
.join('\n'),
).toBe(0);
});
}
// Plan 04-08 iter-2 WARNING 5 — Tier-2 production-bundle filename leak canary.
//
// The test-only WebM fixture filename ('synthetic-display-source')
// appears in the TEST bundle as the resolved Vite hash URL but MUST
// NOT appear in the PRODUCTION dist/ bundle. The offscreen-hooks
// module that imports it is tree-shaken in production per
// __MOKOSH_UAT__; this gate catches any future regression that
// accidentally inlines test-hooks into the production chunk.
//
// The Tier-1 FORBIDDEN_HOOK_STRINGS inventory above tests __mokoshTest-
// family symbols; this Tier-2 gate tests the orthogonal axis of
// test-only ASSET filenames. Total inventory:
// Tier-1 (symbols): 12 entries (unchanged from Plan 01-14)
// Tier-2 (asset filenames): 1 entry (Plan 04-08 — synthetic-display-source)
//
// Note: the import is `tests/uat/fixtures/synthetic-display-source.webm?url`
// and Vite emits the asset to `dist-test/assets/<hash>.webm` only when
// the test bundle (vite.test.config.ts) is built. The production bundle
// (vite.config.ts) tree-shakes the entire offscreen-hooks.ts module
// body because `__MOKOSH_UAT__ === false` makes the dynamic import a
// static dead branch in src/offscreen/recorder.ts:46-48. Result: the
// filename string `synthetic-display-source` MUST be absent from every
// file under dist/.
it('Tier-2: synthetic-display-source filename does not leak into production dist/', () => {
if (!existsSync(DIST_DIR)) {
throw new Error(
`dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 is set but no prior build artifact exists).`,
);
}
// Walk dist/ files via the existing recursive walker (which skips
// symlinks); the existing countOccurrencesInFile helper handles
// binary-extension skipping. Grep for the literal string
// 'synthetic-display-source'. Expected: 0 hits.
const distFiles = listAllFilesRecursive(DIST_DIR);
const offendingFiles: Array<{ filePath: string; count: number }> = [];
for (const filePath of distFiles) {
const count = countOccurrencesInFile(filePath, 'synthetic-display-source');
if (count > 0) {
offendingFiles.push({ filePath, count });
}
}
expect(
offendingFiles.length,
offendingFiles.length === 0
? 'unreachable'
: `Production bundle contains 'synthetic-display-source' filename in ${offendingFiles.length} file(s) — ` +
`this would leak the Plan 04-08 test-only WebM fixture filename to production. ` +
`The offscreen-hooks.ts module that imports the WebM via Vite ?url should be ` +
`tree-shaken in production per __MOKOSH_UAT__; if the filename appears, the ` +
`tree-shake has regressed. Offending files:\n` +
offendingFiles
.map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`)
.join('\n'),
).toBe(0);
});
});