diff --git a/extension/bun.lock b/extension/bun.lock index b00e894..82e2767 100644 --- a/extension/bun.lock +++ b/extension/bun.lock @@ -35,6 +35,7 @@ "globals": "17.6.0", "jest": "^30", "jest-environment-jsdom": "^30", + "jest-webextension-mock": "^4.1.1", "js-yaml": "^4.1.0", "knip": "^6.15.0", "ts-jest": "^29", @@ -797,6 +798,8 @@ "jest-watcher": ["jest-watcher@30.4.1", "", { "dependencies": { "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", "jest-util": "30.4.1", "string-length": "^4.0.2" } }, "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw=="], + "jest-webextension-mock": ["jest-webextension-mock@4.1.1", "", {}, "sha512-smCGBtl+5MpjKnJhyjCeaL/ZtdUa0T41tbHsEdb1nY3UEVogbYvehVw2MuMSvysFD3wNv4iYSIEgAwnR3H0YIw=="], + "jest-worker": ["jest-worker@30.4.1", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], diff --git a/extension/jest.config.cjs b/extension/jest.config.cjs index 5e6593f..a4b9534 100644 --- a/extension/jest.config.cjs +++ b/extension/jest.config.cjs @@ -36,10 +36,20 @@ module.exports = { moduleNameMapper: { "^webext-storage$": "/src/__test-mocks__/webext-storage.ts", }, - // Polyfills/stubs for browser APIs jsdom omits (Element.checkVisibility) or - // returns degenerate values for (offsetWidth/offsetHeight). Centralized so - // tests don't redo the same Object.defineProperty dance. - setupFiles: ["/src/__test-mocks__/jsdom-extras.ts"], + // - jest-webextension-mock: installs globalThis.chrome + browser with + // jest.fn() stubs for MV2/MV3 APIs the extension uses (runtime, storage, + // tabs, action, browserAction). Tests reference `chrome.*` directly. + // - chrome-mv3-extras: adds chrome.scripting (MV3 content-script + // registration), which jest-webextension-mock 4.1 does not include. + // - jsdom-extras: polyfills/stubs for browser APIs jsdom omits + // (Element.checkVisibility) or returns degenerate values for + // (offsetWidth/offsetHeight). Centralized so tests don't redo the same + // Object.defineProperty dance. + setupFiles: [ + "jest-webextension-mock", + "/src/__test-mocks__/chrome-mv3-extras.ts", + "/src/__test-mocks__/jsdom-extras.ts", + ], clearMocks: true, collectCoverageFrom: [ "src/lib/**/*.ts", diff --git a/extension/package.json b/extension/package.json index 4585b3c..ab29ca4 100644 --- a/extension/package.json +++ b/extension/package.json @@ -73,6 +73,7 @@ "globals": "17.6.0", "jest": "^30", "jest-environment-jsdom": "^30", + "jest-webextension-mock": "^4.1.1", "js-yaml": "^4.1.0", "knip": "^6.15.0", "ts-jest": "^29", diff --git a/extension/src/__test-mocks__/chrome-mv3-extras.ts b/extension/src/__test-mocks__/chrome-mv3-extras.ts new file mode 100644 index 0000000..18175ec --- /dev/null +++ b/extension/src/__test-mocks__/chrome-mv3-extras.ts @@ -0,0 +1,26 @@ +/// +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// jest-webextension-mock 4.1 installs `globalThis.chrome` with most MV2/MV3 +// APIs (runtime, storage, tabs, action, browserAction, etc.) but does NOT +// include chrome.scripting (MV3 content-script registration). Patch the +// missing namespace here so tests can `chrome.scripting.registerContentScripts +// .mockImplementation(...)` against the global. Wired via `setupFiles` AFTER +// jest-webextension-mock so the global already exists. + +interface ChromeScriptingMock { + executeScript: jest.Mock; + registerContentScripts: jest.Mock; + unregisterContentScripts: jest.Mock; + getRegisteredContentScripts: jest.Mock; +} + +( + globalThis as unknown as { chrome: { scripting: ChromeScriptingMock } } +).chrome.scripting = { + executeScript: jest.fn(), + registerContentScripts: jest.fn(), + unregisterContentScripts: jest.fn(), + getRegisteredContentScripts: jest.fn(), +}; diff --git a/extension/src/lib/__tests__/rule-engine.test.ts b/extension/src/lib/__tests__/rule-engine.test.ts index aac1af3..1946fca 100644 --- a/extension/src/lib/__tests__/rule-engine.test.ts +++ b/extension/src/lib/__tests__/rule-engine.test.ts @@ -64,7 +64,6 @@ jest.mock("../availability", () => ({ // the real implementation walking the DOM for placeholder elements that // don't exist in these unit tests. jest.mock("../placeholder", () => { - // biome-ignore lint/suspicious/noExplicitAny: jest.requireActual returns any const actual = jest.requireActual>("../placeholder"); return { ...actual, diff --git a/extension/src/lib/__tests__/webdriver-probe-registration.test.ts b/extension/src/lib/__tests__/webdriver-probe-registration.test.ts index 771d82d..86f1ed1 100644 --- a/extension/src/lib/__tests__/webdriver-probe-registration.test.ts +++ b/extension/src/lib/__tests__/webdriver-probe-registration.test.ts @@ -7,9 +7,9 @@ // so the test covers the four corners plus the no-op short-circuits when // the desired state already matches the current one. // -// chrome.scripting and chrome.storage aren't available in jsdom; the -// `chrome` global is replaced with a hand-rolled stub for the duration -// of the test. +// chrome.* APIs come from jest-webextension-mock + chrome-mv3-extras +// (wired via `setupFiles` in jest.config.cjs). Each loadModule call +// replays a stateful registration store on top of those stubs. // See storage.test.ts for the rationale on these mocks — the storage // module pulls in the full rule catalog transitively. @@ -28,6 +28,16 @@ jest.mock("abort-utils", () => ({ }, })); +// chrome.scripting comes from chrome-mv3-extras.ts; @types/chrome types the +// methods as plain functions, so cast each one to a jest.Mock for the +// mock-control surface (mockImplementation, mockReset, .mock.calls). +const registerMock = chrome.scripting + .registerContentScripts as unknown as jest.Mock; +const unregisterMock = chrome.scripting + .unregisterContentScripts as unknown as jest.Mock; +const getRegisteredMock = chrome.scripting + .getRegisteredContentScripts as unknown as jest.Mock; + interface RegistrationModule { startWebdriverProbeRegistration: () => void; } @@ -41,14 +51,6 @@ interface RegisteredScript { allFrames: boolean; } -interface ChromeStub { - scripting: { - registerContentScripts: jest.Mock; - unregisterContentScripts: jest.Mock; - getRegisteredContentScripts: jest.Mock; - }; -} - function flushPromises(): Promise { return new Promise((resolve) => { setTimeout(resolve, 0); @@ -59,8 +61,7 @@ async function loadModule( overrides: Record, enforcementEnabled = true, initiallyRegistered = false, -): Promise<{ chromeStub: ChromeStub; module: RegistrationModule }> { - let chromeStub!: ChromeStub; +): Promise<{ module: RegistrationModule }> { let module!: RegistrationModule; await jest.isolateModulesAsync(async () => { @@ -79,40 +80,33 @@ async function loadModule( ] : []; - chromeStub = { - scripting: { - registerContentScripts: jest.fn( - (scripts: RegisteredScript[]): Promise => { - for (const script of scripts) { - registered.push(script); - } - return Promise.resolve(); - }, - ), - unregisterContentScripts: jest.fn( - (filter: { ids: string[] }): Promise => { - for (const id of filter.ids) { - const index = registered.findIndex((script) => script.id === id); - if (index !== -1) { - registered.splice(index, 1); - } - } - return Promise.resolve(); - }, - ), - getRegisteredContentScripts: jest.fn( - (filter?: { ids?: string[] }): Promise => { - if (!filter?.ids) { - return Promise.resolve([...registered]); - } - return Promise.resolve( - registered.filter((script) => filter.ids?.includes(script.id)), - ); - }, - ), + registerMock.mockImplementation( + (scripts: RegisteredScript[]): Promise => { + registered.push(...scripts); + return Promise.resolve(); + }, + ); + unregisterMock.mockImplementation( + (filter: { ids: string[] }): Promise => { + for (const id of filter.ids) { + const index = registered.findIndex((script) => script.id === id); + if (index !== -1) { + registered.splice(index, 1); + } + } + return Promise.resolve(); + }, + ); + getRegisteredMock.mockImplementation( + (filter?: { ids?: string[] }): Promise => { + if (!filter?.ids) { + return Promise.resolve([...registered]); + } + return Promise.resolve( + registered.filter((script) => filter.ids?.includes(script.id)), + ); }, - }; - (globalThis as unknown as { chrome: ChromeStub }).chrome = chromeStub; + ); const enforcement = await import("../enforcement"); await enforcement.enforcementStorage.set(enforcementEnabled); @@ -120,28 +114,30 @@ async function loadModule( module = await import("../webdriver-probe-registration"); }); - return { chromeStub, module }; + return { module }; } +beforeEach(() => { + registerMock.mockReset(); + unregisterMock.mockReset(); + getRegisteredMock.mockReset(); +}); + afterEach(() => { - delete (globalThis as unknown as { chrome?: unknown }).chrome; delete process.env.EXTENSION_DEFAULT_OVERRIDES; }); describe("startWebdriverProbeRegistration", () => { it("registers the main-world script on startup when the rule is enabled", async () => { - const { chromeStub, module } = await loadModule({ + const { module } = await loadModule({ "webdriver-probe-annotate": true, }); module.startWebdriverProbeRegistration(); await flushPromises(); - expect(chromeStub.scripting.registerContentScripts).toHaveBeenCalledTimes( - 1, - ); - const [scripts] = chromeStub.scripting.registerContentScripts.mock - .calls[0] as [RegisteredScript[]]; + expect(registerMock).toHaveBeenCalledTimes(1); + const [scripts] = registerMock.mock.calls[0] as [RegisteredScript[]]; expect(scripts[0]).toMatchObject({ id: "webdriver-probe-annotate-main-world", matches: [""], @@ -153,21 +149,19 @@ describe("startWebdriverProbeRegistration", () => { }); it("does not register when the rule is disabled", async () => { - const { chromeStub, module } = await loadModule({ + const { module } = await loadModule({ "webdriver-probe-annotate": false, }); module.startWebdriverProbeRegistration(); await flushPromises(); - expect(chromeStub.scripting.registerContentScripts).not.toHaveBeenCalled(); - expect( - chromeStub.scripting.unregisterContentScripts, - ).not.toHaveBeenCalled(); + expect(registerMock).not.toHaveBeenCalled(); + expect(unregisterMock).not.toHaveBeenCalled(); }); it("unregisters when an already-registered script becomes ineligible", async () => { - const { chromeStub, module } = await loadModule( + const { module } = await loadModule( { "webdriver-probe-annotate": false }, true, /* initiallyRegistered */ true, @@ -176,13 +170,13 @@ describe("startWebdriverProbeRegistration", () => { module.startWebdriverProbeRegistration(); await flushPromises(); - expect(chromeStub.scripting.unregisterContentScripts).toHaveBeenCalledWith({ + expect(unregisterMock).toHaveBeenCalledWith({ ids: ["webdriver-probe-annotate-main-world"], }); }); it("does not re-register if the desired state already matches", async () => { - const { chromeStub, module } = await loadModule( + const { module } = await loadModule( { "webdriver-probe-annotate": true }, true, /* initiallyRegistered */ true, @@ -191,14 +185,12 @@ describe("startWebdriverProbeRegistration", () => { module.startWebdriverProbeRegistration(); await flushPromises(); - expect(chromeStub.scripting.registerContentScripts).not.toHaveBeenCalled(); - expect( - chromeStub.scripting.unregisterContentScripts, - ).not.toHaveBeenCalled(); + expect(registerMock).not.toHaveBeenCalled(); + expect(unregisterMock).not.toHaveBeenCalled(); }); it("treats enforcement-off as if the rule were disabled", async () => { - const { chromeStub, module } = await loadModule( + const { module } = await loadModule( { "webdriver-probe-annotate": true }, /* enforcementEnabled */ false, ); @@ -206,6 +198,6 @@ describe("startWebdriverProbeRegistration", () => { module.startWebdriverProbeRegistration(); await flushPromises(); - expect(chromeStub.scripting.registerContentScripts).not.toHaveBeenCalled(); + expect(registerMock).not.toHaveBeenCalled(); }); }); diff --git a/extension/src/rules/__tests__/roach-motel-annotate.test.ts b/extension/src/rules/__tests__/roach-motel-annotate.test.ts index ef01c28..25fa38e 100644 --- a/extension/src/rules/__tests__/roach-motel-annotate.test.ts +++ b/extension/src/rules/__tests__/roach-motel-annotate.test.ts @@ -8,22 +8,21 @@ import { findWarning, roachMotelAnnotateRule } from "../roach-motel-annotate"; const LANDMARK_SELECTOR = 'section[data-abs-rule="roach-motel-annotate"]'; // Rule's `apply` sends a `rule-detection` runtime message after landing -// the landmark. `chrome.runtime.sendMessage` isn't present in jsdom, so -// stub it here. Tests that assert call shape read from this mock. -const sendMessageMock = jest.fn().mockResolvedValue(undefined); +// the landmark. jest-webextension-mock provides chrome.runtime.sendMessage +// as a jest.fn() on globalThis.chrome; cast to jest.Mock to expose the +// mock-control surface (assertion against typeof's overloaded signature +// resolves to `never`-arg, which blocks .mockResolvedValue). +const sendMessageMock = chrome.runtime.sendMessage as unknown as jest.Mock; beforeEach(() => { document.body.innerHTML = ""; - sendMessageMock.mockClear(); - (globalThis as unknown as { chrome: unknown }).chrome = { - runtime: { sendMessage: sendMessageMock }, - }; + sendMessageMock.mockReset(); + sendMessageMock.mockResolvedValue(undefined); }); afterEach(() => { roachMotelAnnotateRule.teardown(); hiddenTextStripRule.teardown(); - delete (globalThis as unknown as { chrome?: unknown }).chrome; }); describe("findWarning", () => { diff --git a/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts b/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts index 5b8d925..d177b1f 100644 --- a/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts +++ b/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts @@ -15,7 +15,9 @@ function dispatchProbe(): void { document.dispatchEvent(new CustomEvent(EVENT_NAME)); } -let sendMessageMock: jest.Mock; +// chrome.runtime.sendMessage is installed as jest.fn() on globalThis by +// jest-webextension-mock. Cast to jest.Mock for the control surface. +const sendMessageMock = chrome.runtime.sendMessage as unknown as jest.Mock; // The rule fires two distinct sendMessage flows: `inject-webdriver-probe` // on every apply (background-side executeScript fallback) and @@ -38,15 +40,12 @@ function detectionCalls(): unknown[] { beforeEach(() => { document.body.innerHTML = ""; - sendMessageMock = jest.fn().mockResolvedValue(undefined); - (globalThis as unknown as { chrome: unknown }).chrome = { - runtime: { sendMessage: sendMessageMock }, - }; + sendMessageMock.mockReset(); + sendMessageMock.mockResolvedValue(undefined); }); afterEach(() => { webdriverProbeAnnotateRule.teardown(); - delete (globalThis as unknown as { chrome?: unknown }).chrome; }); describe("webdriverProbeAnnotateRule.apply", () => {