// The lint rule that keeps src/shared/state.js out of the background bundle // (script/lib/eslint/noStateSingletonInBackground.js). // // Five defects, one of which destroyed a wallet, came from background code // reaching that singleton, and each point fix created the next site // (https://git.eeqj.de/sneak/AutistMask/issues/324). The prohibition is // therefore mechanical rather than a review item — which means the rule's // coverage is itself load-bearing, and a hole in it is indistinguishable from // having no rule at all. // // The hole this file exists to pin shut is SPECIFIER SYNTAX. The rule walks the // require graph textually, and a first version matched only `require("x")` and // `require('x')`. Every shape below was measured against a real `make build`: // each one puts state.js in dist/chrome/src/background/index.js, and each one // was invisible to the narrower match. So each is a case here, and a regression // in the matcher fails the suite instead of shipping a sixth site. // // NOT covered, deliberately: a computed specifier such as // `require("../shared/" + "state")`. esbuild cannot resolve that statically // either, so it never reaches the bundle — there is nothing to block. const fs = require("fs"); const os = require("os"); const path = require("path"); const { Linter } = require("eslint"); const plugin = require("../script/lib/eslint/noStateSingletonInBackground"); const RULE = "background/no-state-singleton-in-background"; // The three files a fixture tree always has. `src/background/index.js` is // supplied per case; the other two stand in for the real modules. const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n"; const SHARED_HOP = "// A shared module the background legitimately imports.\n" + "module.exports = { applyChainSwitchFields() {} };\n"; let roots = []; function fixture(files) { const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")), ); roots.push(root); const tree = { "src/shared/state.js": SHARED_STATE, "src/shared/chainSwitchFields.js": SHARED_HOP, ...files, }; for (const [rel, source] of Object.entries(tree)) { const abs = path.join(root, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, source); } return root; } // Run the rule exactly as eslint.config.js runs it, over a real tree: the walk // reads its sources from disk, so a virtual RuleTester would not exercise it. // `sourceType` is the fixture's own, not the rule's business: the walk is // textual and never parses the files it follows. The two ESM cases below pass // "module" only so espree can parse the fixture at all — in this repo those // shapes are also a parse error under the commonjs config, but the rule must // not be left depending on that. function lintBackground(root, { sourceType = "commonjs" } = {}) { const file = path.join(root, "src/background/index.js"); const linter = new Linter({ cwd: root }); return linter.verify( fs.readFileSync(file, "utf8"), { plugins: { background: plugin }, languageOptions: { ecmaVersion: 2024, sourceType }, rules: { [RULE]: "error" }, }, file, ); } function chainOf(messages) { expect(messages).toHaveLength(1); expect(messages[0].ruleId).toBe(RULE); // "...singleton: . The MV3 worker..." — the chain is what the // message exists to hand the reader, so assert on it rather than on the // fact that something was reported. return messages[0].message.split("singleton: ")[1].split(". The MV3")[0]; } afterEach(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); roots = []; }); describe("every specifier syntax esbuild resolves is blocked", () => { test("a quoted require", () => { const root = fixture({ "src/background/index.js": 'const { state } = require("../shared/state");\n' + "module.exports = { state };\n", }); expect(chainOf(lintBackground(root))).toBe( "src/background/index.js -> src/shared/state.js", ); }); test("a backtick require", () => { const root = fixture({ "src/background/index.js": "const { state } = require(`../shared/state`);\n" + "module.exports = { state };\n", }); expect(chainOf(lintBackground(root))).toBe( "src/background/index.js -> src/shared/state.js", ); }); test("a dynamic import inside an async function", () => { const root = fixture({ "src/background/index.js": "async function readState() {\n" + ' const m = await import("../shared/state");\n' + " return m.state;\n" + "}\n" + "module.exports = { readState };\n", }); expect(chainOf(lintBackground(root))).toBe( "src/background/index.js -> src/shared/state.js", ); }); test("a static import from-clause", () => { const root = fixture({ "src/background/index.js": 'import { state } from "../shared/state";\n' + "export { state };\n", }); expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe( "src/background/index.js -> src/shared/state.js", ); }); test("a bare side-effect import", () => { const root = fixture({ "src/background/index.js": 'import "../shared/state";\n', }); expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe( "src/background/index.js -> src/shared/state.js", ); }); }); describe("reachability, not just the direct specifier", () => { // The shape a no-restricted-imports could never see: no background file // names state.js, and the singleton is in the bundle anyway. In a backtick // require, so this fails on the specifier widening as well as on the walk. test("a two-hop re-export through a shared module", () => { const root = fixture({ "src/background/index.js": 'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' + "module.exports = { applyChainSwitchFields };\n", "src/shared/chainSwitchFields.js": SHARED_HOP + "module.exports.state = require(`./state`).state;\n", }); expect(chainOf(lintBackground(root))).toBe( "src/background/index.js -> src/shared/chainSwitchFields.js" + " -> src/shared/state.js", ); }); }); describe("what the rule must not report", () => { test("a background file that reaches only its own state layer", () => { const root = fixture({ "src/background/index.js": 'const { getState } = require("./state");\n' + 'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' + "module.exports = { getState, applyChainSwitchFields };\n", "src/background/state.js": "async function getState() {}\nmodule.exports = { getState };\n", }); expect(lintBackground(root)).toEqual([]); }); // The tree as it actually stands. This is the assertion that would catch a // widened matcher that resolves something it should not: it runs the rule // over the real background entrypoint, from the real repo root. test("the repository's own background entrypoint", () => { const root = path.resolve(__dirname, ".."); expect(lintBackground(root)).toEqual([]); }); });