// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the // MV3 background bundle cannot contain src/shared/state.js // (https://git.eeqj.de/sneak/AutistMask/issues/324). // // Why this file exists: `make check` does not run `make build`. CI executes // the assertion (Dockerfile runs `make build`), but executing is not testing — // invert its condition, or make the table lookup always come back undefined, // and every check in this repo stays green while the singleton walks back into // the worker. Five defects, one destroyed wallet, and the whole argument for // the scoped loud-read guard rest on this assertion, so it is pinned here. // // The subject is build.js's exported helpers, driven against SYNTHETIC // metafiles in esbuild's shape. Nothing here shells out to a build or writes // dist/: the assertion's job is to read a metafile correctly, and a metafile is // data. That the real shapes reach it is the build's own business and is // measured in the PR that introduced it. // // Paths are absolute on the way in, because the helpers normalize whatever // esbuild gave them to repo-relative and this file should not depend on the // working directory jest was started from. const path = require("path"); const { importChain, newForbiddenRecord, recordBundledInputs, assertNoForbiddenInputs, assertForbiddenTableCovered, } = require("../build"); const ROOT = path.resolve(__dirname, ".."); const abs = (p) => path.join(ROOT, p); const ENTRY = "src/background/index.js"; const OUT = "dist/chrome/src/background/index.js"; const STATE = "src/shared/state.js"; const HOP = "src/shared/chainSwitchFields.js"; // The real table's shape: entry point -> modules its bundle may not contain. const TABLE = { [ENTRY]: [STATE] }; // A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of // every input that contributed to that output, and `inputs[file].imports` is // the edge list, which is what the chain walk follows. function metafile({ outputs = {}, imports = {} } = {}) { return { outputs: Object.fromEntries( Object.entries(outputs).map(([out, inputs]) => [ abs(out), { inputs: Object.fromEntries( inputs.map((input) => [ abs(input), { bytesInOutput: 1 }, ]), ), }, ]), ), inputs: Object.fromEntries( Object.entries(imports).map(([file, targets]) => [ abs(file), { imports: targets.map((target) => ({ path: abs(target) })) }, ]), ), }; } function check(mf, table = TABLE, record = newForbiddenRecord()) { recordBundledInputs(mf, record); assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table); return record; } describe("assertNoForbiddenInputs()", () => { test("a forbidden module in the bundle fails, naming the import chain", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY, HOP, STATE] }, imports: { [ENTRY]: [HOP], [HOP]: [STATE], }, }); expect(() => check(mf)).toThrow( `${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` + `${ENTRY} -> ${HOP} -> ${STATE}.`, ); }); test("a bundle without the forbidden module passes, and is recorded as checked", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] }, imports: { [ENTRY]: [HOP, "src/background/state.js"] }, }); const record = check(mf); expect([...record.entriesChecked]).toEqual([ENTRY]); expect(record.bundledInputs.has(STATE)).toBe(false); }); test("the failure still names the bundle when no import chain can be shown", () => { // esbuild resolves `import("../shared/" + variable)` as a glob: the // module is an input of the output, but no single edge leads to it. // The message must degrade to no chain rather than crash. const mf = metafile({ outputs: { [OUT]: [ENTRY, STATE] }, imports: { [ENTRY]: [] }, }); expect(() => check(mf)).toThrow( `${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`, ); }); }); describe("importChain()", () => { test("terminates on a cyclic input graph, and still finds the module", () => { const mf = metafile({ imports: { [ENTRY]: [HOP], [HOP]: ["src/shared/log.js"], // The cycle: log <-> hop, with the target one hop past it. "src/shared/log.js": [HOP, STATE], }, }); expect(importChain(mf, abs(ENTRY), STATE)).toEqual([ ENTRY, HOP, "src/shared/log.js", STATE, ]); }); test("terminates and returns null when a cycle cannot reach the module", () => { const mf = metafile({ imports: { [ENTRY]: [HOP], [HOP]: ["src/shared/log.js"], "src/shared/log.js": [HOP, ENTRY], }, }); expect(importChain(mf, abs(ENTRY), STATE)).toBeNull(); }); }); describe("assertForbiddenTableCovered()", () => { test("the shipped table is satisfied by a build that checked it", () => { const record = newForbiddenRecord(); record.entriesChecked.add(ENTRY); // The popup bundle is what legitimately contains the singleton. record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow(); }); test("a key no bundled entry point matched fails", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY] }, imports: { [ENTRY]: [] }, }); const stale = { "src/background/renamed.js": [STATE] }; const record = check(mf, stale); record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, stale)).toThrow( "src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" + " not bundled, so nothing checked it", ); }); test("a forbidden module this build bundled nowhere fails", () => { // The other half of the same rot: renaming or moving the singleton // leaves a table that names a path nothing resolves to any more, and // every bundle then passes it vacuously. const mf = metafile({ outputs: { [OUT]: [ENTRY] }, imports: { [ENTRY]: [] }, }); const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] }; const record = check(mf, stale); record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, stale)).toThrow( "src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" + ` ${ENTRY}, but this build bundled it nowhere`, ); }); });