The build-completes Tier-1 gate at tests/background/no-test-hooks-in-prod-bundle.test.ts:247 was racing vitest's default 5000ms it() ceiling. Plan 01-10 closure shipped the welcome page (commitsd48a715welcome mark +49f087fwelcome HTML/CSS/JS + 8 WOFF2 fonts) which slowed standalone `npm run build` from ~2.88s to ~5.28s. The exec-level BUILD_TIMEOUT_MS = 60_000 child-process bound was correctly declared at line 240, but the surrounding it() block had no timeout option, so the 5s default fired first and the 60s exec bound was never reachable. Surgical fix: add `, 30_000` 3rd arg to the it() call. 30s is ~6× the observed build duration and well below the 60s exec ceiling, so both bounds remain meaningfully active. SKIP_BUILD=1 env-var escape hatch untouched. Acceptance gates: - `npm test` (FULL, no SKIP_BUILD=1): 150/150 GREEN, exit 0 - `npx tsc --noEmit`: exit 0 - `npm run build`: exit 0 - Tier-1 grep gate: PASS (all 12 FORBIDDEN_HOOK_STRINGS asserted against dist/) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
297 lines
13 KiB
TypeScript
297 lines
13 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);
|
|
});
|
|
}
|
|
});
|