diff --git a/tests/build/dead-code-grep.test.ts b/tests/build/dead-code-grep.test.ts new file mode 100644 index 0000000..49a8e8d --- /dev/null +++ b/tests/build/dead-code-grep.test.ts @@ -0,0 +1,176 @@ +// tests/build/dead-code-grep.test.ts — Plan 04-02 Wave 0 GREEN-on-arrival +// regression pin (ROADMAP SC #4). +// +// ROADMAP SC #4 ("Dead-code grep returns no live references for +// permissions.request + duplicate offscreen inline string") is verified +// against the current src/ source tree (NOT against dist/). Both targets were +// removed during Phase 1: +// +// - `permissions.request` was the broken runtime-permission flow under the +// pre-D-01 `chrome.tabCapture` architecture; removed in Plan 01-05 as part +// of the SW shrink (audit P0 #5 fix). Under D-01 (`getDisplayMedia` via +// offscreen + `desktopCapture` permission), no runtime permission check is +// meaningful — every user-gesture-initiated getDisplayMedia surfaces its +// own picker UI as the implicit grant. Reintroducing `permissions.request` +// would route start-of-recording back into the never-granted branch and +// silently fail. +// +// - The duplicate offscreen inline string was the build-pipeline-side +// manifestation of the same architecture: pre-01-06 vite.config.ts shipped +// a 174-line inline copy-offscreen plugin that emitted an HTML literal of +// the offscreen document for the `chrome.offscreen.createDocument` path. +// Plan 01-06 collapsed vite.config.ts to 21 lines + relocated the +// offscreen entry into `rollupOptions.input` under the crxjs plugin's +// canonical multi-entry pattern. Reintroducing the inline-string emit +// would defeat the build-pipeline collapse. +// +// Polarity at Wave 0 land: +// - GREEN-on-arrival today (both targets are absent — verified by +// `grep -rn 'permissions.request' src/` returning 0 hits + the vite.config.ts +// review at Plan 04-02 RED time). This test acts as a regression pin: a +// future commit reintroducing either offender breaks CI. +// +// Note on the offscreen-inline-string sub-test: the planner empirically +// determined that no single literal HTML string from the pre-01-06 inline +// plugin is uniquely pinnable post-collapse — the plugin emitted a templated +// HTML body that included only generic ... markup with no +// SUMMARY-distinguishable sentinel. The audit-side coverage for "duplicate +// offscreen inline string absence" is delegated to: +// - tests/build/no-remote-fonts.test.ts (Plan 01-12) — full dist/ walk for +// `googleapis` etc. would catch any inline-HTML reintroduction with remote +// URLs; AND +// - vite.config.ts review at Plan 04-02 time confirms the file is still 21 +// lines + 75 LOC including blanks (no inline plugin block re-added). +// This file documents the delegation in the SUMMARY rather than asserting a +// brittle sentinel that future minor formatting changes would invalidate. +// +// Implementation mirrors tests/build/no-remote-fonts.test.ts (recursive walk + +// substring count + describe-block scaffold), but scopes the walk to `src/` +// directly (no build step needed; this is a pure source-tree audit). +// +// References: +// - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §"Phase +// Requirements" mapping (ROADMAP SC #4 ↔ this test) +// - .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md (this test +// scaffold; analog at tests/build/no-remote-fonts.test.ts) +// - .planning/phases/01-stabilize-video-pipeline/01-05-SUMMARY.md (Plan +// 01-05 SW shrink, where permissions.request was removed) +// - .planning/phases/01-stabilize-video-pipeline/01-06-SUMMARY.md (Plan +// 01-06 build-pipeline collapse, where the inline-string plugin was +// deleted) +// - Plan 04-02 threat model T-04-02-03 (Information Disclosure via +// leftover deleted-permission literal masking architecture) + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +interface DeadCodeEntry { + readonly needle: string; + readonly searchPaths: ReadonlyArray; + readonly rationale: string; +} + +/** Forbidden dead-code substrings — any occurrence in src/ is a regression. */ +const FORBIDDEN_DEAD_CODE: ReadonlyArray = [ + { + needle: 'permissions.request', + searchPaths: ['src'], + rationale: + 'Removed in Phase 1 Plan 01-05 SW shrink (audit P0 #5 fix). Under D-01 ' + + '(getDisplayMedia via offscreen + desktopCapture manifest permission), no ' + + 'runtime permission check is meaningful. Reintroducing permissions.request ' + + 'would route start-of-recording back into the never-granted branch.', + }, +]; + +interface ForbiddenMatch { + readonly filePath: string; + readonly count: number; +} + +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); + // Filter-pipeline form: skip symlinks via short-circuit (no `continue` per + // CLAUDE.md global instructions; we read `if (...) {} else if (...) { stack.push }`). + if (entry.isSymbolicLink()) { + // Symlinks within src/ should not exist in this project; defensive skip. + } else if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + accumulator.push(fullPath); + } + } + } + return accumulator.sort(); +} + +function isTextLikeFile(filePath: string): boolean { + 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() : ''; + return !binaryExtensions.has(ext); +} + +function countOccurrencesInFile(filePath: string, needle: string): number { + if (!isTextLikeFile(filePath)) 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; +} + +function findMatchesInPaths(needle: string, searchPaths: ReadonlyArray): ReadonlyArray { + const accumulator: ForbiddenMatch[] = []; + const cwd = process.cwd(); + for (const searchPath of searchPaths) { + const fullPath = resolvePath(cwd, searchPath); + const stat = statSync(fullPath); + const files = stat.isDirectory() ? listAllFilesRecursive(fullPath) : [fullPath]; + for (const filePath of files) { + const count = countOccurrencesInFile(filePath, needle); + if (count > 0) { + accumulator.push({ filePath, count }); + } + } + } + return accumulator; +} + +describe('dead-code regression pin (ROADMAP SC #4 — Plan 04-02 T-04-02-03)', () => { + for (const entry of FORBIDDEN_DEAD_CODE) { + const pathsDescription = entry.searchPaths.join(' + '); + it(`${pathsDescription} does not contain '${entry.needle}' (${entry.rationale.split('.')[0]})`, () => { + const matches = findMatchesInPaths(entry.needle, entry.searchPaths); + expect( + matches.length, + matches.length === 0 + ? 'unreachable' + : `${pathsDescription} contains dead-code '${entry.needle}' in ${matches.length} file(s) ` + + `— REGRESSION. ${entry.rationale}\n` + + `Offending files:\n` + + matches + .map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`) + .join('\n'), + ).toBe(0); + }); + } +}); diff --git a/tests/build/no-new-function-in-sw-chunk.test.ts b/tests/build/no-new-function-in-sw-chunk.test.ts new file mode 100644 index 0000000..8e598bb --- /dev/null +++ b/tests/build/no-new-function-in-sw-chunk.test.ts @@ -0,0 +1,168 @@ +// 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); + }); + } +});