// tests/build/no-new-function-in-sw-chunk.test.ts — Plan 04-02 Wave 0 RED unit test. // // MV3 CSP-hardening gate: the production SW chunk MUST contain ZERO occurrences // of `new Function`. The pre-existing offender (verified across Phase 1 history; // documented at .planning/phases/01-stabilize-video-pipeline/deferred-items.md) // is the `setimmediate` npm package's CSP-unsafe fallback for `setImmediate(string)`, // bundled transitively by `vite-plugin-node-polyfills`: // // b.setImmediate=function(I){typeof I!="function"&&(I=new Function(""+I));...} // // JSZip (the only legitimate consumer of setImmediate in the bundle) calls // `setImmediate(function)` only — never the string form — so the unsafe fallback // is dead in the static call graph but Rollup conservatively preserves it // (runtime type check, not static dead branch). Plan 04-02 Task 2 replaces the // transitive polyfill with an inline `queueMicrotask` polyfill and adds // `exclude: ['setimmediate']` to the nodePolyfills config — after which the // `new Function` literal disappears from the SW chunk entirely. // // Polarity at Wave 0 land: // - RED today (1 occurrence; the setimmediate polyfill literal in // dist/assets/index.ts-.js). Flips GREEN after Task 2 lands the // polyfill replacement. // // Scope: the file walk is narrowed to `dist/assets/` filtered by the SW chunk // regex `^index\.ts-.*\.js$`. The offscreen + welcome + loader chunks are out // of scope (they cannot run `setImmediate(string)` since they don't ship the // setimmediate polyfill anyway). // // Implementation mirrors tests/build/no-remote-fonts.test.ts: // - Build via npm run build (gated by SKIP_BUILD=1 escape hatch) // - Narrowed walk over dist/assets/index.ts-*.js // - For each file, count occurrences of forbidden substring `new Function` // - Assert zero matches // // References: // - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1 (setimmediate) // - .planning/phases/01-stabilize-video-pipeline/deferred-items.md (1-occurrence // baseline + closure-flip target) // - .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md (this test // scaffold; analog at tests/build/no-remote-fonts.test.ts) // - Plan 04-02 threat model T-04-02-01 (Elevation of Privilege via static // `new Function` red flag) 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); /** Forbidden substrings — any occurrence in the SW chunk violates MV3 CSP hardening (Plan 04-02). */ const FORBIDDEN_SW_CSP_PATTERNS: ReadonlyArray = [ 'new Function', ]; const PROD_BUILD_TIMEOUT_MS = 90_000; const DIST_DIR = resolvePath(process.cwd(), 'dist'); const DIST_ASSETS_DIR = resolvePath(DIST_DIR, 'assets'); // SW chunk filename pattern: `index.ts-.js`. The loader chunk // `index.ts-loader-.js` is also matched by `^index\.ts-.*\.js$` — that's // intentional; the loader chunk is part of the SW boot pipeline and must also // be CSP-safe. (Note: the loader chunk currently contains 0 hits, so this only // strengthens the regression net.) const SW_CHUNK_REGEX = /^index\.ts-.*\.js$/; interface ForbiddenMatch { readonly filePath: string; readonly count: number; } function listSwChunkFiles(): ReadonlyArray { if (!existsSync(DIST_ASSETS_DIR)) return []; const entries = readdirSync(DIST_ASSETS_DIR); return entries .filter((name) => SW_CHUNK_REGEX.test(name)) .map((name) => resolvePath(DIST_ASSETS_DIR, name)) .sort(); } function countOccurrencesInFile(filePath: string, needle: string): number { 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; } function findMatchesInSwChunks(needle: string): ReadonlyArray { const files = listSwChunkFiles(); const accumulator: ForbiddenMatch[] = []; for (const filePath of files) { const count = countOccurrencesInFile(filePath, needle); if (count > 0) { accumulator.push({ filePath, count }); } } return accumulator; } async function runProductionBuild(): Promise { await execFileAsync('npm', ['run', 'build'], { timeout: PROD_BUILD_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NODE_NO_WARNINGS: '1' }, }); } describe('production SW chunk has no `new Function` literal (MV3 CSP hardening — Plan 04-02 / RESEARCH Q1)', () => { it( 'npm run build completes and dist/assets/ exists with at least one SW chunk', { timeout: PROD_BUILD_TIMEOUT_MS + 5_000 }, async () => { if (process.env.SKIP_BUILD !== '1') { await runProductionBuild(); } expect( existsSync(DIST_ASSETS_DIR), `dist/assets/ missing at ${DIST_ASSETS_DIR}. Either npm run build failed or ` + `SKIP_BUILD=1 was set without a pre-existing build.`, ).toBe(true); const swChunks = listSwChunkFiles(); expect( swChunks.length, `Expected at least one SW chunk matching /^index\\.ts-.*\\.js$/ in ${DIST_ASSETS_DIR}; ` + `found ${swChunks.length}. Glob pre-gate: the file walk for the CSP grep depends on ` + `the SW chunk filename pattern; if no files match, the grep silently no-ops and the ` + `test would pass falsely. Investigate the Vite bundling pipeline (rollupOptions.input + ` + `assetFileNames + crxjs plugin output).`, ).toBeGreaterThanOrEqual(1); }, ); for (const needle of FORBIDDEN_SW_CSP_PATTERNS) { it(`dist/assets/index.ts-*.js does not contain '${needle}' (MV3 CSP — Plan 04-02 T-04-02-01)`, () => { if (!existsSync(DIST_ASSETS_DIR)) { throw new Error( `dist/assets/ missing — run \`npm run build\` first (SKIP_BUILD=1 set but no prior build).`, ); } const matches = findMatchesInSwChunks(needle); expect( matches.length, matches.length === 0 ? 'unreachable' : `SW chunk(s) contain '${needle}' in ${matches.length} file(s) — violates MV3 CSP ` + `hardening (Plan 04-02 T-04-02-01). The pre-existing offender is the setimmediate ` + `npm package's \`new Function("" + I)\` fallback, bundled transitively by ` + `vite-plugin-node-polyfills. Fix: add \`exclude: ['setimmediate']\` to vite.config.ts ` + `nodePolyfills config + add an inline \`queueMicrotask\`-based polyfill prelude at ` + `the top of src/background/index.ts. See Plan 04-02 Task 2 + RESEARCH §Q1.\n` + `Offending files:\n` + matches .map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`) .join('\n'), ).toBe(0); }); } });