Milestone v1 (v2.0.0): Mokosh — Session Capture #1
176
tests/build/dead-code-grep.test.ts
Normal file
176
tests/build/dead-code-grep.test.ts
Normal file
@@ -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 <html><head>... 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<string>;
|
||||
readonly rationale: string;
|
||||
}
|
||||
|
||||
/** Forbidden dead-code substrings — any occurrence in src/ is a regression. */
|
||||
const FORBIDDEN_DEAD_CODE: ReadonlyArray<DeadCodeEntry> = [
|
||||
{
|
||||
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<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);
|
||||
// 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<string>): ReadonlyArray<ForbiddenMatch> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
168
tests/build/no-new-function-in-sw-chunk.test.ts
Normal file
168
tests/build/no-new-function-in-sw-chunk.test.ts
Normal file
@@ -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-<hash>.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<string> = [
|
||||
'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-<hash>.js`. The loader chunk
|
||||
// `index.ts-loader-<hash>.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<string> {
|
||||
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<ForbiddenMatch> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user