// 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 = [ '__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 { 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 { 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 { 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/.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); }); });