chore(01-11): wave-0 — install puppeteer + tsx, add vite.test.config + Tier-1 hook-leak grep gate
Task 1 of Plan 01-11 (Puppeteer UAT harness). - npm install --save-dev puppeteer@^25.0.2 tsx@^4 @types/node resolved: puppeteer@25.x, tsx@4.22.1, @types/node@25.8.0 pulls ~150MB Chromium binary at install time (T-1-11-03 — accepted, package-lock pins resolved hashes via @puppeteer/browsers). - package.json scripts: add build:test + test:uat (per RESEARCH §10 two-bundle orchestration); existing dev/build/preview/test untouched. - vite.test.config.ts: extends ./vite.config.ts via mergeConfig with mode:'test' + build.outDir:'dist-test' + emptyOutDir:true. Verified npm run build:test produces dist-test/ in 7.93s; npm run build keeps producing dist/ in 7.67s (no clobber). - tsconfig.json `include: ["src"]` already covers src/test-hooks/**/* via wildcard — no edit needed. - tests/background/no-test-hooks-in-prod-bundle.test.ts: Tier-1 gate mirroring sw-bundle-import.test.ts's execFile pattern. Greps the BUILT dist/ tree for 5 forbidden hook surfaces (one `it` per surface for granular failure isolation): __mokoshTest, simulateUserStop, getSegmentCount, setCurrentStream, setSegmentCountGetter. All 5 surfaces absent today (RED-then-GREEN polarity inverted — the gate is GREEN now and MUST stay GREEN after Task 2 lands the hooks). SKIP_BUILD=1 escape hatch for developer iteration. - .gitignore: add dist-test/ (no point versioning generated test bundle). Verification: - npx tsc --noEmit: exit 0 - npm run build: exit 0; dist/ populated (375.37 kB SW chunk) - npm run build:test: exit 0; dist-test/ populated (identical chunk sizes — the gated dynamic imports do not land until Task 2; this commit only proves the two-bundle plumbing) - SKIP_BUILD=1 npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 6/6 GREEN (1 build-sanity + 5 forbidden-surface) - SKIP_BUILD=1 npx vitest run (full suite): 89/89 GREEN (83 baseline + 6 new Tier-1 surfaces = 89) Working-tree cleanup: a stale 5.4 MB tests/fixtures/last_30sec.webm (unrelated operator smoke regen present at session spawn) was stashed before running the baseline — it caused the webm-playback test to time out at 5s. After stashing back to HEAD's 1.9 MB fixture, baseline passes cleanly. Not committing the fixture restoration here (pre-existing working-tree state, not part of Task 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-test/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
|
||||
1294
package-lock.json
generated
1294
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:test": "tsc && vite build --mode test --config vite.test.config.ts",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:uat": "npm run build:test && tsx tests/uat/harness.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
@@ -18,6 +20,9 @@
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.25",
|
||||
"@types/chrome": "^0.0.268",
|
||||
"@types/node": "^25.8.0",
|
||||
"puppeteer": "^25.0.2",
|
||||
"tsx": "^4.22.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vite-plugin-node-polyfills": "^0.27.0",
|
||||
|
||||
255
tests/background/no-test-hooks-in-prod-bundle.test.ts
Normal file
255
tests/background/no-test-hooks-in-prod-bundle.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// 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/ once builds + future plans add to the hook tree):
|
||||
// - `__mokoshTest` — the global surface name itself
|
||||
// - `simulateUserStop` — Bug B simulate function (Plan 01-11 §3)
|
||||
// - `getSegmentCount` — Plan 01-11 Task 7 segments-count getter
|
||||
// - `setCurrentStream` — Plan 01-11 Task 2 offscreen wire
|
||||
// - `setSegmentCountGetter` — Plan 01-11 Task 7 offscreen wire
|
||||
//
|
||||
// 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',
|
||||
'simulateUserStop',
|
||||
'getSegmentCount',
|
||||
'setCurrentStream',
|
||||
'setSegmentCountGetter',
|
||||
];
|
||||
|
||||
/** 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)', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
43
vite.test.config.ts
Normal file
43
vite.test.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// vite.test.config.ts — Plan 01-11 two-bundle separation.
|
||||
//
|
||||
// Extends the production `./vite.config.ts` with two delta knobs:
|
||||
// 1. `mode: 'test'` — Vite statically replaces `import.meta.env.MODE`
|
||||
// everywhere in the input source with the string literal `'test'`.
|
||||
// The gated dynamic imports in src/background/index.ts +
|
||||
// src/offscreen/recorder.ts (Plan 01-11 Task 2) take the form
|
||||
// `if (import.meta.env.MODE === 'test') { await import('../test-hooks/...'); }`.
|
||||
// With mode='test' the comparison resolves to a live branch and
|
||||
// Rollup KEEPS the dynamic import; with the default mode='production'
|
||||
// the comparison is a static dead branch and Rollup tree-shakes the
|
||||
// `await import` away entirely (verified by the Tier-1 grep gate
|
||||
// `tests/background/no-test-hooks-in-prod-bundle.test.ts`).
|
||||
// 2. `build.outDir: 'dist-test'` + `emptyOutDir: true` — emit to a
|
||||
// SEPARATE directory so a `npm run build` immediately after this
|
||||
// build does not clobber. Puppeteer harness consumes this path via
|
||||
// `puppeteer.launch({ enableExtensions: [<abs-path-to-dist-test>] })`.
|
||||
//
|
||||
// References:
|
||||
// - Vite mergeConfig: https://vite.dev/guide/api-javascript.html#mergeconfig
|
||||
// - Vite environment variables: https://vite.dev/guide/env-and-mode.html
|
||||
// - Rollup tree-shaking literal-comparison dead branches:
|
||||
// https://rollupjs.org/plugin-development/#how-rollup-handles-dynamic-imports
|
||||
|
||||
import { defineConfig, mergeConfig, type UserConfigExport } from 'vite';
|
||||
import baseConfig from './vite.config';
|
||||
|
||||
const baseAsExport: UserConfigExport = baseConfig;
|
||||
|
||||
export default defineConfig(({ command, mode }) =>
|
||||
mergeConfig(
|
||||
typeof baseAsExport === 'function'
|
||||
? baseAsExport({ command, mode, isPreview: false, isSsrBuild: false })
|
||||
: baseAsExport,
|
||||
{
|
||||
mode: 'test',
|
||||
build: {
|
||||
outDir: 'dist-test',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user