From 32383c3d34212f2a5e13101444875d8ef3f7892d Mon Sep 17 00:00:00 2001
From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com>
Date: Wed, 2 Sep 2026 18:56:22 +0300
Subject: [PATCH 01/11] feat(testing): add framework-agnostic browser harness
---
package.json | 7 +-
scripts/verify-build.mjs | 137 +++
src/testing/browser-state.ts | 134 +++
src/testing/configurable.test.ts | 104 +++
src/testing/configurable.ts | 710 ++++++++++++++++
src/testing/coverage.test.ts | 209 +++++
src/testing/coverage.ts | 922 +++++++++++++++++++++
src/testing/event.test.ts | 134 +++
src/testing/event.ts | 113 +++
src/testing/fixtures.test.ts | 112 +++
src/testing/fixtures.ts | 134 +++
src/testing/globals.integration.test.ts | 276 ++++++
src/testing/globals.ts | 238 ++++++
src/testing/harness.test.ts | 69 ++
src/testing/harness.ts | 350 ++++++++
src/testing/index.ts | 78 ++
src/testing/internal.ts | 88 ++
src/testing/listener-errors.ts | 54 ++
src/testing/method.test.ts | 257 ++++++
src/testing/method.ts | 281 +++++++
src/testing/permissions.ts | 180 ++++
src/testing/production.integration.test.ts | 75 ++
src/testing/runtime.messaging.test.ts | 201 +++++
src/testing/runtime.ts | 522 ++++++++++++
src/testing/scripting.ts | 180 ++++
src/testing/stateful.integration.test.ts | 281 +++++++
src/testing/tabs.ts | 573 +++++++++++++
src/testing/types.ts | 182 ++++
src/testing/windows.ts | 328 ++++++++
tsup.config.ts | 27 +-
30 files changed, 6954 insertions(+), 2 deletions(-)
create mode 100644 scripts/verify-build.mjs
create mode 100644 src/testing/browser-state.ts
create mode 100644 src/testing/configurable.test.ts
create mode 100644 src/testing/configurable.ts
create mode 100644 src/testing/coverage.test.ts
create mode 100644 src/testing/coverage.ts
create mode 100644 src/testing/event.test.ts
create mode 100644 src/testing/event.ts
create mode 100644 src/testing/fixtures.test.ts
create mode 100644 src/testing/fixtures.ts
create mode 100644 src/testing/globals.integration.test.ts
create mode 100644 src/testing/globals.ts
create mode 100644 src/testing/harness.test.ts
create mode 100644 src/testing/harness.ts
create mode 100644 src/testing/index.ts
create mode 100644 src/testing/internal.ts
create mode 100644 src/testing/listener-errors.ts
create mode 100644 src/testing/method.test.ts
create mode 100644 src/testing/method.ts
create mode 100644 src/testing/permissions.ts
create mode 100644 src/testing/production.integration.test.ts
create mode 100644 src/testing/runtime.messaging.test.ts
create mode 100644 src/testing/runtime.ts
create mode 100644 src/testing/scripting.ts
create mode 100644 src/testing/stateful.integration.test.ts
create mode 100644 src/testing/tabs.ts
create mode 100644 src/testing/types.ts
create mode 100644 src/testing/windows.ts
diff --git a/package.json b/package.json
index e8c3b1b..879ab8c 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,11 @@
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
+ },
+ "./testing": {
+ "types": "./dist/testing/index.d.ts",
+ "import": "./dist/testing/index.js",
+ "require": "./dist/testing/index.cjs"
}
},
"files": [
@@ -53,7 +58,7 @@
},
"scripts": {
"prepare": "husky",
- "build": "tsup && node ./scripts/copy-api-types.mjs",
+ "build": "tsup && node ./scripts/copy-api-types.mjs && node ./scripts/verify-build.mjs",
"prepublishOnly": "npm run build",
"dev": "tsup --watch",
"lint": "biome check .",
diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs
new file mode 100644
index 0000000..c564168
--- /dev/null
+++ b/scripts/verify-build.mjs
@@ -0,0 +1,137 @@
+import assert from "node:assert/strict";
+import {existsSync, readdirSync, readFileSync} from "node:fs";
+import {createRequire} from "node:module";
+import {dirname, resolve} from "node:path";
+import {fileURLToPath} from "node:url";
+import ts from "typescript";
+
+const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const sourceEntry = resolve(projectRoot, "src/index.ts");
+const declarationEntry = resolve(projectRoot, "dist/index.d.ts");
+const testingSourceDirectory = resolve(projectRoot, "src/testing");
+
+const getModuleExports = (file, compilerOptions) => {
+ const program = ts.createProgram([file], compilerOptions);
+ const sourceFile = program.getSourceFile(file);
+
+ assert.ok(sourceFile, `Unable to load ${file}`);
+
+ const checker = program.getTypeChecker();
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
+
+ assert.ok(moduleSymbol, `Unable to resolve module symbol for ${file}`);
+
+ return checker.getExportsOfModule(moduleSymbol).map(symbol => {
+ const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
+
+ return {
+ hasValue: (resolved.flags & ts.SymbolFlags.Value) !== 0,
+ name: symbol.getName(),
+ };
+ });
+};
+
+const sortNames = values => values.map(value => value.name).sort();
+const sourceExports = getModuleExports(sourceEntry, {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Node10,
+ skipLibCheck: true,
+ target: ts.ScriptTarget.ESNext,
+ types: ["chrome"],
+});
+const declarationExports = getModuleExports(declarationEntry, {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Node10,
+ skipLibCheck: true,
+ target: ts.ScriptTarget.ESNext,
+ types: ["chrome"],
+});
+
+assert.equal(sourceExports.length, 331, "The source public-export baseline changed; update the coverage matrix first");
+assert.equal(
+ sourceExports.filter(value => value.hasValue).length,
+ 328,
+ "The source runtime-export baseline changed; update the coverage matrix first"
+);
+assert.deepEqual(sortNames(declarationExports), sortNames(sourceExports), "Source and declaration exports differ");
+
+const expectedTypeOnly = ["BrowserGuess", "LaunchWebAuthFlowDetails", "WindowEventFilter"];
+assert.deepEqual(
+ sourceExports
+ .filter(value => !value.hasValue)
+ .map(value => value.name)
+ .sort(),
+ expectedTypeOnly.slice().sort(),
+ "The type-only public-export allowlist changed"
+);
+
+const esm = await import(`${new URL("../dist/index.js", import.meta.url).href}?verify=${Date.now()}`);
+const require = createRequire(import.meta.url);
+const cjs = require(resolve(projectRoot, "dist/index.cjs"));
+const sourceValueNames = sourceExports
+ .filter(value => value.hasValue)
+ .map(value => value.name)
+ .sort();
+
+assert.deepEqual(Object.keys(esm).sort(), sourceValueNames, "ESM runtime exports differ from source value exports");
+assert.deepEqual(Object.keys(cjs).sort(), sourceValueNames, "CJS runtime exports differ from source value exports");
+
+const sourceIndex = readFileSync(sourceEntry, "utf8");
+const esmIndex = readFileSync(resolve(projectRoot, "dist/index.js"), "utf8");
+const cjsIndex = readFileSync(resolve(projectRoot, "dist/index.cjs"), "utf8");
+const esmMap = JSON.parse(readFileSync(resolve(projectRoot, "dist/index.js.map"), "utf8"));
+const cjsMap = JSON.parse(readFileSync(resolve(projectRoot, "dist/index.cjs.map"), "utf8"));
+
+assert.doesNotMatch(sourceIndex, /(?:^|\/)testing(?:\/|")/m, "The production source entrypoint imports testing code");
+assert.doesNotMatch(esmIndex, /createBrowserHarness/, "The production ESM bundle contains testing code");
+assert.doesNotMatch(cjsIndex, /createBrowserHarness/, "The production CJS bundle contains testing code");
+assert.equal(
+ esmMap.sources.some(source => source.includes("/testing/")),
+ false,
+ "The production ESM source map contains testing modules"
+);
+assert.equal(
+ cjsMap.sources.some(source => source.includes("/testing/")),
+ false,
+ "The production CJS source map contains testing modules"
+);
+
+for (const file of ["dist/testing/index.js", "dist/testing/index.cjs", "dist/testing/index.d.ts"]) {
+ readFileSync(resolve(projectRoot, file));
+}
+
+for (const file of ["dist/testing/index.js.map", "dist/testing/index.cjs.map"]) {
+ assert.equal(existsSync(resolve(projectRoot, file)), false, `${file} must not be published`);
+}
+
+const testingDeclarations = readFileSync(resolve(projectRoot, "dist/testing/index.d.ts"), "utf8");
+
+assert.match(testingDeclarations, /^\/\/\/ /);
+assert.match(testingDeclarations, /^\/\/\/ /m);
+assert.equal(
+ existsSync(resolve(projectRoot, "dist/testing/index.d.ts.map")),
+ false,
+ "Testing declaration maps are not published"
+);
+
+const listRuntimeSources = directory =>
+ readdirSync(directory, {withFileTypes: true}).flatMap(entry => {
+ const file = resolve(directory, entry.name);
+
+ if (entry.isDirectory()) return listRuntimeSources(file);
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) return [];
+
+ return [{file, source: readFileSync(file, "utf8")}];
+ });
+
+const testingRuntimeSources = listRuntimeSources(testingSourceDirectory);
+
+for (const {file, source} of testingRuntimeSources) {
+ assert.doesNotMatch(
+ source,
+ /(?:from\s+["'](?:@jest\/globals|jest|@rstest\/[^"']*|vitest|sinon)["']|\b(?:jest|rs|vi|expect)\s*\.)/,
+ `Testing runtime source ${file} depends on a test runner`
+ );
+}
+
+console.log("Verified 331 TypeScript exports, 328 ESM/CJS runtime exports, and isolated testing bundles.");
diff --git a/src/testing/browser-state.ts b/src/testing/browser-state.ts
new file mode 100644
index 0000000..0595bc7
--- /dev/null
+++ b/src/testing/browser-state.ts
@@ -0,0 +1,134 @@
+import {createWindowFixture} from "./fixtures";
+import {cloneRecord} from "./internal";
+
+export interface BrowserMemoryStateOptions {
+ tabs?: readonly chrome.tabs.Tab[];
+ windows?: readonly chrome.windows.Window[];
+}
+
+export interface BrowserMemoryState {
+ readonly tabs: Map;
+ readonly windows: Map;
+ readonly lastFocusedWindowId: number | undefined;
+ reset(): void;
+ nextTabId(): number;
+ nextWindowId(): number;
+ currentWindowId(): number | undefined;
+ setLastFocusedWindow(id: number | undefined): void;
+ cloneTab(tab: chrome.tabs.Tab): chrome.tabs.Tab;
+ cloneWindow(window: chrome.windows.Window, populate?: boolean): chrome.windows.Window;
+ reindexTabs(windowId: number): void;
+ ensureWindow(windowId?: number): chrome.windows.Window;
+}
+
+export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): BrowserMemoryState => {
+ const initialWindows = (options.windows ?? []).map(window => cloneRecord(window));
+ const nestedTabs = initialWindows.flatMap(window =>
+ typeof window.id === "number"
+ ? (window.tabs ?? []).map(tab => cloneRecord({...tab, windowId: window.id as number}))
+ : []
+ );
+ const initialTabs = [...nestedTabs, ...(options.tabs ?? [])].map(tab => cloneRecord(tab));
+ let tabs = new Map();
+ let windows = new Map();
+ let tabCounter = 0;
+ let windowCounter = 0;
+ let lastFocusedWindowId: number | undefined;
+
+ const reset = (): void => {
+ tabs = new Map();
+ windows = new Map();
+
+ for (const window of initialWindows) {
+ if (typeof window.id !== "number") continue;
+ const copy = cloneRecord(window);
+ delete copy.tabs;
+ windows.set(window.id, copy);
+ }
+ for (const tab of initialTabs) {
+ if (typeof tab.id !== "number") continue;
+ tabs.set(tab.id, cloneRecord(tab));
+ if (!windows.has(tab.windowId)) {
+ windows.set(tab.windowId, createWindowFixture({focused: false, id: tab.windowId, tabs: undefined}));
+ }
+ }
+
+ tabCounter = Math.max(0, ...tabs.keys());
+ windowCounter = Math.max(0, ...windows.keys());
+ lastFocusedWindowId = [...windows.values()].find(window => window.focused)?.id ?? [...windows.keys()][0];
+ };
+
+ const state: BrowserMemoryState = {
+ get tabs() {
+ return tabs;
+ },
+ get windows() {
+ return windows;
+ },
+ get lastFocusedWindowId() {
+ return lastFocusedWindowId;
+ },
+ cloneTab(tab) {
+ return cloneRecord(tab);
+ },
+ cloneWindow(window, populate = false) {
+ const copy = cloneRecord(window);
+ if (populate && typeof copy.id === "number") {
+ copy.tabs = [...tabs.values()]
+ .filter(tab => tab.windowId === copy.id)
+ .sort((left, right) => left.index - right.index)
+ .map(tab => cloneRecord(tab));
+ } else {
+ delete copy.tabs;
+ }
+ return copy;
+ },
+ currentWindowId() {
+ return (
+ [...windows.values()].find(window => window.focused)?.id ??
+ lastFocusedWindowId ??
+ [...windows.keys()][0]
+ );
+ },
+ ensureWindow(windowId) {
+ const requestedId = windowId ?? state.currentWindowId();
+ if (typeof requestedId === "number") {
+ const existing = windows.get(requestedId);
+ if (existing) return existing;
+ }
+
+ const id = typeof windowId === "number" ? windowId : state.nextWindowId();
+ const window = createWindowFixture({focused: windows.size === 0, id, tabs: undefined});
+ windows.set(id, window);
+ if (window.focused) lastFocusedWindowId = id;
+ return window;
+ },
+ nextTabId() {
+ do {
+ tabCounter += 1;
+ } while (tabs.has(tabCounter));
+ return tabCounter;
+ },
+ nextWindowId() {
+ do {
+ windowCounter += 1;
+ } while (windows.has(windowCounter));
+ return windowCounter;
+ },
+ reindexTabs(windowId) {
+ [...tabs.values()]
+ .filter(tab => tab.windowId === windowId)
+ .sort((left, right) => left.index - right.index)
+ .forEach((tab, index) => {
+ tab.index = index;
+ });
+ },
+ reset,
+ setLastFocusedWindow(id) {
+ lastFocusedWindowId = id;
+ },
+ };
+
+ reset();
+ return state;
+};
diff --git a/src/testing/configurable.test.ts b/src/testing/configurable.test.ts
new file mode 100644
index 0000000..0077527
--- /dev/null
+++ b/src/testing/configurable.test.ts
@@ -0,0 +1,104 @@
+import {createConfigurableNamespaces} from "./configurable";
+import {RAW_CAPABILITY_COVERAGE} from "./coverage";
+import {createLastErrorController} from "./internal";
+
+describe("configurable browser namespaces", () => {
+ test("materializes every configurable method and raw event from the capability matrix", () => {
+ const configurable = createConfigurableNamespaces({facade: "chrome"});
+
+ for (const entry of RAW_CAPABILITY_COVERAGE) {
+ if (entry.coverage === "configurable" && entry.kind === "method") {
+ expect(configurable.method(entry.path)).toBeDefined();
+ }
+ if (entry.kind === "event") {
+ expect(configurable.event(entry.path)).toBeDefined();
+ }
+ }
+ });
+
+ test("exposes typed callback controls and records callback calls", async () => {
+ const configurable = createConfigurableNamespaces({facade: "chrome"});
+ const item = {id: 7} as chrome.downloads.DownloadItem;
+
+ configurable.controls.downloads.search.setResult([item]);
+
+ const result = await new Promise(resolve => {
+ configurable.api.downloads.search({id: 7}, resolve);
+ });
+
+ expect(result).toEqual([item]);
+ expect(configurable.controls.downloads.search.calls).toMatchObject([
+ {
+ args: [{id: 7}],
+ callbackCalls: [[[item]]],
+ invocation: "callback",
+ },
+ ]);
+ expect(configurable.calls.map(call => call.api)).toEqual(["downloads.search"]);
+ });
+
+ test("uses dual Promise behavior for browser facades", async () => {
+ const configurable = createConfigurableNamespaces({facade: "browser"});
+ const alarm: chrome.alarms.Alarm = {
+ name: "deterministic-alarm",
+ persistAcrossSessions: false,
+ scheduledTime: 1,
+ };
+
+ configurable.controls.alarms.getAll.setResult([alarm]);
+
+ await expect(configurable.api.alarms.getAll()).resolves.toEqual([alarm]);
+ expect(configurable.controls.alarms.getAll.calls[0]).toMatchObject({
+ args: [],
+ callback: undefined,
+ invocation: "promise",
+ });
+ });
+
+ test("uses runtime.lastError only while a failing callback runs", () => {
+ const lastError = createLastErrorController();
+ const configurable = createConfigurableNamespaces({facade: "chrome", lastError});
+ let observed: chrome.runtime.LastError | undefined;
+
+ configurable.controls.alarms.getAll.failNext(new Error("alarms unavailable"));
+ configurable.api.alarms.getAll(() => {
+ observed = lastError.current;
+ });
+
+ expect(observed?.message).toBe("alarms unavailable");
+ expect(lastError.current).toBeUndefined();
+ });
+
+ test("supports filtered event registrations and manual emission", async () => {
+ const configurable = createConfigurableNamespaces({facade: "chrome"});
+ const listener = jest.fn();
+ const filter: chrome.webNavigation.WebNavigationEventFilter = {url: [{hostEquals: "example.test"}]};
+
+ configurable.api.webNavigation.onCommitted.addListener(listener, filter);
+ const details = {tabId: 3} as chrome.webNavigation.WebNavigationTransitionCallbackDetails;
+ await configurable.controls.webNavigation.onCommitted.emit(details);
+
+ expect(listener).toHaveBeenCalledWith(details);
+ expect(configurable.controls.webNavigation.onCommitted.registrations()[0]?.args).toEqual([filter]);
+ });
+
+ test("physically removes and restores method capabilities", () => {
+ const configurable = createConfigurableNamespaces({facade: "chrome"});
+
+ configurable.setCapability("offscreen.hasDocument", false);
+ expect(configurable.hasCapability("offscreen.hasDocument")).toBe(false);
+ expect("hasDocument" in configurable.api.offscreen).toBe(false);
+
+ configurable.reset();
+ expect(configurable.hasCapability("offscreen.hasDocument")).toBe(true);
+ expect(typeof configurable.api.offscreen.hasDocument).toBe("function");
+ });
+
+ test("does not silently answer an unconfigured method", () => {
+ const configurable = createConfigurableNamespaces({facade: "chrome"});
+
+ expect(() => configurable.api.extension.getViews()).toThrow(
+ 'Browser method "extension.getViews" was called without a configured result or implementation.'
+ );
+ });
+});
diff --git a/src/testing/configurable.ts b/src/testing/configurable.ts
new file mode 100644
index 0000000..196604e
--- /dev/null
+++ b/src/testing/configurable.ts
@@ -0,0 +1,710 @@
+import {RAW_CAPABILITY_COVERAGE, type RawCapabilityEntry} from "./coverage";
+import {type BrowserEventHarness, createBrowserEvent} from "./event";
+import {type BrowserMethod, type BrowserMethodLastErrorController, createBrowserMethod} from "./method";
+import type {BrowserHarnessCall} from "./types";
+
+type AnyFunction = (...args: never[]) => unknown;
+type BrowserEventLike = {
+ addListener: AnyFunction;
+ removeListener: AnyFunction;
+ hasListener: AnyFunction;
+};
+
+type MethodKeys = {
+ [TKey in keyof TApi]-?: TApi[TKey] extends AnyFunction ? TKey : never;
+}[keyof TApi];
+
+type EventKeys = {
+ [TKey in keyof TApi]-?: TApi[TKey] extends BrowserEventLike ? TKey : never;
+}[keyof TApi];
+
+type Last = TValues extends readonly [...infer _, infer TValue] ? TValue : never;
+type CallbackArguments = [Last>] extends [never]
+ ? never
+ : Last> extends (...args: infer TArgs) => unknown
+ ? TArgs
+ : never;
+
+type ResultFromCallback = TArgs extends readonly []
+ ? undefined
+ : TArgs extends readonly [infer TResult]
+ ? TResult
+ : TArgs extends readonly [(infer TResult)?]
+ ? TResult | undefined
+ : TArgs;
+
+export type BrowserMethodResult =
+ Awaited> extends void
+ ? [CallbackArguments] extends [never]
+ ? undefined
+ : ResultFromCallback>
+ : Awaited>;
+
+type MethodControls = {
+ readonly [TKey in MethodKeys]: BrowserMethod<
+ Extract unknown>,
+ BrowserMethodResult>
+ >;
+};
+
+type ListenerArguments = Parameters[0] extends (
+ ...args: infer TArgs
+) => unknown
+ ? TArgs
+ : readonly unknown[];
+
+type RegistrationArguments =
+ Parameters extends readonly [unknown, ...infer TArgs] ? TArgs : readonly unknown[];
+
+type EventControls = {
+ readonly [TKey in EventKeys]: BrowserEventHarness<
+ ListenerArguments>,
+ RegistrationArguments>
+ >;
+};
+
+export type BrowserNamespaceHarness = {
+ readonly api: TApi;
+ reset(): void;
+} & MethodControls &
+ EventControls;
+
+export type ActionConfigurableApi = Pick<
+ typeof chrome.action,
+ | "disable"
+ | "enable"
+ | "getBadgeBackgroundColor"
+ | "getBadgeText"
+ | "getBadgeTextColor"
+ | "getPopup"
+ | "getTitle"
+ | "getUserSettings"
+ | "isEnabled"
+ | "onClicked"
+ | "onUserSettingsChanged"
+ | "openPopup"
+ | "setBadgeBackgroundColor"
+ | "setBadgeText"
+ | "setBadgeTextColor"
+ | "setIcon"
+ | "setPopup"
+ | "setTitle"
+>;
+
+export type BrowserActionConfigurableApi = Pick<
+ typeof chrome.browserAction,
+ | "disable"
+ | "enable"
+ | "getBadgeBackgroundColor"
+ | "getBadgeText"
+ | "getPopup"
+ | "getTitle"
+ | "onClicked"
+ | "setBadgeBackgroundColor"
+ | "setBadgeText"
+ | "setIcon"
+ | "setPopup"
+ | "setTitle"
+>;
+
+export type AlarmsConfigurableApi = Pick<
+ typeof chrome.alarms,
+ "clear" | "clearAll" | "create" | "get" | "getAll" | "onAlarm"
+>;
+export type AudioConfigurableApi = Pick<
+ typeof chrome.audio,
+ | "getDevices"
+ | "getMute"
+ | "onDeviceListChanged"
+ | "onLevelChanged"
+ | "onMuteChanged"
+ | "setActiveDevices"
+ | "setMute"
+ | "setProperties"
+>;
+export type BrowsingDataConfigurableApi = Pick<
+ typeof chrome.browsingData,
+ | "remove"
+ | "removeAppcache"
+ | "removeCache"
+ | "removeCacheStorage"
+ | "removeCookies"
+ | "removeDownloads"
+ | "removeFileSystems"
+ | "removeFormData"
+ | "removeHistory"
+ | "removeIndexedDB"
+ | "removeLocalStorage"
+ | "removePasswords"
+ | "removeServiceWorkers"
+ | "removeWebSQL"
+ | "settings"
+>;
+export type CommandsConfigurableApi = Pick;
+export type ContextMenusConfigurableApi = Pick<
+ typeof chrome.contextMenus,
+ "create" | "onClicked" | "remove" | "removeAll" | "update"
+>;
+export type CookiesConfigurableApi = Pick<
+ typeof chrome.cookies,
+ "get" | "getAll" | "getAllCookieStores" | "getPartitionKey" | "onChanged" | "remove" | "set"
+>;
+export type DocumentScanConfigurableApi = Pick<
+ typeof chrome.documentScan,
+ | "cancelScan"
+ | "closeScanner"
+ | "getOptionGroups"
+ | "getScannerList"
+ | "openScanner"
+ | "readScanData"
+ | "scan"
+ | "setOptions"
+ | "startScan"
+>;
+export type DownloadsConfigurableApi = Pick<
+ typeof chrome.downloads,
+ | "acceptDanger"
+ | "cancel"
+ | "download"
+ | "erase"
+ | "getFileIcon"
+ | "onChanged"
+ | "onCreated"
+ | "onDeterminingFilename"
+ | "open"
+ | "pause"
+ | "removeFile"
+ | "resume"
+ | "search"
+ | "setUiOptions"
+ | "show"
+ | "showDefaultFolder"
+>;
+export type ExtensionConfigurableApi = Pick<
+ typeof chrome.extension,
+ "getBackgroundPage" | "getViews" | "isAllowedFileSchemeAccess" | "isAllowedIncognitoAccess" | "setUpdateUrlData"
+>;
+export type HistoryConfigurableApi = Pick<
+ typeof chrome.history,
+ "addUrl" | "deleteAll" | "deleteRange" | "deleteUrl" | "getVisits" | "onVisited" | "onVisitRemoved" | "search"
+>;
+export type I18nConfigurableApi = Pick<
+ typeof chrome.i18n,
+ "detectLanguage" | "getAcceptLanguages" | "getMessage" | "getUILanguage"
+>;
+export type IdentityConfigurableApi = Pick<
+ typeof chrome.identity,
+ | "clearAllCachedAuthTokens"
+ | "getAccounts"
+ | "getAuthToken"
+ | "getProfileUserInfo"
+ | "getRedirectURL"
+ | "launchWebAuthFlow"
+ | "onSignInChanged"
+ | "removeCachedAuthToken"
+>;
+export type IdleConfigurableApi = Pick<
+ typeof chrome.idle,
+ "getAutoLockDelay" | "onStateChanged" | "queryState" | "setDetectionInterval"
+>;
+export type ManagementConfigurableApi = Pick<
+ typeof chrome.management,
+ | "createAppShortcut"
+ | "generateAppForLink"
+ | "get"
+ | "getAll"
+ | "getPermissionWarningsById"
+ | "getPermissionWarningsByManifest"
+ | "getSelf"
+ | "launchApp"
+ | "onDisabled"
+ | "onEnabled"
+ | "onInstalled"
+ | "onUninstalled"
+ | "setEnabled"
+ | "setLaunchType"
+ | "uninstall"
+ | "uninstallSelf"
+>;
+export type NotificationsConfigurableApi = Pick<
+ typeof chrome.notifications,
+ | "clear"
+ | "create"
+ | "getAll"
+ | "getPermissionLevel"
+ | "onButtonClicked"
+ | "onClicked"
+ | "onClosed"
+ | "onPermissionLevelChanged"
+ | "update"
+>;
+export type OffscreenConfigurableApi = Pick<
+ typeof chrome.offscreen,
+ "closeDocument" | "createDocument" | "hasDocument"
+>;
+export type PermissionsConfigurableApi = Pick<
+ typeof chrome.permissions,
+ "addHostAccessRequest" | "onAdded" | "onRemoved" | "removeHostAccessRequest"
+>;
+export type RuntimeConfigurableApi = Pick<
+ typeof chrome.runtime,
+ | "connect"
+ | "connectNative"
+ | "getPackageDirectoryEntry"
+ | "getPlatformInfo"
+ | "onConnect"
+ | "onConnectExternal"
+ | "onInstalled"
+ | "onMessage"
+ | "onMessageExternal"
+ | "onRestartRequired"
+ | "onStartup"
+ | "onSuspend"
+ | "onSuspendCanceled"
+ | "onUpdateAvailable"
+ | "onUserScriptConnect"
+ | "onUserScriptMessage"
+ | "openOptionsPage"
+ | "reload"
+ | "requestUpdateCheck"
+ | "restart"
+ | "restartAfterDelay"
+ | "setUninstallURL"
+>;
+export type ScriptingConfigurableApi = Pick;
+export type SidePanelConfigurableApi = Pick<
+ typeof chrome.sidePanel,
+ "close" | "getOptions" | "getPanelBehavior" | "open" | "setOptions" | "setPanelBehavior"
+>;
+export type TabCaptureConfigurableApi = Pick<
+ typeof chrome.tabCapture,
+ "capture" | "getCapturedTabs" | "getMediaStreamId" | "onStatusChanged"
+>;
+export type TabsConfigurableApi = Pick<
+ typeof chrome.tabs,
+ | "captureVisibleTab"
+ | "connect"
+ | "detectLanguage"
+ | "discard"
+ | "duplicate"
+ | "executeScript"
+ | "getCurrent"
+ | "getZoom"
+ | "getZoomSettings"
+ | "goBack"
+ | "goForward"
+ | "group"
+ | "highlight"
+ | "insertCSS"
+ | "move"
+ | "onActivated"
+ | "onAttached"
+ | "onCreated"
+ | "onDetached"
+ | "onHighlighted"
+ | "onMoved"
+ | "onRemoved"
+ | "onReplaced"
+ | "onUpdated"
+ | "onZoomChange"
+ | "reload"
+ | "removeCSS"
+ | "setZoom"
+ | "setZoomSettings"
+ | "ungroup"
+>;
+export type UserScriptsConfigurableApi = Pick<
+ typeof chrome.userScripts,
+ | "configureWorld"
+ | "execute"
+ | "getScripts"
+ | "getWorldConfigurations"
+ | "register"
+ | "resetWorldConfiguration"
+ | "unregister"
+ | "update"
+>;
+export type WebNavigationConfigurableApi = Pick<
+ typeof chrome.webNavigation,
+ | "getAllFrames"
+ | "getFrame"
+ | "onBeforeNavigate"
+ | "onCommitted"
+ | "onCompleted"
+ | "onCreatedNavigationTarget"
+ | "onDOMContentLoaded"
+ | "onErrorOccurred"
+ | "onHistoryStateUpdated"
+ | "onReferenceFragmentUpdated"
+ | "onTabReplaced"
+>;
+export type WebRequestConfigurableApi = Pick<
+ typeof chrome.webRequest,
+ | "handlerBehaviorChanged"
+ | "onAuthRequired"
+ | "onBeforeRedirect"
+ | "onBeforeRequest"
+ | "onBeforeSendHeaders"
+ | "onCompleted"
+ | "onErrorOccurred"
+ | "onHeadersReceived"
+ | "onResponseStarted"
+ | "onSendHeaders"
+>;
+export type WindowsEventsConfigurableApi = Pick<
+ typeof chrome.windows,
+ "onBoundsChanged" | "onCreated" | "onFocusChanged" | "onRemoved"
+>;
+export type FirefoxSidebarActionConfigurableApi = Pick<
+ typeof browser.sidebarAction,
+ "close" | "getPanel" | "getTitle" | "isOpen" | "open" | "setIcon" | "setPanel" | "setTitle" | "toggle"
+>;
+export type OperaSidebarActionConfigurableApi = Pick<
+ typeof opr.sidebarAction,
+ | "getBadgeBackgroundColor"
+ | "getBadgeText"
+ | "getBadgeTextColor"
+ | "getPanel"
+ | "getTitle"
+ | "setBadgeBackgroundColor"
+ | "setBadgeText"
+ | "setBadgeTextColor"
+ | "setIcon"
+ | "setPanel"
+ | "setTitle"
+>;
+
+export interface ConfigurableBrowserApi {
+ action: ActionConfigurableApi;
+ alarms: AlarmsConfigurableApi;
+ audio: AudioConfigurableApi;
+ browserAction: BrowserActionConfigurableApi;
+ browsingData: BrowsingDataConfigurableApi;
+ commands: CommandsConfigurableApi;
+ contextMenus: ContextMenusConfigurableApi;
+ cookies: CookiesConfigurableApi;
+ documentScan: DocumentScanConfigurableApi;
+ downloads: DownloadsConfigurableApi;
+ extension: ExtensionConfigurableApi;
+ history: HistoryConfigurableApi;
+ i18n: I18nConfigurableApi;
+ identity: IdentityConfigurableApi;
+ idle: IdleConfigurableApi;
+ management: ManagementConfigurableApi;
+ notifications: NotificationsConfigurableApi;
+ offscreen: OffscreenConfigurableApi;
+ permissions: PermissionsConfigurableApi;
+ runtime: RuntimeConfigurableApi;
+ scripting: ScriptingConfigurableApi;
+ sidePanel: SidePanelConfigurableApi;
+ tabCapture: TabCaptureConfigurableApi;
+ tabs: TabsConfigurableApi;
+ userScripts: UserScriptsConfigurableApi;
+ webNavigation: WebNavigationConfigurableApi;
+ webRequest: WebRequestConfigurableApi;
+ windows: WindowsEventsConfigurableApi;
+}
+
+export interface ConfigurableBrowserControls {
+ readonly action: BrowserNamespaceHarness;
+ readonly alarms: BrowserNamespaceHarness;
+ readonly audio: BrowserNamespaceHarness;
+ readonly browserAction: BrowserNamespaceHarness;
+ readonly browsingData: BrowserNamespaceHarness;
+ readonly commands: BrowserNamespaceHarness;
+ readonly contextMenus: BrowserNamespaceHarness;
+ readonly cookies: BrowserNamespaceHarness;
+ readonly documentScan: BrowserNamespaceHarness;
+ readonly downloads: BrowserNamespaceHarness;
+ readonly extension: BrowserNamespaceHarness;
+ readonly history: BrowserNamespaceHarness;
+ readonly i18n: BrowserNamespaceHarness;
+ readonly identity: BrowserNamespaceHarness;
+ readonly idle: BrowserNamespaceHarness;
+ readonly management: BrowserNamespaceHarness;
+ readonly notifications: BrowserNamespaceHarness;
+ readonly offscreen: BrowserNamespaceHarness;
+ readonly permissions: BrowserNamespaceHarness;
+ readonly runtime: BrowserNamespaceHarness;
+ readonly scripting: BrowserNamespaceHarness;
+ readonly sidePanel: BrowserNamespaceHarness;
+ readonly tabCapture: BrowserNamespaceHarness;
+ readonly tabs: BrowserNamespaceHarness;
+ readonly userScripts: BrowserNamespaceHarness;
+ readonly webNavigation: BrowserNamespaceHarness;
+ readonly webRequest: BrowserNamespaceHarness;
+ readonly windows: BrowserNamespaceHarness;
+ readonly sidebarAction: BrowserNamespaceHarness;
+ readonly operaSidebarAction: BrowserNamespaceHarness;
+}
+
+export interface ConfigurableNamespacesOptions {
+ readonly facade: "chrome" | "browser";
+ readonly lastError?: BrowserMethodLastErrorController;
+ readonly nextSequence?: () => number;
+}
+
+export interface ConfigurableNamespaces {
+ readonly api: ConfigurableBrowserApi;
+ readonly controls: ConfigurableBrowserControls;
+ readonly sidebarActionApi: FirefoxSidebarActionConfigurableApi;
+ readonly operaSidebarActionApi: OperaSidebarActionConfigurableApi;
+ readonly calls: readonly BrowserHarnessCall[];
+ setCapability(path: string, enabled: boolean): void;
+ hasCapability(path: string): boolean;
+ method unknown, TResult = BrowserMethodResult>(
+ path: string
+ ): BrowserMethod;
+ event(
+ path: string
+ ): BrowserEventHarness;
+ reset(): void;
+}
+
+const NO_RESULT_METHODS = new Set([
+ "action.disable",
+ "action.enable",
+ "action.openPopup",
+ "action.setBadgeBackgroundColor",
+ "action.setBadgeText",
+ "action.setBadgeTextColor",
+ "action.setIcon",
+ "action.setPopup",
+ "action.setTitle",
+ "alarms.create",
+ "audio.setActiveDevices",
+ "audio.setMute",
+ "audio.setProperties",
+ "browserAction.disable",
+ "browserAction.enable",
+ "browserAction.setBadgeBackgroundColor",
+ "browserAction.setBadgeText",
+ "browserAction.setIcon",
+ "browserAction.setPopup",
+ "browserAction.setTitle",
+ "browsingData.remove",
+ "browsingData.removeAppcache",
+ "browsingData.removeCache",
+ "browsingData.removeCacheStorage",
+ "browsingData.removeCookies",
+ "browsingData.removeDownloads",
+ "browsingData.removeFileSystems",
+ "browsingData.removeFormData",
+ "browsingData.removeHistory",
+ "browsingData.removeIndexedDB",
+ "browsingData.removeLocalStorage",
+ "browsingData.removePasswords",
+ "browsingData.removeServiceWorkers",
+ "browsingData.removeWebSQL",
+ "contextMenus.create",
+ "contextMenus.remove",
+ "contextMenus.removeAll",
+ "contextMenus.update",
+ "downloads.acceptDanger",
+ "downloads.cancel",
+ "downloads.open",
+ "downloads.pause",
+ "downloads.removeFile",
+ "downloads.resume",
+ "downloads.setUiOptions",
+ "extension.setUpdateUrlData",
+ "history.addUrl",
+ "history.deleteAll",
+ "history.deleteRange",
+ "history.deleteUrl",
+ "idle.setDetectionInterval",
+ "identity.clearAllCachedAuthTokens",
+ "identity.removeCachedAuthToken",
+ "management.createAppShortcut",
+ "management.generateAppForLink",
+ "management.launchApp",
+ "management.setEnabled",
+ "management.setLaunchType",
+ "management.uninstall",
+ "management.uninstallSelf",
+ "offscreen.closeDocument",
+ "offscreen.createDocument",
+ "permissions.addHostAccessRequest",
+ "permissions.removeHostAccessRequest",
+ "runtime.openOptionsPage",
+ "runtime.reload",
+ "runtime.restart",
+ "runtime.restartAfterDelay",
+ "runtime.setUninstallURL",
+ "scripting.insertCSS",
+ "scripting.removeCSS",
+ "sidePanel.close",
+ "sidePanel.open",
+ "sidePanel.setOptions",
+ "sidePanel.setPanelBehavior",
+ "tabs.goBack",
+ "tabs.goForward",
+ "tabs.insertCSS",
+ "tabs.reload",
+ "tabs.removeCSS",
+ "tabs.setZoom",
+ "tabs.setZoomSettings",
+ "tabs.ungroup",
+ "userScripts.configureWorld",
+ "userScripts.register",
+ "userScripts.resetWorldConfiguration",
+ "userScripts.unregister",
+ "userScripts.update",
+ "webRequest.handlerBehaviorChanged",
+ "browser.sidebarAction.close",
+ "browser.sidebarAction.open",
+ "browser.sidebarAction.setIcon",
+ "browser.sidebarAction.setPanel",
+ "browser.sidebarAction.setTitle",
+ "browser.sidebarAction.toggle",
+ "opr.sidebarAction.setBadgeBackgroundColor",
+ "opr.sidebarAction.setBadgeText",
+ "opr.sidebarAction.setBadgeTextColor",
+ "opr.sidebarAction.setIcon",
+ "opr.sidebarAction.setPanel",
+ "opr.sidebarAction.setTitle",
+]);
+
+const MULTI_RESULT_METHODS = new Set(["runtime.requestUpdateCheck"]);
+
+const isConfigurableMember = (entry: RawCapabilityEntry): boolean =>
+ entry.coverage === "configurable" || entry.kind === "event";
+
+const namespaceControl = (controls: Record, api: object): Record => ({
+ ...controls,
+ api,
+ reset(): void {
+ for (const control of Object.values(controls)) {
+ (control as {reset(): void}).reset();
+ }
+ },
+});
+
+/** Creates all non-stateful raw namespaces used by the production entrypoint. */
+export const createConfigurableNamespaces = (options: ConfigurableNamespacesOptions): ConfigurableNamespaces => {
+ const apiNamespaces: Record> = {};
+ const namespaceControls: Record> = {};
+ const methods = new Map unknown, unknown>>();
+ const events = new Map>();
+ const enabled = new Set();
+
+ const entries = RAW_CAPABILITY_COVERAGE.filter(isConfigurableMember);
+
+ for (const entry of entries) {
+ const isFirefoxSidebar = entry.namespace === "browser.sidebarAction";
+ const isOperaSidebar = entry.namespace === "opr.sidebarAction";
+ const namespace = isFirefoxSidebar ? "sidebarAction" : isOperaSidebar ? "operaSidebarAction" : entry.namespace;
+ apiNamespaces[namespace] ??= {};
+ namespaceControls[namespace] ??= {};
+ const namespaceApi = apiNamespaces[namespace];
+ const controls = namespaceControls[namespace];
+
+ if (entry.kind === "event") {
+ const event = createBrowserEvent();
+ events.set(entry.path, event);
+ controls[entry.member] = event;
+ namespaceApi[entry.member] = event.api;
+ enabled.add(entry.path);
+ continue;
+ }
+
+ if (entry.kind !== "method") continue;
+
+ const invocation = options.facade === "chrome" ? entry.chromeInvocation : entry.browserInvocation;
+
+ if (!invocation) {
+ throw new Error(`Browser method "${entry.path}" has no ${options.facade} invocation contract.`);
+ }
+
+ const callbackArgs = NO_RESULT_METHODS.has(entry.path)
+ ? () => []
+ : MULTI_RESULT_METHODS.has(entry.path)
+ ? (result: unknown) => result as readonly unknown[]
+ : (result: unknown) => [result];
+ const method = createBrowserMethod<(...args: never[]) => unknown, unknown>({
+ callback: "last",
+ callbackArgs,
+ invocation,
+ lastError: options.lastError,
+ name: entry.path,
+ nextSequence: options.nextSequence,
+ });
+
+ methods.set(entry.path, method);
+ controls[entry.member] = method;
+ namespaceApi[entry.member] = method.api;
+ enabled.add(entry.path);
+ }
+
+ const controls = Object.fromEntries(
+ Object.entries(namespaceControls).map(([namespace, members]) => [
+ namespace,
+ namespaceControl(members, apiNamespaces[namespace]),
+ ])
+ ) as unknown as ConfigurableBrowserControls;
+ const api = Object.fromEntries(
+ Object.entries(apiNamespaces).filter(
+ ([namespace]) => namespace !== "sidebarAction" && namespace !== "operaSidebarAction"
+ )
+ ) as unknown as ConfigurableBrowserApi;
+
+ const setCapability = (path: string, isEnabled: boolean): void => {
+ const entry = entries.find(candidate => candidate.path === path);
+
+ if (!entry) {
+ throw new Error(`Unknown configurable browser capability "${path}".`);
+ }
+
+ const namespace =
+ entry.namespace === "browser.sidebarAction"
+ ? "sidebarAction"
+ : entry.namespace === "opr.sidebarAction"
+ ? "operaSidebarAction"
+ : entry.namespace;
+ const namespaceApi = apiNamespaces[namespace];
+ const control = namespaceControls[namespace][entry.member] as {api: unknown};
+
+ if (isEnabled) {
+ namespaceApi[entry.member] = control.api;
+ enabled.add(path);
+ } else {
+ Reflect.deleteProperty(namespaceApi, entry.member);
+ enabled.delete(path);
+ }
+ };
+
+ return {
+ api,
+ controls,
+ get calls(): readonly BrowserHarnessCall[] {
+ return [...methods.entries()]
+ .flatMap(([path, method]) => method.calls.map(call => ({...call, api: path})))
+ .sort((left, right) => left.sequence - right.sequence);
+ },
+ event(
+ path: string
+ ): BrowserEventHarness {
+ const event = events.get(path);
+ if (!event) throw new Error(`Unknown configurable browser event "${path}".`);
+ return event as unknown as BrowserEventHarness;
+ },
+ hasCapability(path): boolean {
+ return enabled.has(path);
+ },
+ method unknown, TResult = BrowserMethodResult>(
+ path: string
+ ): BrowserMethod {
+ const method = methods.get(path);
+ if (!method) throw new Error(`Unknown configurable browser method "${path}".`);
+ return method as BrowserMethod;
+ },
+ operaSidebarActionApi: apiNamespaces.operaSidebarAction as unknown as OperaSidebarActionConfigurableApi,
+ reset(): void {
+ for (const method of methods.values()) method.reset();
+ for (const event of events.values()) event.reset();
+ for (const entry of entries) setCapability(entry.path, true);
+ },
+ setCapability,
+ sidebarActionApi: apiNamespaces.sidebarAction as unknown as FirefoxSidebarActionConfigurableApi,
+ };
+};
diff --git a/src/testing/coverage.test.ts b/src/testing/coverage.test.ts
new file mode 100644
index 0000000..1dfe531
--- /dev/null
+++ b/src/testing/coverage.test.ts
@@ -0,0 +1,209 @@
+import ts from "typescript";
+import {
+ EXPECTED_ROOT_RUNTIME_EXPORT_COUNT,
+ EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT,
+ PUBLIC_EXPORT_COVERAGE,
+ RAW_CAPABILITY_COVERAGE,
+ TYPE_ONLY_ROOT_EXPORTS,
+} from "./coverage";
+import {createBrowserHarness} from "./harness";
+import type {RawCapabilityEntry} from "./coverage";
+import type {BrowserMethod} from "./method";
+
+type Harness = ReturnType;
+type UnknownRecord = Record;
+type AnyBrowserMethod = BrowserMethod<(...args: never[]) => unknown, unknown>;
+
+const asRecord = (value: unknown): UnknownRecord | undefined =>
+ value !== null && typeof value === "object" ? (value as UnknownRecord) : undefined;
+
+const memberOf = (value: unknown, member: string): unknown => asRecord(value)?.[member];
+
+const directMethodNamespace = (harness: Harness, namespace: string): unknown => {
+ switch (namespace) {
+ case "runtime":
+ return harness.runtime;
+ case "permissions":
+ return harness.permissions;
+ case "tabs":
+ return harness.tabs;
+ case "windows":
+ return harness.windows;
+ case "scripting":
+ return harness.scripting;
+ default:
+ return undefined;
+ }
+};
+
+const directEventNamespace = (harness: Harness, namespace: string): unknown => {
+ switch (namespace) {
+ case "runtime":
+ return harness.runtime.events;
+ case "permissions":
+ return harness.permissions;
+ case "tabs":
+ return harness.tabs.events;
+ case "windows":
+ return harness.windows.events;
+ default:
+ return undefined;
+ }
+};
+
+const configurableNamespaces = (harness: Harness, namespace: string): readonly unknown[] => {
+ if (namespace === "browser.sidebarAction") return [harness.sidebar.firefox];
+ if (namespace === "opr.sidebarAction") return [harness.sidebar.opera];
+ return [memberOf(harness.configurable.chrome, namespace), memberOf(harness.configurable.browser, namespace)];
+};
+
+/** Resolves the control that actually owns a raw facade member, independent of its declared coverage. */
+const resolveRawCapability = (harness: Harness, entry: RawCapabilityEntry): readonly unknown[] => {
+ if (entry.kind === "property") {
+ return [harness.chrome, harness.browser].map(facade => {
+ const namespace = memberOf(facade, entry.namespace);
+ const record = asRecord(namespace);
+ return record ? Object.getOwnPropertyDescriptor(record, entry.member) : undefined;
+ });
+ }
+
+ const directNamespace =
+ entry.kind === "method"
+ ? directMethodNamespace(harness, entry.namespace)
+ : directEventNamespace(harness, entry.namespace);
+ const directControl = memberOf(directNamespace, entry.member);
+ if (directControl !== undefined) return [directControl];
+
+ return configurableNamespaces(harness, entry.namespace).map(namespace => memberOf(namespace, entry.member));
+};
+
+const isBrowserMethodControl = (value: unknown): value is AnyBrowserMethod => {
+ const record = asRecord(value);
+ return (
+ record !== undefined &&
+ typeof record.api === "function" &&
+ Array.isArray(record.calls) &&
+ typeof record.hasDefaultImplementation === "boolean" &&
+ typeof record.reset === "function"
+ );
+};
+
+const isBrowserEventControl = (value: unknown): boolean => {
+ const record = asRecord(value);
+ const api = asRecord(record?.api);
+ return (
+ record !== undefined &&
+ api !== undefined &&
+ typeof api.addListener === "function" &&
+ typeof api.removeListener === "function" &&
+ typeof api.hasListener === "function" &&
+ typeof record.emit === "function" &&
+ typeof record.reset === "function"
+ );
+};
+
+const isPropertyDescriptor = (value: unknown): boolean => {
+ const record = asRecord(value);
+ return record !== undefined && ("value" in record || "get" in record);
+};
+
+const isValidResolution = (entry: RawCapabilityEntry, control: unknown): boolean => {
+ if (entry.kind === "method") return isBrowserMethodControl(control);
+ if (entry.kind === "event") return isBrowserEventControl(control);
+ return isPropertyDescriptor(control);
+};
+
+const rootExports = () => {
+ const program = ts.createProgram(["src/index.ts"], {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Node10,
+ target: ts.ScriptTarget.ESNext,
+ types: ["chrome"],
+ });
+ const checker = program.getTypeChecker();
+ const source = program.getSourceFile("src/index.ts");
+
+ if (!source) throw new Error("Unable to load src/index.ts for the public export coverage test");
+
+ const moduleSymbol = checker.getSymbolAtLocation(source);
+ if (!moduleSymbol) throw new Error("Unable to resolve the src/index.ts module symbol");
+
+ return {checker, exports: checker.getExportsOfModule(moduleSymbol)};
+};
+
+describe("testing coverage matrices", () => {
+ test("classifies every root TypeScript export exactly once", () => {
+ const {exports} = rootExports();
+ const actual = exports.map(symbol => symbol.name).sort();
+ const classified = PUBLIC_EXPORT_COVERAGE.map(entry => entry.name).sort();
+
+ expect(actual).toHaveLength(EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT);
+ expect(classified).toHaveLength(EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT);
+ expect(new Set(classified).size).toBe(classified.length);
+ expect(classified).toEqual(actual);
+ expect(PUBLIC_EXPORT_COVERAGE.filter(entry => entry.coverage === "unsupported")).toEqual([]);
+ });
+
+ test("keeps the three interfaces type-only and the other 328 exports runtime-visible", () => {
+ const {checker, exports} = rootExports();
+ const typeOnly = exports
+ .filter(symbol => {
+ const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
+ return !(target.flags & ts.SymbolFlags.Value);
+ })
+ .map(symbol => symbol.name)
+ .sort();
+
+ expect(typeOnly).toEqual([...TYPE_ONLY_ROOT_EXPORTS].sort());
+ expect(exports.length - typeOnly.length).toBe(EXPECTED_ROOT_RUNTIME_EXPORT_COUNT);
+ });
+
+ test("classifies every raw capability path once", () => {
+ const paths = RAW_CAPABILITY_COVERAGE.map(entry => entry.path);
+
+ expect(paths).toHaveLength(305);
+ expect(new Set(paths).size).toBe(paths.length);
+ expect(
+ RAW_CAPABILITY_COVERAGE.filter(
+ entry => entry.kind === "method" && (!entry.browserInvocation || !entry.chromeInvocation)
+ )
+ ).toEqual([]);
+ });
+
+ test("resolves all raw capabilities to their actual harness controls", () => {
+ const harness = createBrowserHarness();
+ const resolutions = RAW_CAPABILITY_COVERAGE.map(entry => ({
+ controls: resolveRawCapability(harness, entry),
+ entry,
+ }));
+
+ expect(resolutions).toHaveLength(305);
+ expect(
+ resolutions
+ .filter(
+ ({controls, entry}) =>
+ controls.length === 0 || controls.some(control => !isValidResolution(entry, control))
+ )
+ .map(({entry}) => `${entry.kind}:${entry.path}`)
+ ).toEqual([]);
+ });
+
+ test("keeps stateful coverage equivalent to having a default implementation", () => {
+ const harness = createBrowserHarness();
+ const mismatches = RAW_CAPABILITY_COVERAGE.filter(entry => entry.kind === "method")
+ .flatMap(entry => resolveRawCapability(harness, entry).map(control => ({control, entry})))
+ .filter(({control, entry}) => {
+ if (!isBrowserMethodControl(control)) return true;
+ return (entry.coverage === "stateful") !== control.hasDefaultImplementation;
+ })
+ .map(({control, entry}) => ({
+ coverage: entry.coverage,
+ hasDefaultImplementation: isBrowserMethodControl(control)
+ ? control.hasDefaultImplementation
+ : "unresolved",
+ path: entry.path,
+ }));
+
+ expect(mismatches).toEqual([]);
+ });
+});
diff --git a/src/testing/coverage.ts b/src/testing/coverage.ts
new file mode 100644
index 0000000..0d3fe56
--- /dev/null
+++ b/src/testing/coverage.ts
@@ -0,0 +1,922 @@
+export type PublicExportKind = "method-wrapper" | "event-wrapper" | "class" | "enum" | "interface";
+
+export type PublicExportCoverage = "stateful" | "configurable" | "event" | "behavioral" | "declaration" | "unsupported";
+
+export interface PublicExportCoverageEntry {
+ readonly name: string;
+ readonly module: string;
+ readonly kind: PublicExportKind;
+ readonly coverage: PublicExportCoverage;
+}
+
+const entries = (
+ module: string,
+ kind: PublicExportKind,
+ coverage: PublicExportCoverage,
+ names: readonly string[]
+): PublicExportCoverageEntry[] => names.map(name => ({coverage, kind, module, name}));
+
+/**
+ * Classification of every export from the package root entrypoint.
+ *
+ * Keep this list explicit. The coverage test compares it with the TypeScript
+ * compiler's view of `src/index.ts`, so adding an unclassified root export is a
+ * deliberate test failure instead of an implicit fake implementation.
+ */
+export const PUBLIC_EXPORT_COVERAGE: readonly PublicExportCoverageEntry[] = [
+ ...entries("action", "method-wrapper", "configurable", [
+ "disableAction",
+ "enableAction",
+ "getBadgeBgColor",
+ "getBadgeText",
+ "getBadgeTextColor",
+ "getActionPopup",
+ "getActionTitle",
+ "getActionUserSetting",
+ "isActionEnabled",
+ "openActionPopup",
+ "setBadgeBgColor",
+ "setBadgeText",
+ "setBadgeTextColor",
+ "setActionIcon",
+ "setActionPopup",
+ "setActionTitle",
+ ]),
+ ...entries("action", "method-wrapper", "behavioral", ["getDefaultPopup", "clearBadgeText"]),
+ ...entries("action", "event-wrapper", "event", ["onActionClicked", "onActionUserSettingsChanged"]),
+
+ ...entries("alarms", "method-wrapper", "configurable", [
+ "clearAlarm",
+ "clearAllAlarm",
+ "createAlarm",
+ "getAlarm",
+ "getAllAlarm",
+ ]),
+ ...entries("alarms", "event-wrapper", "event", ["onAlarm"]),
+
+ ...entries("audio", "method-wrapper", "configurable", [
+ "getAudioDevices",
+ "getAudioMute",
+ "setAudioActiveDevices",
+ "setAudioMute",
+ "setAudioProperties",
+ ]),
+ ...entries("audio", "event-wrapper", "event", [
+ "onAudioDeviceListChanged",
+ "onAudioLevelChanged",
+ "onAudioMuteChanged",
+ ]),
+
+ ...entries("browser", "method-wrapper", "behavioral", ["browser"]),
+
+ ...entries("browserDetection", "enum", "declaration", ["BrowserName", "BrowserFamily", "BrowserGuessSource"]),
+ ...entries("browserDetection", "interface", "declaration", ["BrowserGuess"]),
+ ...entries("browserDetection", "method-wrapper", "behavioral", ["guessBrowser", "isBrowser", "isBrowserFamily"]),
+
+ ...entries("browsingData", "method-wrapper", "configurable", [
+ "removeBrowsingData",
+ "removeAppcacheData",
+ "removeCacheData",
+ "removeCacheStorageData",
+ "removeCookiesData",
+ "removeDownloadsData",
+ "removeFileSystemsData",
+ "removeFormData",
+ "removeHistoryData",
+ "removeIndexedDBData",
+ "removeLocalStorageData",
+ "removePasswordsData",
+ "removeServiceWorkersData",
+ "removeWebSQLData",
+ "getBrowsingDataSettings",
+ ]),
+
+ ...entries("commands", "method-wrapper", "configurable", ["getAllCommands"]),
+ ...entries("commands", "event-wrapper", "event", ["onCommand", "onSpecificCommand"]),
+
+ ...entries("contextMenus", "method-wrapper", "configurable", [
+ "createContextMenus",
+ "removeContextMenus",
+ "removeAllContextMenus",
+ "updateContextMenus",
+ ]),
+ ...entries("contextMenus", "method-wrapper", "behavioral", ["createOrUpdateContextMenu"]),
+ ...entries("contextMenus", "event-wrapper", "event", ["onContextMenusClicked"]),
+
+ ...entries("cookies", "method-wrapper", "configurable", [
+ "getCookie",
+ "getAllCookie",
+ "getAllCookieStores",
+ "getCookiePartitionKey",
+ "removeCookie",
+ "setCookie",
+ ]),
+ ...entries("cookies", "event-wrapper", "event", ["onCookieChanged"]),
+
+ ...entries("documentScan", "method-wrapper", "configurable", [
+ "cancelDocScanning",
+ "closeDocScanner",
+ "getDocScannerOptionGroups",
+ "getDocScannerList",
+ "openDocScanner",
+ "readDocScanningData",
+ "docScanning",
+ "setDocScannerOptions",
+ "startDocScanning",
+ ]),
+
+ ...entries("downloads", "class", "behavioral", ["BlockDownloadError"]),
+ ...entries("downloads", "method-wrapper", "configurable", [
+ "acceptDownloadDanger",
+ "cancelDownload",
+ "eraseDownload",
+ "getDownloadFileIcon",
+ "openDownload",
+ "pauseDownload",
+ "removeDownloadFile",
+ "resumeDownload",
+ "searchDownloads",
+ "setDownloadsUiOptions",
+ "showDownloadFolder",
+ ]),
+ ...entries("downloads", "method-wrapper", "behavioral", [
+ "download",
+ "showDownload",
+ "findDownload",
+ "isDownloadExists",
+ "getDownloadState",
+ ]),
+ ...entries("downloads", "event-wrapper", "event", [
+ "onDownloadsChanged",
+ "onDownloadsCreated",
+ "onDownloadsDeterminingFilename",
+ ]),
+
+ ...entries("env", "method-wrapper", "behavioral", ["isBackground"]),
+
+ ...entries("extension", "method-wrapper", "configurable", [
+ "getBackgroundPage",
+ "getViews",
+ "isAllowedFileSchemeAccess",
+ "isAllowedIncognitoAccess",
+ "setUpdateUrlData",
+ ]),
+
+ ...entries("history", "method-wrapper", "configurable", [
+ "addHistoryUrl",
+ "deleteAllHistory",
+ "deleteRangeHistory",
+ "deleteHistoryUrl",
+ "getHistoryVisits",
+ "searchHistory",
+ ]),
+ ...entries("history", "event-wrapper", "event", ["onHistoryVisited", "onHistoryVisitRemoved"]),
+
+ ...entries("i18n", "method-wrapper", "configurable", [
+ "detectI18Language",
+ "getI18nAcceptLanguages",
+ "getI18nUILanguage",
+ "getI18nMessage",
+ ]),
+ ...entries("i18n", "method-wrapper", "behavioral", ["getDefaultLanguage"]),
+
+ ...entries("identity", "interface", "declaration", ["LaunchWebAuthFlowDetails"]),
+ ...entries("identity", "method-wrapper", "configurable", [
+ "getIdentityRedirectUrl",
+ "launchWebAuthFlow",
+ "getAuthToken",
+ "removeCachedAuthToken",
+ "clearAllCachedAuthTokens",
+ "getProfileUserInfo",
+ "getIdentityAccounts",
+ ]),
+ ...entries("identity", "event-wrapper", "event", ["onIdentitySignInChanged"]),
+
+ ...entries("idle", "method-wrapper", "configurable", [
+ "getIdleAutoLockDelay",
+ "queryIdleState",
+ "setIdleDetectionInterval",
+ ]),
+ ...entries("idle", "event-wrapper", "event", ["onIdleStateChanged"]),
+
+ ...entries("management", "method-wrapper", "configurable", [
+ "createAppShortcut",
+ "generateAppForLink",
+ "getExtensionInfo",
+ "getAllExtensionInfo",
+ "getPermissionWarningsById",
+ "getPermissionWarningsByManifest",
+ "getCurrentExtension",
+ "launchExtensionApp",
+ "setExtensionEnabled",
+ "setExtensionLaunchType",
+ "uninstallExtension",
+ "uninstallCurrentExtension",
+ ]),
+ ...entries("management", "event-wrapper", "event", [
+ "onExtensionDisabled",
+ "onExtensionEnabled",
+ "onExtensionInstalled",
+ "onExtensionUninstalled",
+ ]),
+
+ ...entries("notifications", "method-wrapper", "configurable", [
+ "clearNotification",
+ "createNotification",
+ "getAllNotifications",
+ "getNotificationPermissionLevel",
+ "updateNotification",
+ ]),
+ ...entries("notifications", "method-wrapper", "behavioral", ["isAvailableNotifications", "clearAllNotifications"]),
+ ...entries("notifications", "event-wrapper", "event", [
+ "onNotificationsButtonClicked",
+ "onNotificationsClicked",
+ "onNotificationsClosed",
+ "onNotificationsPermissionLevelChanged",
+ ]),
+
+ ...entries("offscreen", "method-wrapper", "configurable", ["closeOffscreen", "createOffscreen", "hasOffscreen"]),
+ ...entries("offscreen", "method-wrapper", "behavioral", [
+ "getOffscreenContext",
+ "getOffscreenUrl",
+ "getOffscreenPath",
+ "hasOffscreenUrl",
+ "hasOffscreenPath",
+ ]),
+
+ ...entries("permissions", "method-wrapper", "configurable", ["addHostAccessRequest", "removeHostAccessRequest"]),
+ ...entries("permissions", "method-wrapper", "stateful", [
+ "containsPermissions",
+ "getAllPermissions",
+ "removePermissions",
+ "requestPermissions",
+ ]),
+ ...entries("permissions", "event-wrapper", "event", ["onPermissionsAdded", "onPermissionsRemoved"]),
+
+ ...entries("runtime", "method-wrapper", "configurable", [
+ "connect",
+ "connectNative",
+ "getPackageDirectoryEntry",
+ "getPlatformInfo",
+ "openOptionsPage",
+ "reload",
+ "requestUpdateCheck",
+ "restart",
+ "restartAfterDelay",
+ "setUninstallUrl",
+ ]),
+ ...entries("runtime", "method-wrapper", "stateful", [
+ "getContexts",
+ "getManifest",
+ "getBrowserInfo",
+ "getUrl",
+ "sendMessage",
+ ]),
+ ...entries("runtime", "method-wrapper", "behavioral", ["getId", "getManifestVersion", "isManifestVersion3"]),
+ ...entries("runtime", "event-wrapper", "event", [
+ "onConnect",
+ "onConnectExternal",
+ "onInstalled",
+ "onMessage",
+ "onMessageExternal",
+ "onRestartRequired",
+ "onStartup",
+ "onSuspend",
+ "onSuspendCanceled",
+ "onUpdateAvailable",
+ "onUserScriptConnect",
+ "onUserScriptMessage",
+ ]),
+
+ ...entries("scripting", "method-wrapper", "configurable", ["executeScript", "insertCss", "removeCss"]),
+ ...entries("scripting", "method-wrapper", "stateful", [
+ "getRegisteredContentScripts",
+ "registerContentScripts",
+ "unregisterContentScripts",
+ "updateContentScripts",
+ ]),
+ ...entries("scripting", "method-wrapper", "behavioral", ["isAvailableScripting"]),
+
+ ...entries("sidebar", "class", "behavioral", ["SidebarError"]),
+ ...entries("sidebar", "method-wrapper", "behavioral", [
+ "getSidebarOptions",
+ "getSidebarBehavior",
+ "canOpenSidebar",
+ "canCloseSidebar",
+ "openSidebar",
+ "closeSidebar",
+ "setSidebarOptions",
+ "setSidebarBehavior",
+ "isOpenSidebar",
+ "toggleSidebar",
+ "setSidebarPath",
+ "getSidebarPath",
+ "setSidebarTitle",
+ "setSidebarBadgeText",
+ "clearSidebarBadgeText",
+ "setSidebarIcon",
+ "setSidebarBadgeTextColor",
+ "setSidebarBadgeBgColor",
+ "getSidebarTitle",
+ "getSidebarBadgeText",
+ "getSidebarBadgeTextColor",
+ "getSidebarBadgeBgColor",
+ ]),
+
+ ...entries("tabCapture", "method-wrapper", "configurable", [
+ "createTabCapture",
+ "getCapturedTabs",
+ "getCaptureMediaStreamId",
+ ]),
+ ...entries("tabCapture", "event-wrapper", "event", ["onCaptureStatusChanged"]),
+
+ ...entries("tabs", "method-wrapper", "stateful", [
+ "createTab",
+ "getCurrentTab",
+ "getTab",
+ "queryTabs",
+ "removeTab",
+ "updateTab",
+ ]),
+ ...entries("tabs", "method-wrapper", "configurable", [
+ "captureVisibleTab",
+ "connectTab",
+ "detectTabLanguage",
+ "discardTab",
+ "duplicateTab",
+ "getTabZoom",
+ "getTabZoomSettings",
+ "goTabBack",
+ "goTabForward",
+ "groupTabs",
+ "highlightTab",
+ "moveTab",
+ "moveTabs",
+ "reloadTab",
+ "sendTabMessage",
+ "setTabZoom",
+ "setTabZoomSettings",
+ "ungroupTab",
+ "executeScriptTab",
+ "insertCssTab",
+ "removeCssTab",
+ ]),
+ ...entries("tabs", "method-wrapper", "behavioral", [
+ "getTabUrl",
+ "getActiveTab",
+ "queryTabIds",
+ "findTab",
+ "findTabById",
+ "findTabByUrl",
+ "updateTabAsSelected",
+ "updateTabAsActive",
+ "openOrCreateTab",
+ "openOrCreateTabByUrl",
+ ]),
+ ...entries("tabs", "event-wrapper", "event", [
+ "onTabActivated",
+ "onTabAttached",
+ "onTabCreated",
+ "onTabDetached",
+ "onTabHighlighted",
+ "onTabMoved",
+ "onTabRemoved",
+ "onTabReplaced",
+ "onTabUpdated",
+ "onTabZoomChange",
+ ]),
+
+ ...entries("userScripts", "method-wrapper", "configurable", [
+ "configureUserScriptsWorld",
+ "getUserScripts",
+ "getUserScriptsWorldConfigs",
+ "executeUserScript",
+ "registerUserScripts",
+ "resetUserScriptsWorldConfigs",
+ "unregisterUserScripts",
+ "updateUserScripts",
+ ]),
+ ...entries("userScripts", "method-wrapper", "behavioral", ["isAvailableUserScripts"]),
+
+ ...entries("webNavigation", "method-wrapper", "configurable", ["getAllFrames", "getFrame"]),
+ ...entries("webNavigation", "event-wrapper", "event", [
+ "onWebNavigationBeforeNavigate",
+ "onWebNavigationCommitted",
+ "onWebNavigationCompleted",
+ "onWebNavigationCreatedNavigationTarget",
+ "onWebNavigationDOMContentLoaded",
+ "onWebNavigationErrorOccurred",
+ "onWebNavigationHistoryStateUpdated",
+ "onWebNavigationReferenceFragmentUpdated",
+ "onWebNavigationTabReplaced",
+ ]),
+
+ ...entries("webRequest", "method-wrapper", "configurable", ["handlerWebRequestBehaviorChanged"]),
+ ...entries("webRequest", "event-wrapper", "event", [
+ "onWebRequestAuthRequired",
+ "onWebRequestBeforeRedirect",
+ "onWebRequestBeforeRequest",
+ "onWebRequestBeforeSendHeaders",
+ "onWebRequestCompleted",
+ "onWebRequestErrorOccurred",
+ "onWebRequestHeadersReceived",
+ "onWebRequestResponseStarted",
+ "onWebRequestSendHeaders",
+ ]),
+
+ ...entries("windows", "interface", "declaration", ["WindowEventFilter"]),
+ ...entries("windows", "method-wrapper", "stateful", [
+ "createWindow",
+ "getWindow",
+ "getAllWindows",
+ "getCurrentWindow",
+ "getLastFocusedWindow",
+ "removeWindow",
+ "updateWindow",
+ ]),
+ ...entries("windows", "event-wrapper", "event", [
+ "onWindowBoundsChanged",
+ "onWindowCreated",
+ "onWindowFocusChanged",
+ "onWindowRemoved",
+ ]),
+] as const;
+
+export const TYPE_ONLY_ROOT_EXPORTS = ["BrowserGuess", "LaunchWebAuthFlowDetails", "WindowEventFilter"] as const;
+
+export const EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT = 331;
+export const EXPECTED_ROOT_RUNTIME_EXPORT_COUNT = 328;
+
+export const getPublicExportCoverage = (name: string): PublicExportCoverageEntry | undefined =>
+ PUBLIC_EXPORT_COVERAGE.find(entry => entry.name === name);
+
+export type RawCapabilityKind = "method" | "event" | "property";
+export type RawCapabilityCoverage = "stateful" | "configurable" | "event";
+export type RawMethodInvocation = "sync" | "callback" | "promise" | "dual" | "promise-tolerant" | "hybrid";
+export type RawFailureChannel =
+ | "none"
+ | "sync-throw"
+ | "callback-last-error"
+ | "promise-rejection"
+ | "invocation-dependent";
+
+export interface RawCapabilityEntry {
+ readonly path: string;
+ readonly namespace: string;
+ readonly member: string;
+ readonly kind: RawCapabilityKind;
+ readonly coverage: RawCapabilityCoverage;
+ readonly chromeInvocation?: RawMethodInvocation;
+ readonly browserInvocation?: RawMethodInvocation;
+ readonly failureChannel: RawFailureChannel;
+ readonly supportedOptions?: readonly string[];
+}
+
+type InvocationPair = {
+ readonly chrome: RawMethodInvocation;
+ readonly browser: RawMethodInvocation;
+};
+
+const callbackInvocation: InvocationPair = {browser: "dual", chrome: "callback"};
+const syncInvocation: InvocationPair = {browser: "sync", chrome: "sync"};
+const promiseInvocation: InvocationPair = {browser: "promise", chrome: "promise"};
+const hybridInvocation: InvocationPair = {browser: "hybrid", chrome: "hybrid"};
+
+const methodCapabilities = (
+ namespace: string,
+ coverage: Exclude,
+ invocation: InvocationPair,
+ names: readonly string[],
+ supportedOptions?: Readonly>
+): RawCapabilityEntry[] =>
+ names.map(member => ({
+ browserInvocation: invocation.browser,
+ chromeInvocation: invocation.chrome,
+ coverage,
+ failureChannel:
+ invocation.browser === "sync" && invocation.chrome === "sync" ? "sync-throw" : "invocation-dependent",
+ kind: "method",
+ member,
+ namespace,
+ path: `${namespace}.${member}`,
+ supportedOptions: supportedOptions?.[member],
+ }));
+
+const eventCapabilities = (namespace: string, names: readonly string[]): RawCapabilityEntry[] =>
+ names.map(member => ({
+ coverage: "event",
+ failureChannel: "none",
+ kind: "event",
+ member,
+ namespace,
+ path: `${namespace}.${member}`,
+ }));
+
+const propertyCapabilities = (
+ namespace: string,
+ coverage: Exclude,
+ names: readonly string[]
+): RawCapabilityEntry[] =>
+ names.map(member => ({
+ coverage,
+ failureChannel: "none",
+ kind: "property",
+ member,
+ namespace,
+ path: `${namespace}.${member}`,
+ }));
+
+/**
+ * Raw WebExtension members used by the production wrappers. The harness may
+ * model a member statefully or expose a configurable test double, but it must
+ * never synthesize an unlisted browser capability.
+ */
+export const RAW_CAPABILITY_COVERAGE: readonly RawCapabilityEntry[] = [
+ ...methodCapabilities("action", "configurable", callbackInvocation, [
+ "disable",
+ "enable",
+ "getBadgeBackgroundColor",
+ "getBadgeText",
+ "getBadgeTextColor",
+ "getPopup",
+ "getTitle",
+ "getUserSettings",
+ "isEnabled",
+ "openPopup",
+ "setBadgeBackgroundColor",
+ "setBadgeText",
+ "setBadgeTextColor",
+ "setIcon",
+ "setPopup",
+ "setTitle",
+ ]),
+ ...eventCapabilities("action", ["onClicked", "onUserSettingsChanged"]),
+ ...methodCapabilities("browserAction", "configurable", callbackInvocation, [
+ "disable",
+ "enable",
+ "getBadgeBackgroundColor",
+ "getBadgeText",
+ "getPopup",
+ "getTitle",
+ "setBadgeBackgroundColor",
+ "setBadgeText",
+ "setIcon",
+ "setPopup",
+ "setTitle",
+ ]),
+ ...eventCapabilities("browserAction", ["onClicked"]),
+
+ ...methodCapabilities("alarms", "configurable", callbackInvocation, [
+ "clear",
+ "clearAll",
+ "create",
+ "get",
+ "getAll",
+ ]),
+ ...eventCapabilities("alarms", ["onAlarm"]),
+
+ ...methodCapabilities("audio", "configurable", callbackInvocation, [
+ "getDevices",
+ "getMute",
+ "setActiveDevices",
+ "setMute",
+ "setProperties",
+ ]),
+ ...eventCapabilities("audio", ["onDeviceListChanged", "onLevelChanged", "onMuteChanged"]),
+
+ ...methodCapabilities("browsingData", "configurable", callbackInvocation, [
+ "remove",
+ "removeAppcache",
+ "removeCache",
+ "removeCacheStorage",
+ "removeCookies",
+ "removeDownloads",
+ "removeFileSystems",
+ "removeFormData",
+ "removeHistory",
+ "removeIndexedDB",
+ "removeLocalStorage",
+ "removePasswords",
+ "removeServiceWorkers",
+ "removeWebSQL",
+ "settings",
+ ]),
+
+ ...methodCapabilities("commands", "configurable", callbackInvocation, ["getAll"]),
+ ...eventCapabilities("commands", ["onCommand"]),
+
+ ...methodCapabilities("contextMenus", "configurable", callbackInvocation, [
+ "create",
+ "remove",
+ "removeAll",
+ "update",
+ ]),
+ ...eventCapabilities("contextMenus", ["onClicked"]),
+
+ ...methodCapabilities("cookies", "configurable", callbackInvocation, [
+ "get",
+ "getAll",
+ "getAllCookieStores",
+ "getPartitionKey",
+ "remove",
+ "set",
+ ]),
+ ...eventCapabilities("cookies", ["onChanged"]),
+
+ ...methodCapabilities("documentScan", "configurable", callbackInvocation, [
+ "cancelScan",
+ "closeScanner",
+ "getOptionGroups",
+ "getScannerList",
+ "openScanner",
+ "readScanData",
+ "scan",
+ "setOptions",
+ "startScan",
+ ]),
+
+ ...methodCapabilities("downloads", "configurable", callbackInvocation, [
+ "acceptDanger",
+ "cancel",
+ "download",
+ "erase",
+ "getFileIcon",
+ "open",
+ "pause",
+ "removeFile",
+ "resume",
+ "search",
+ "setUiOptions",
+ ]),
+ ...methodCapabilities("downloads", "configurable", syncInvocation, ["show", "showDefaultFolder"]),
+ ...eventCapabilities("downloads", ["onChanged", "onCreated", "onDeterminingFilename"]),
+
+ ...methodCapabilities("extension", "configurable", syncInvocation, [
+ "getBackgroundPage",
+ "getViews",
+ "setUpdateUrlData",
+ ]),
+ ...methodCapabilities("extension", "configurable", callbackInvocation, [
+ "isAllowedFileSchemeAccess",
+ "isAllowedIncognitoAccess",
+ ]),
+
+ ...methodCapabilities("history", "configurable", callbackInvocation, [
+ "addUrl",
+ "deleteAll",
+ "deleteRange",
+ "deleteUrl",
+ "getVisits",
+ "search",
+ ]),
+ ...eventCapabilities("history", ["onVisited", "onVisitRemoved"]),
+
+ ...methodCapabilities("i18n", "configurable", callbackInvocation, ["detectLanguage", "getAcceptLanguages"]),
+ ...methodCapabilities("i18n", "configurable", syncInvocation, ["getMessage", "getUILanguage"]),
+
+ ...methodCapabilities("identity", "configurable", syncInvocation, ["getRedirectURL"]),
+ ...methodCapabilities("identity", "configurable", callbackInvocation, [
+ "clearAllCachedAuthTokens",
+ "getAccounts",
+ "getProfileUserInfo",
+ "launchWebAuthFlow",
+ "removeCachedAuthToken",
+ ]),
+ ...methodCapabilities("identity", "configurable", hybridInvocation, ["getAuthToken"]),
+ ...eventCapabilities("identity", ["onSignInChanged"]),
+
+ ...methodCapabilities("idle", "configurable", callbackInvocation, ["getAutoLockDelay", "queryState"]),
+ ...methodCapabilities("idle", "configurable", syncInvocation, ["setDetectionInterval"]),
+ ...eventCapabilities("idle", ["onStateChanged"]),
+
+ ...methodCapabilities("management", "configurable", callbackInvocation, [
+ "createAppShortcut",
+ "generateAppForLink",
+ "get",
+ "getAll",
+ "getPermissionWarningsById",
+ "getPermissionWarningsByManifest",
+ "getSelf",
+ "launchApp",
+ "setEnabled",
+ "setLaunchType",
+ "uninstall",
+ "uninstallSelf",
+ ]),
+ ...eventCapabilities("management", ["onDisabled", "onEnabled", "onInstalled", "onUninstalled"]),
+
+ ...methodCapabilities("notifications", "configurable", callbackInvocation, [
+ "clear",
+ "create",
+ "getAll",
+ "getPermissionLevel",
+ "update",
+ ]),
+ ...eventCapabilities("notifications", ["onButtonClicked", "onClicked", "onClosed", "onPermissionLevelChanged"]),
+
+ ...methodCapabilities("offscreen", "configurable", callbackInvocation, [
+ "closeDocument",
+ "createDocument",
+ "hasDocument",
+ ]),
+
+ ...methodCapabilities("permissions", "configurable", callbackInvocation, [
+ "addHostAccessRequest",
+ "removeHostAccessRequest",
+ ]),
+ ...methodCapabilities("permissions", "stateful", callbackInvocation, ["contains", "getAll", "remove", "request"]),
+ ...eventCapabilities("permissions", ["onAdded", "onRemoved"]),
+
+ ...propertyCapabilities("runtime", "stateful", ["id", "lastError"]),
+ ...methodCapabilities("runtime", "configurable", syncInvocation, ["connect", "connectNative", "reload", "restart"]),
+ ...methodCapabilities("runtime", "configurable", callbackInvocation, [
+ "getPackageDirectoryEntry",
+ "getPlatformInfo",
+ "openOptionsPage",
+ "requestUpdateCheck",
+ "restartAfterDelay",
+ "setUninstallURL",
+ ]),
+ ...methodCapabilities("runtime", "stateful", syncInvocation, ["getManifest", "getURL"]),
+ ...methodCapabilities("runtime", "stateful", callbackInvocation, ["getContexts", "sendMessage"]),
+ ...methodCapabilities("runtime", "stateful", promiseInvocation, ["getBrowserInfo"]),
+ ...eventCapabilities("runtime", [
+ "onConnect",
+ "onConnectExternal",
+ "onInstalled",
+ "onMessage",
+ "onMessageExternal",
+ "onRestartRequired",
+ "onStartup",
+ "onSuspend",
+ "onSuspendCanceled",
+ "onUpdateAvailable",
+ "onUserScriptConnect",
+ "onUserScriptMessage",
+ ]),
+
+ ...methodCapabilities("scripting", "configurable", callbackInvocation, ["executeScript", "insertCSS", "removeCSS"]),
+ ...methodCapabilities("scripting", "stateful", callbackInvocation, [
+ "getRegisteredContentScripts",
+ "registerContentScripts",
+ "unregisterContentScripts",
+ "updateContentScripts",
+ ]),
+
+ ...methodCapabilities("sidePanel", "configurable", callbackInvocation, [
+ "close",
+ "getOptions",
+ "getPanelBehavior",
+ "open",
+ "setOptions",
+ "setPanelBehavior",
+ ]),
+
+ ...methodCapabilities("tabCapture", "configurable", callbackInvocation, [
+ "capture",
+ "getCapturedTabs",
+ "getMediaStreamId",
+ ]),
+ ...eventCapabilities("tabCapture", ["onStatusChanged"]),
+
+ ...methodCapabilities(
+ "tabs",
+ "stateful",
+ callbackInvocation,
+ ["create", "get", "getCurrent", "query", "remove", "update"],
+ {
+ query: [
+ "active",
+ "audible",
+ "autoDiscardable",
+ "currentWindow",
+ "discarded",
+ "frozen",
+ "groupId",
+ "highlighted",
+ "index",
+ "lastFocusedWindow",
+ "muted",
+ "pinned",
+ "splitViewId",
+ "status",
+ "title (literal only)",
+ "url (literal only)",
+ "windowId",
+ "windowType",
+ ],
+ }
+ ),
+ ...methodCapabilities("tabs", "configurable", syncInvocation, ["connect"]),
+ ...methodCapabilities("tabs", "configurable", callbackInvocation, [
+ "captureVisibleTab",
+ "detectLanguage",
+ "discard",
+ "duplicate",
+ "executeScript",
+ "getZoom",
+ "getZoomSettings",
+ "goBack",
+ "goForward",
+ "group",
+ "highlight",
+ "insertCSS",
+ "move",
+ "reload",
+ "removeCSS",
+ "sendMessage",
+ "setZoom",
+ "setZoomSettings",
+ "ungroup",
+ ]),
+ ...eventCapabilities("tabs", [
+ "onActivated",
+ "onAttached",
+ "onCreated",
+ "onDetached",
+ "onHighlighted",
+ "onMoved",
+ "onRemoved",
+ "onReplaced",
+ "onUpdated",
+ "onZoomChange",
+ ]),
+
+ ...methodCapabilities("userScripts", "configurable", callbackInvocation, ["getScripts"]),
+ ...methodCapabilities("userScripts", "configurable", promiseInvocation, [
+ "configureWorld",
+ "execute",
+ "getWorldConfigurations",
+ "register",
+ "resetWorldConfiguration",
+ "unregister",
+ "update",
+ ]),
+
+ ...methodCapabilities("webNavigation", "configurable", callbackInvocation, ["getAllFrames", "getFrame"]),
+ ...eventCapabilities("webNavigation", [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onCompleted",
+ "onCreatedNavigationTarget",
+ "onDOMContentLoaded",
+ "onErrorOccurred",
+ "onHistoryStateUpdated",
+ "onReferenceFragmentUpdated",
+ "onTabReplaced",
+ ]),
+
+ ...methodCapabilities("webRequest", "configurable", callbackInvocation, ["handlerBehaviorChanged"]),
+ ...eventCapabilities("webRequest", [
+ "onAuthRequired",
+ "onBeforeRedirect",
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onCompleted",
+ "onErrorOccurred",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onSendHeaders",
+ ]),
+
+ ...methodCapabilities("windows", "stateful", callbackInvocation, [
+ "create",
+ "get",
+ "getAll",
+ "getCurrent",
+ "getLastFocused",
+ "remove",
+ "update",
+ ]),
+ ...eventCapabilities("windows", ["onBoundsChanged", "onCreated", "onFocusChanged", "onRemoved"]),
+
+ ...methodCapabilities("browser.sidebarAction", "configurable", promiseInvocation, [
+ "close",
+ "getPanel",
+ "getTitle",
+ "isOpen",
+ "open",
+ "setIcon",
+ "setPanel",
+ "setTitle",
+ "toggle",
+ ]),
+ ...methodCapabilities("opr.sidebarAction", "configurable", callbackInvocation, [
+ "getBadgeBackgroundColor",
+ "getBadgeText",
+ "getBadgeTextColor",
+ "getPanel",
+ "getTitle",
+ ]),
+ ...methodCapabilities("opr.sidebarAction", "configurable", syncInvocation, [
+ "setBadgeBackgroundColor",
+ "setBadgeText",
+ "setBadgeTextColor",
+ "setIcon",
+ "setPanel",
+ "setTitle",
+ ]),
+] as const;
+
+export const getRawCapability = (path: string): RawCapabilityEntry | undefined =>
+ RAW_CAPABILITY_COVERAGE.find(entry => entry.path === path);
diff --git a/src/testing/event.test.ts b/src/testing/event.test.ts
new file mode 100644
index 0000000..3ce249b
--- /dev/null
+++ b/src/testing/event.test.ts
@@ -0,0 +1,134 @@
+import {createBrowserEvent} from "./event";
+
+describe("createBrowserEvent", () => {
+ test("preserves listener identity and supports idempotent unsubscribe", async () => {
+ const event = createBrowserEvent<[value: string]>();
+ const listener = jest.fn();
+
+ event.api.addListener(listener);
+ event.api.addListener(listener);
+
+ expect(event.listenerCount()).toBe(1);
+ expect(event.api.hasListener(listener)).toBe(true);
+
+ const unsubscribe = event.on(listener);
+ unsubscribe();
+ unsubscribe();
+
+ expect(event.api.hasListener(listener)).toBe(false);
+ await event.emit("ignored");
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ test("starts a snapshot of every listener synchronously and awaits async results", async () => {
+ const event = createBrowserEvent<[value: number]>();
+ const calls: string[] = [];
+ let release: (() => void) | undefined;
+
+ event.api.addListener(value => {
+ calls.push(`first:${value}`);
+ return new Promise(resolve => {
+ release = resolve;
+ });
+ });
+ event.api.addListener(value => {
+ calls.push(`second:${value}`);
+ });
+
+ const emitted = event.emit(2);
+
+ expect(calls).toEqual(["first:2", "second:2"]);
+
+ release?.();
+ await emitted;
+ });
+
+ test("uses an emission snapshot when listeners change during emission", async () => {
+ const event = createBrowserEvent<[]>();
+ const second = jest.fn();
+ const first = jest.fn(() => event.api.removeListener(second));
+
+ event.api.addListener(first);
+ event.api.addListener(second);
+
+ await event.emit();
+ await event.emit();
+
+ expect(first).toHaveBeenCalledTimes(2);
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
+ test("assimilates arbitrary thenables", async () => {
+ const event = createBrowserEvent<[]>();
+ const failure = new Error("thenable failed");
+
+ event.api.addListener(() => ({
+ // biome-ignore lint/suspicious/noThenProperty: this intentionally models a non-Promise thenable.
+ then(_resolve: (value: unknown) => void, reject: (reason: unknown) => void) {
+ reject(failure);
+ },
+ }));
+
+ await expect(event.emit()).rejects.toBe(failure);
+ });
+
+ test("runs every listener before rethrowing one failure", async () => {
+ const event = createBrowserEvent<[]>();
+ const failure = new Error("listener failed");
+ const remaining = jest.fn();
+
+ event.api.addListener(() => {
+ throw failure;
+ });
+ event.api.addListener(remaining);
+
+ await expect(event.emit()).rejects.toBe(failure);
+ expect(remaining).toHaveBeenCalledTimes(1);
+ });
+
+ test("aggregates multiple failures after all listeners settle", async () => {
+ const event = createBrowserEvent<[]>();
+ const first = new Error("first");
+ const second = new Error("second");
+
+ event.api.addListener(() => {
+ throw first;
+ });
+ event.api.addListener(() => Promise.reject(second));
+
+ try {
+ await event.emit();
+ throw new Error("Expected emit to reject");
+ } catch (error) {
+ expect(error).toBeInstanceOf(AggregateError);
+ expect((error as AggregateError).errors).toEqual([first, second]);
+ }
+ });
+
+ test("isolates instances and resets listeners", () => {
+ const first = createBrowserEvent<[]>();
+ const second = createBrowserEvent<[]>();
+ const listener = jest.fn();
+
+ first.api.addListener(listener);
+ second.api.addListener(listener);
+ first.reset();
+
+ expect(first.listenerCount()).toBe(0);
+ expect(second.listenerCount()).toBe(1);
+ });
+
+ test("records optional registration arguments", () => {
+ const event = createBrowserEvent<[value: string], [filter: {url: string}]>();
+ const listener = jest.fn();
+ const filter = {url: "https://example.com"};
+
+ event.api.addListener(listener, filter);
+
+ expect(event.registrations()).toEqual([{listener, args: [filter]}]);
+ expect(Object.isFrozen(event.registrations())).toBe(true);
+
+ event.api.removeListener(listener);
+ expect(event.registrations()).toEqual([]);
+ });
+});
diff --git a/src/testing/event.ts b/src/testing/event.ts
new file mode 100644
index 0000000..433baa5
--- /dev/null
+++ b/src/testing/event.ts
@@ -0,0 +1,113 @@
+export type BrowserEventListener = (...args: TArgs) => unknown;
+
+export interface BrowserEventApi<
+ TArgs extends readonly unknown[],
+ TRegistrationArgs extends readonly unknown[] = readonly unknown[],
+> {
+ addListener(listener: BrowserEventListener, ...registrationArgs: TRegistrationArgs): void;
+ removeListener(listener: BrowserEventListener): void;
+ hasListener(listener: BrowserEventListener): boolean;
+}
+
+export interface BrowserEventRegistration<
+ TArgs extends readonly unknown[],
+ TRegistrationArgs extends readonly unknown[] = readonly unknown[],
+> {
+ readonly listener: BrowserEventListener;
+ readonly args: TRegistrationArgs;
+}
+
+export interface BrowserEventHarness<
+ TArgs extends readonly unknown[],
+ TRegistrationArgs extends readonly unknown[] = readonly unknown[],
+> {
+ readonly api: BrowserEventApi;
+ on(listener: BrowserEventListener, ...registrationArgs: TRegistrationArgs): () => void;
+ emit(...args: TArgs): Promise;
+ listenerCount(): number;
+ registrations(): readonly BrowserEventRegistration[];
+ reset(): void;
+}
+
+/**
+ * Creates an isolated WebExtension-style event with explicit emission controls.
+ */
+export function createBrowserEvent<
+ TArgs extends readonly unknown[],
+ TRegistrationArgs extends readonly unknown[] = readonly unknown[],
+>(): BrowserEventHarness {
+ const listeners = new Set>();
+ const registrationArgs = new Map, TRegistrationArgs>();
+
+ const api: BrowserEventApi = {
+ addListener(listener, ...args) {
+ listeners.add(listener);
+ registrationArgs.set(listener, args);
+ },
+ removeListener(listener) {
+ listeners.delete(listener);
+ registrationArgs.delete(listener);
+ },
+ hasListener(listener) {
+ return listeners.has(listener);
+ },
+ };
+
+ return {
+ api,
+ on(listener, ...args) {
+ api.addListener(listener, ...args);
+
+ let subscribed = true;
+
+ return () => {
+ if (!subscribed) {
+ return;
+ }
+
+ subscribed = false;
+ api.removeListener(listener);
+ };
+ },
+ async emit(...args) {
+ const pending = [...listeners].map(listener => {
+ try {
+ return Promise.resolve(listener(...args));
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+ const outcomes = await Promise.allSettled(pending);
+ const errors = outcomes
+ .filter((outcome): outcome is PromiseRejectedResult => outcome.status === "rejected")
+ .map(outcome => outcome.reason);
+
+ if (errors.length === 1) {
+ throw errors[0];
+ }
+
+ if (errors.length > 1) {
+ throw new AggregateError(errors, "Multiple browser event listeners failed");
+ }
+ },
+ listenerCount() {
+ return listeners.size;
+ },
+ registrations() {
+ return Object.freeze(
+ [...listeners].map(listener =>
+ Object.freeze({
+ listener,
+ args: Object.freeze([
+ ...(registrationArgs.get(listener) ?? []),
+ ]) as unknown as TRegistrationArgs,
+ })
+ )
+ );
+ },
+ reset() {
+ listeners.clear();
+ registrationArgs.clear();
+ },
+ };
+}
diff --git a/src/testing/fixtures.test.ts b/src/testing/fixtures.test.ts
new file mode 100644
index 0000000..9caf571
--- /dev/null
+++ b/src/testing/fixtures.test.ts
@@ -0,0 +1,112 @@
+import {
+ createExtensionContextFixture,
+ createInjectionResultFixture,
+ createInstalledDetailsFixture,
+ createManifestFixture,
+ createMessageSenderFixture,
+ createPermissionsFixture,
+ createTabFixture,
+ createWindowFixture,
+} from "./fixtures";
+
+describe("testing fixtures", () => {
+ test("creates deterministic manifests and clones nested overrides", () => {
+ const permissions: chrome.runtime.ManifestPermission[] = ["tabs"];
+ const first = createManifestFixture({name: "Overridden", permissions});
+ const second = createManifestFixture({name: "Overridden", permissions});
+
+ expect(first).toMatchObject({manifest_version: 3, name: "Overridden", version: "1.0.0"});
+ expect(first).not.toBe(second);
+ expect(first.permissions).not.toBe(permissions);
+ expect(first.permissions).not.toBe(second.permissions);
+ });
+
+ test("creates a valid fresh tab with cloned nested state", () => {
+ const mutedInfo: chrome.tabs.MutedInfo = {muted: true, reason: "user"};
+ const first = createTabFixture({id: 7, active: false, mutedInfo});
+ const second = createTabFixture({id: 7, active: false, mutedInfo});
+
+ expect(first).toMatchObject({
+ id: 7,
+ index: 0,
+ windowId: 1,
+ active: false,
+ highlighted: true,
+ pinned: false,
+ frozen: false,
+ discarded: false,
+ groupId: -1,
+ });
+ expect(first).not.toBe(second);
+ expect(first.mutedInfo).not.toBe(mutedInfo);
+ expect(first.mutedInfo).not.toBe(second.mutedInfo);
+ });
+
+ test("creates a fresh window and clones tabs", () => {
+ const tab = createTabFixture();
+ const first = createWindowFixture({id: 3, tabs: [tab]});
+ const second = createWindowFixture({id: 3, tabs: [tab]});
+
+ expect(first).toMatchObject({id: 3, focused: true, alwaysOnTop: false, incognito: false});
+ expect(first.tabs).not.toBe(second.tabs);
+ expect(first.tabs?.[0]).not.toBe(tab);
+ expect(first.tabs?.[0]).not.toBe(second.tabs?.[0]);
+ });
+
+ test("creates fresh permission arrays", () => {
+ const permissions = ["tabs"] as chrome.runtime.ManifestPermission[];
+ const origins = ["https://example.com/*"];
+ const first = createPermissionsFixture({permissions, origins});
+ const second = createPermissionsFixture({permissions, origins});
+
+ expect(first).toEqual({permissions, origins});
+ expect(first.permissions).not.toBe(permissions);
+ expect(first.permissions).not.toBe(second.permissions);
+ expect(first.origins).not.toBe(origins);
+ expect(first.origins).not.toBe(second.origins);
+ });
+
+ test("creates installed details with an install reason", () => {
+ expect(createInstalledDetailsFixture()).toEqual({reason: "install"});
+ expect(createInstalledDetailsFixture({reason: "update", previousVersion: "0.9.0"})).toEqual({
+ reason: "update",
+ previousVersion: "0.9.0",
+ });
+ });
+
+ test("creates message senders and clones overridden tabs", () => {
+ const tab = createTabFixture();
+ const sender = createMessageSenderFixture({tab});
+
+ expect(sender).toMatchObject({
+ id: "test-extension-id",
+ origin: "chrome-extension://test-extension-id",
+ url: "chrome-extension://test-extension-id/background.html",
+ });
+ expect(sender.tab).not.toBe(tab);
+ });
+
+ test("creates deterministic extension contexts", () => {
+ const context = createExtensionContextFixture({contextType: "OFFSCREEN_DOCUMENT", documentId: "document-2"});
+
+ expect(context).toEqual({
+ contextId: "test-context-id",
+ contextType: "OFFSCREEN_DOCUMENT",
+ documentId: "document-2",
+ frameId: -1,
+ incognito: false,
+ tabId: -1,
+ windowId: -1,
+ });
+ });
+
+ test("creates typed generic injection results and clones result objects", () => {
+ const result = {value: 15, items: ["first"]};
+ const injection = createInjectionResultFixture({frameId: 2, result});
+ const typed: chrome.scripting.InjectionResult<{value: number; items: string[]}> = injection;
+
+ expect(typed).toEqual({documentId: "test-document-id", frameId: 2, result});
+ expect(typed.result).not.toBe(result);
+ expect(typed.result?.items).not.toBe(result.items);
+ });
+});
diff --git a/src/testing/fixtures.ts b/src/testing/fixtures.ts
new file mode 100644
index 0000000..23abca1
--- /dev/null
+++ b/src/testing/fixtures.ts
@@ -0,0 +1,134 @@
+const TEST_EXTENSION_ID = "test-extension-id";
+const TEST_EXTENSION_ORIGIN = `chrome-extension://${TEST_EXTENSION_ID}`;
+
+type FixtureOverrides = Readonly>;
+
+function cloneFixtureValue(value: T): T {
+ if (Array.isArray(value)) {
+ return value.map(item => cloneFixtureValue(item)) as T;
+ }
+
+ if (value !== null && typeof value === "object") {
+ const prototype = Object.getPrototypeOf(value);
+
+ if (prototype === Object.prototype || prototype === null) {
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneFixtureValue(item)])) as T;
+ }
+ }
+
+ return value;
+}
+
+function createFixture(defaults: T, overrides: FixtureOverrides): T {
+ return cloneFixtureValue({...defaults, ...overrides});
+}
+
+export function createManifestFixture(
+ overrides: FixtureOverrides = {}
+): chrome.runtime.Manifest {
+ return createFixture(
+ {
+ manifest_version: 3,
+ name: "Test Extension",
+ version: "1.0.0",
+ },
+ overrides
+ );
+}
+
+export function createTabFixture(overrides: FixtureOverrides = {}): chrome.tabs.Tab {
+ return createFixture(
+ {
+ id: 1,
+ index: 0,
+ windowId: 1,
+ active: true,
+ selected: true,
+ highlighted: true,
+ pinned: false,
+ frozen: false,
+ incognito: false,
+ discarded: false,
+ autoDiscardable: true,
+ groupId: -1,
+ status: "complete",
+ title: "Test Tab",
+ url: `${TEST_EXTENSION_ORIGIN}/index.html`,
+ },
+ overrides
+ );
+}
+
+export function createWindowFixture(overrides: FixtureOverrides = {}): chrome.windows.Window {
+ return createFixture(
+ {
+ id: 1,
+ focused: true,
+ alwaysOnTop: false,
+ incognito: false,
+ state: "normal",
+ type: "normal",
+ tabs: [],
+ },
+ overrides
+ );
+}
+
+export function createPermissionsFixture(
+ overrides: FixtureOverrides = {}
+): chrome.permissions.Permissions {
+ return createFixture(
+ {
+ permissions: [],
+ origins: [],
+ },
+ overrides
+ );
+}
+
+export function createInstalledDetailsFixture(
+ overrides: FixtureOverrides = {}
+): chrome.runtime.InstalledDetails {
+ return createFixture({reason: "install"}, overrides);
+}
+
+export function createMessageSenderFixture(
+ overrides: FixtureOverrides = {}
+): chrome.runtime.MessageSender {
+ return createFixture(
+ {
+ id: TEST_EXTENSION_ID,
+ origin: TEST_EXTENSION_ORIGIN,
+ url: `${TEST_EXTENSION_ORIGIN}/background.html`,
+ },
+ overrides
+ );
+}
+
+export function createExtensionContextFixture(
+ overrides: FixtureOverrides = {}
+): chrome.runtime.ExtensionContext {
+ return createFixture(
+ {
+ contextId: "test-context-id",
+ contextType: "BACKGROUND",
+ frameId: -1,
+ incognito: false,
+ tabId: -1,
+ windowId: -1,
+ },
+ overrides
+ );
+}
+
+export function createInjectionResultFixture(
+ overrides: FixtureOverrides> = {}
+): chrome.scripting.InjectionResult {
+ return createFixture>(
+ {
+ documentId: "test-document-id",
+ frameId: 0,
+ },
+ overrides
+ );
+}
diff --git a/src/testing/globals.integration.test.ts b/src/testing/globals.integration.test.ts
new file mode 100644
index 0000000..ed4eb87
--- /dev/null
+++ b/src/testing/globals.integration.test.ts
@@ -0,0 +1,276 @@
+import {BrowserGuessSource, BrowserName, guessBrowser} from "../browserDetection";
+import {getI18nUILanguage} from "../i18n";
+import {getId} from "../runtime";
+import {canOpenSidebar, getSidebarTitle, openSidebar, SidebarError, setSidebarTitle} from "../sidebar";
+import {onTabCreated} from "../tabs";
+import {createBrowserHarness, createTabFixture, installBrowserGlobals, installGlobals} from "./index";
+
+const restorers: Array<() => void> = [];
+
+afterEach(() => {
+ while (restorers.length > 0) restorers.pop()?.();
+});
+
+describe("transactional browser globals", () => {
+ test("leaves omitted globals untouched while explicit undefined removes one temporarily", () => {
+ const harness = createBrowserHarness();
+ const restoreOuter = installGlobals({browser: harness.browser, chrome: harness.chrome});
+ restorers.push(restoreOuter);
+ const chromeDescriptor = Reflect.getOwnPropertyDescriptor(globalThis, "chrome");
+ const browserDescriptor = Reflect.getOwnPropertyDescriptor(globalThis, "browser");
+ const restoreInner = installGlobals({browser: undefined});
+ restorers.push(restoreInner);
+
+ expect("browser" in globalThis).toBe(false);
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(chromeDescriptor);
+
+ restoreInner();
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "browser")).toEqual(browserDescriptor);
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(chromeDescriptor);
+ });
+
+ test("restores exact descriptors, absent globals, and tolerates repeated restore", () => {
+ const keys = ["chrome", "browser", "opr", "safari", "navigator", "window", "location"] as const;
+ const before = Object.fromEntries(keys.map(key => [key, Reflect.getOwnPropertyDescriptor(globalThis, key)]));
+ const harness = createBrowserHarness();
+ const restore = installBrowserGlobals(harness, {context: "serviceWorker", profile: "firefox"});
+ restorers.push(restore);
+
+ expect(globalThis.browser).toBe(harness.browser);
+ expect(globalThis.chrome).toBe(harness.chrome);
+ expect("window" in globalThis).toBe(false);
+ expect("location" in globalThis).toBe(false);
+
+ restore();
+ restore();
+
+ for (const key of keys) {
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, key)).toEqual(before[key]);
+ }
+ });
+
+ test("distinguishes omitted globals from explicit undefined and restores profile markers", () => {
+ const outerHarness = createBrowserHarness();
+ const outerRestore = installGlobals({
+ browser: outerHarness.browser,
+ chrome: outerHarness.chrome,
+ opr: {},
+ safari: {marker: "original"},
+ });
+ restorers.push(outerRestore);
+ const outerDescriptors = {
+ browser: Reflect.getOwnPropertyDescriptor(globalThis, "browser"),
+ chrome: Reflect.getOwnPropertyDescriptor(globalThis, "chrome"),
+ opr: Reflect.getOwnPropertyDescriptor(globalThis, "opr"),
+ safari: Reflect.getOwnPropertyDescriptor(globalThis, "safari"),
+ };
+ const profileHarness = createBrowserHarness();
+ const restoreProfile = installBrowserGlobals(profileHarness, {profile: "chrome"});
+ restorers.push(restoreProfile);
+
+ expect(globalThis.chrome).toBe(profileHarness.chrome);
+ expect("browser" in globalThis).toBe(false);
+ expect("opr" in globalThis).toBe(false);
+ expect("safari" in globalThis).toBe(false);
+
+ restoreProfile();
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "browser")).toEqual(outerDescriptors.browser);
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(outerDescriptors.chrome);
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "opr")).toEqual(outerDescriptors.opr);
+ expect(Reflect.getOwnPropertyDescriptor(globalThis, "safari")).toEqual(outerDescriptors.safari);
+ });
+});
+
+describe("browser profiles and routing", () => {
+ test("models a content script as a deterministic host page instead of an extension page", () => {
+ const harness = createBrowserHarness();
+ const restoreExtensionPage = installBrowserGlobals(harness, {context: "extensionPage", profile: "chrome"});
+ restorers.push(restoreExtensionPage);
+
+ expect(globalThis.location.pathname).toBe("/index.html");
+ expect(globalThis.location.href).toBeUndefined();
+
+ restoreExtensionPage();
+ const restoreContentScript = installBrowserGlobals(harness, {context: "contentScript", profile: "chrome"});
+ restorers.push(restoreContentScript);
+
+ expect(globalThis.location).toMatchObject({
+ href: "https://example.test/content/page.html",
+ origin: "https://example.test",
+ pathname: "/content/page.html",
+ protocol: "https:",
+ });
+ expect(globalThis.window.location).toBe(globalThis.location);
+ });
+
+ test("routes to browser when runtime.id exists and falls back to chrome when it does not", () => {
+ const harness = createBrowserHarness();
+ harness.configurable.chrome.i18n.getUILanguage.setResult("chrome-language");
+ harness.configurable.browser.i18n.getUILanguage.setResult("browser-language");
+ const restoreFirefox = installBrowserGlobals(harness, {profile: "firefox"});
+ restorers.push(restoreFirefox);
+
+ expect(getI18nUILanguage()).toBe("browser-language");
+
+ restoreFirefox();
+ const browserWithoutRuntimeId = harness.createProfileFacade("browser", true);
+ Reflect.deleteProperty(browserWithoutRuntimeId.runtime, "id");
+ const restoreFallback = installBrowserGlobals(harness, {
+ globals: {browser: browserWithoutRuntimeId, chrome: harness.chrome},
+ profile: "custom",
+ });
+ restorers.push(restoreFallback);
+
+ expect(getI18nUILanguage()).toBe("chrome-language");
+ });
+
+ test("throws a clear production error when neither namespace is available", () => {
+ const harness = createBrowserHarness();
+ restorers.push(
+ installBrowserGlobals(harness, {
+ globals: {browser: undefined, chrome: undefined},
+ profile: "custom",
+ })
+ );
+
+ expect(() => getId()).toThrow("WebExtension API not available in this context");
+ });
+
+ test("removes a disabled method from the already-installed facade and changes detection fallback", async () => {
+ const harness = createBrowserHarness();
+ restorers.push(installBrowserGlobals(harness, {profile: "firefox"}));
+
+ expect(typeof globalThis.browser?.runtime.getBrowserInfo).toBe("function");
+ harness.capabilities.set("runtime.getBrowserInfo", false);
+ expect("getBrowserInfo" in (globalThis.browser?.runtime ?? {})).toBe(false);
+
+ await expect(guessBrowser()).resolves.toMatchObject({
+ name: BrowserName.Firefox,
+ source: BrowserGuessSource.UserAgent,
+ });
+
+ harness.reset();
+ expect(typeof globalThis.browser?.runtime.getBrowserInfo).toBe("function");
+ });
+
+ test.each([
+ ["chrome", true, false, false],
+ ["firefox", false, true, false],
+ ["opera", false, false, true],
+ ["safari", false, false, false],
+ ] as const)("%s installs only its coherent sidebar flavor", (profile, hasSidePanel, hasFirefoxSidebar, hasOperaSidebar) => {
+ const harness = createBrowserHarness();
+ const restore = installBrowserGlobals(harness, {profile});
+
+ try {
+ expect(Boolean(globalThis.chrome?.sidePanel)).toBe(hasSidePanel);
+ expect(Boolean(globalThis.browser?.sidebarAction)).toBe(hasFirefoxSidebar);
+ expect(Boolean(globalThis.opr?.sidebarAction)).toBe(hasOperaSidebar);
+ expect(canOpenSidebar()).toBe(hasSidePanel || hasFirefoxSidebar);
+ } finally {
+ restore();
+ }
+ });
+
+ test("executes Chrome, Firefox, and Opera sidebar methods with their real invocation styles", async () => {
+ const chromeHarness = createBrowserHarness();
+ chromeHarness.sidebar.sidePanel.open.setResult(undefined);
+ const restoreChrome = installBrowserGlobals(chromeHarness, {profile: "chrome"});
+ restorers.push(restoreChrome);
+ await expect(openSidebar({windowId: 1})).resolves.toBeUndefined();
+ expect(chromeHarness.sidebar.sidePanel.open.calls[0]).toMatchObject({invocation: "callback"});
+ restoreChrome();
+
+ const firefoxHarness = createBrowserHarness();
+ firefoxHarness.sidebar.firefox.open.setResult(undefined);
+ const restoreFirefox = installBrowserGlobals(firefoxHarness, {profile: "firefox"});
+ restorers.push(restoreFirefox);
+ await expect(openSidebar({windowId: 1})).resolves.toBeUndefined();
+ expect(firefoxHarness.sidebar.firefox.open.calls[0]).toMatchObject({invocation: "promise"});
+ restoreFirefox();
+
+ const operaHarness = createBrowserHarness();
+ operaHarness.sidebar.opera.setTitle.setResult(undefined);
+ operaHarness.sidebar.opera.getTitle.setResult("Opera title");
+ restorers.push(installBrowserGlobals(operaHarness, {profile: "opera"}));
+
+ await expect(setSidebarTitle("Configured title", 4)).resolves.toBeUndefined();
+ await expect(getSidebarTitle(4)).resolves.toBe("Opera title");
+ expect(operaHarness.sidebar.opera.setTitle.calls[0]).toMatchObject({
+ args: [{tabId: 4, title: "Configured title"}],
+ callback: undefined,
+ invocation: "sync",
+ });
+ expect(operaHarness.sidebar.opera.getTitle.calls[0]).toMatchObject({invocation: "callback"});
+ });
+
+ test("none flavor exposes the real SidebarError path", async () => {
+ const harness = createBrowserHarness();
+ harness.sidebar.flavor = "none";
+ restorers.push(
+ installBrowserGlobals(harness, {
+ globals: {browser: undefined, chrome: harness.chrome, opr: undefined, safari: undefined},
+ profile: "custom",
+ })
+ );
+
+ await expect(openSidebar({windowId: 1})).rejects.toBeInstanceOf(SidebarError);
+ await expect(openSidebar({windowId: 1})).rejects.toThrow(
+ "The sidebarAction.open API is not supported in this browser"
+ );
+ });
+});
+
+describe("raw events and production listener error handling", () => {
+ test("keeps raw errors observable while capturing safeListener behavior", async () => {
+ const forwarded: unknown[][] = [];
+ const restoreConsole = installGlobals({consoleError: (...args) => forwarded.push(args)});
+ restorers.push(restoreConsole);
+ const harness = createBrowserHarness();
+ restorers.push(installBrowserGlobals(harness, {captureListenerErrors: true, profile: "chrome"}));
+ const tab = createTabFixture();
+
+ const rawFailure = new Error("raw listener failed");
+ harness.tabs.events.onCreated.api.addListener(() => {
+ throw rawFailure;
+ });
+ await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(rawFailure);
+
+ harness.tabs.events.onCreated.reset();
+ const syncFailure = new Error("sync listener failed");
+ const unsubscribeSync = onTabCreated(() => {
+ throw syncFailure;
+ });
+ await expect(harness.tabs.events.onCreated.emit(tab)).resolves.toBeUndefined();
+ unsubscribeSync();
+ expect(harness.listenerErrors.entries).toEqual([{args: [], error: syncFailure, kind: "sync"}]);
+
+ harness.tabs.events.onCreated.reset();
+ harness.listenerErrors.reset();
+ const promiseFailure = new Error("promise listener failed");
+ const unsubscribePromise = onTabCreated(async () => {
+ throw promiseFailure;
+ });
+ await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(promiseFailure);
+ unsubscribePromise();
+ expect(harness.listenerErrors.entries).toEqual([{args: [], error: promiseFailure, kind: "promise"}]);
+
+ harness.tabs.events.onCreated.reset();
+ harness.listenerErrors.reset();
+ const thenableFailure = new Error("custom thenable failed");
+ const thenable = {
+ // biome-ignore lint/suspicious/noThenProperty: This intentionally models a non-Promise thenable.
+ then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void {
+ reject(thenableFailure);
+ },
+ };
+ const unsubscribeThenable = onTabCreated((() => thenable) as unknown as Parameters[0]);
+ await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(thenableFailure);
+ unsubscribeThenable();
+ expect(harness.listenerErrors.entries).toEqual([]);
+
+ console.error("unrecognized listener output", 7);
+ expect(harness.listenerErrors.raw).toEqual([["unrecognized listener output", 7]]);
+ expect(forwarded).toEqual([["unrecognized listener output", 7]]);
+ });
+});
diff --git a/src/testing/globals.ts b/src/testing/globals.ts
new file mode 100644
index 0000000..3573db5
--- /dev/null
+++ b/src/testing/globals.ts
@@ -0,0 +1,238 @@
+import {type BrowserHarness, sidebarDefaultForProfile} from "./harness";
+import type {BrowserProfile, BrowserTestApi, ExtensionContextKind} from "./types";
+
+export interface NavigatorTestValue extends Partial {
+ brave?: {
+ isBrave?: () => boolean | Promise;
+ };
+ userAgentData?: {
+ brands?: Array<{brand: string; version: string}>;
+ getHighEntropyValues?: (hints: string[]) => Promise<{
+ brands?: Array<{brand: string; version: string}>;
+ fullVersionList?: Array<{brand: string; version: string}>;
+ }>;
+ };
+}
+
+export type WindowTestValue = Partial;
+export type LocationTestValue = Partial;
+
+export interface TestGlobalValues {
+ chrome?: BrowserTestApi | undefined;
+ browser?: BrowserTestApi | undefined;
+ opr?: {sidebarAction?: Partial} | undefined;
+ safari?: object | undefined;
+ navigator?: NavigatorTestValue | undefined;
+ window?: WindowTestValue | undefined;
+ location?: LocationTestValue | undefined;
+ consoleError?: ((...args: unknown[]) => void) | undefined;
+}
+
+interface DescriptorChange {
+ key: PropertyKey;
+ descriptor: PropertyDescriptor | undefined;
+ target: object;
+}
+
+const applyDescriptor = (target: object, key: PropertyKey, value: unknown): void => {
+ if (typeof value === "undefined") {
+ if (!Reflect.deleteProperty(target, key)) {
+ throw new Error(`Unable to remove global ${String(key)}`);
+ }
+ return;
+ }
+
+ if (
+ !Reflect.defineProperty(target, key, {
+ configurable: true,
+ enumerable: true,
+ value,
+ writable: true,
+ })
+ ) {
+ throw new Error(`Unable to install global ${String(key)}`);
+ }
+};
+
+const restoreChanges = (changes: readonly DescriptorChange[]): void => {
+ for (const {descriptor, key, target} of [...changes].reverse()) {
+ if (descriptor) {
+ Reflect.defineProperty(target, key, descriptor);
+ } else {
+ Reflect.deleteProperty(target, key);
+ }
+ }
+};
+
+/** Installs only own properties present in `values` and restores their exact descriptors. */
+export const installGlobals = (values: TestGlobalValues): (() => void) => {
+ const changes: DescriptorChange[] = [];
+
+ try {
+ for (const key of ["chrome", "browser", "opr", "safari", "navigator", "window", "location"] as const) {
+ if (!Object.hasOwn(values, key)) continue;
+
+ changes.push({descriptor: Reflect.getOwnPropertyDescriptor(globalThis, key), key, target: globalThis});
+ applyDescriptor(globalThis, key, values[key]);
+ }
+
+ if (Object.hasOwn(values, "consoleError")) {
+ changes.push({
+ descriptor: Reflect.getOwnPropertyDescriptor(console, "error"),
+ key: "error",
+ target: console,
+ });
+ applyDescriptor(console, "error", values.consoleError);
+ }
+ } catch (error) {
+ restoreChanges(changes);
+ throw error;
+ }
+
+ let restored = false;
+
+ return (): void => {
+ if (restored) return;
+ restored = true;
+ restoreChanges(changes);
+ };
+};
+
+export interface ContextGlobals {
+ location: LocationTestValue | undefined;
+ window: WindowTestValue | undefined;
+}
+
+export const createContextGlobals = (kind: ExtensionContextKind): ContextGlobals => {
+ if (kind === "serviceWorker" || kind === "none") {
+ return {location: undefined, window: undefined};
+ }
+
+ const location =
+ kind === "contentScript"
+ ? ({
+ hash: "",
+ host: "example.test",
+ hostname: "example.test",
+ href: "https://example.test/content/page.html",
+ origin: "https://example.test",
+ pathname: "/content/page.html",
+ port: "",
+ protocol: "https:",
+ search: "",
+ } satisfies LocationTestValue)
+ : ({
+ pathname: kind === "backgroundPage" ? "/_generated_background_page.html" : "/index.html",
+ } satisfies LocationTestValue);
+
+ return {
+ location,
+ window: {location: location as Location} as WindowTestValue,
+ };
+};
+
+export interface InstallBrowserGlobalsOptions {
+ profile?: BrowserProfile;
+ context?: ExtensionContextKind;
+ captureListenerErrors?: boolean;
+ /** Required to express non-standard namespace combinations with the custom profile. */
+ globals?: TestGlobalValues;
+}
+
+const chromeNavigator = (): NavigatorTestValue => ({
+ userAgent:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
+ userAgentData: {
+ brands: [
+ {brand: "Chromium", version: "126"},
+ {brand: "Google Chrome", version: "126"},
+ ],
+ },
+});
+
+const firefoxNavigator = (): NavigatorTestValue => ({
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0",
+});
+
+const operaNavigator = (): NavigatorTestValue => ({
+ userAgent:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36 OPR/112.0.0.0",
+ userAgentData: {
+ brands: [
+ {brand: "Chromium", version: "126"},
+ {brand: "Opera", version: "112"},
+ ],
+ },
+});
+
+const safariNavigator = (): NavigatorTestValue => ({
+ userAgent:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
+});
+
+const profileGlobals = (harness: BrowserHarness, profile: Exclude): TestGlobalValues => {
+ switch (profile) {
+ case "chrome":
+ return {
+ browser: undefined,
+ chrome: harness.chrome,
+ navigator: chromeNavigator(),
+ opr: undefined,
+ safari: undefined,
+ };
+ case "firefox":
+ return {
+ browser: harness.browser,
+ chrome: harness.chrome,
+ navigator: firefoxNavigator(),
+ opr: undefined,
+ safari: undefined,
+ };
+ case "opera":
+ return {
+ browser: undefined,
+ chrome: harness.chrome,
+ navigator: operaNavigator(),
+ opr: Object.defineProperty({}, "sidebarAction", {
+ configurable: true,
+ enumerable: true,
+ get: () => harness.getOperaSidebarAction(),
+ }),
+ safari: undefined,
+ };
+ case "safari":
+ return {
+ browser: harness.browser,
+ chrome: harness.chrome,
+ navigator: safariNavigator(),
+ opr: undefined,
+ safari: {},
+ };
+ }
+};
+
+/** Installs a coherent browser profile. Importing this module never mutates globals by itself. */
+export const installBrowserGlobals = (
+ harness: BrowserHarness,
+ options: InstallBrowserGlobalsOptions = {}
+): (() => void) => {
+ const profile = options.profile ?? "chrome";
+ const context = options.context ?? "extensionPage";
+
+ harness.setActiveProfile(profile);
+ harness.setProfileSidebarFlavor(sidebarDefaultForProfile(profile));
+ if (profile !== "custom") {
+ harness.setProfileCapability("runtime.getBrowserInfo", profile === "firefox");
+ }
+
+ const values: TestGlobalValues =
+ profile === "custom"
+ ? {...createContextGlobals(context), ...(options.globals ?? {})}
+ : {...profileGlobals(harness, profile), ...createContextGlobals(context), ...(options.globals ?? {})};
+
+ if (options.captureListenerErrors) {
+ values.consoleError = harness.getListenerErrorHandler(console.error);
+ }
+
+ return installGlobals(values);
+};
diff --git a/src/testing/harness.test.ts b/src/testing/harness.test.ts
new file mode 100644
index 0000000..90ce9d4
--- /dev/null
+++ b/src/testing/harness.test.ts
@@ -0,0 +1,69 @@
+import {installBrowserGlobals, installGlobals} from "./globals";
+import {createBrowserHarness} from "./harness";
+
+describe("browser profiles and globals", () => {
+ test("keeps Firefox facades live after installation and restores exact descriptors", () => {
+ const browserDescriptor = Object.getOwnPropertyDescriptor(globalThis, "browser");
+ const chromeDescriptor = Object.getOwnPropertyDescriptor(globalThis, "chrome");
+ const harness = createBrowserHarness({extensionId: "initial-id"});
+ const restore = installBrowserGlobals(harness, {context: "serviceWorker", profile: "firefox"});
+
+ expect(globalThis.browser).toBe(harness.browser);
+ expect(globalThis.chrome).toBe(harness.chrome);
+ expect(globalThis.window).toBeUndefined();
+ expect(globalThis.location).toBeUndefined();
+ expect(globalThis.browser.sidebarAction).toBeDefined();
+
+ harness.runtime.setExtensionId("changed-id");
+ expect(globalThis.browser.runtime.id).toBe("changed-id");
+
+ harness.capabilities.set("runtime.getBrowserInfo", false);
+ expect("getBrowserInfo" in globalThis.browser.runtime).toBe(false);
+ harness.capabilities.set("runtime.getBrowserInfo", true);
+ expect(typeof globalThis.browser.runtime.getBrowserInfo).toBe("function");
+
+ harness.sidebar.flavor = "none";
+ expect(globalThis.browser.sidebarAction).toBeUndefined();
+
+ restore();
+ restore();
+ expect(Object.getOwnPropertyDescriptor(globalThis, "browser")).toEqual(browserDescriptor);
+ expect(Object.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(chromeDescriptor);
+ });
+
+ test.each([
+ ["chrome", false, false, false],
+ ["opera", false, true, false],
+ ["safari", true, false, true],
+ ] as const)("installs a coherent %s profile", (profile, hasBrowser, hasOpera, hasSafari) => {
+ const harness = createBrowserHarness();
+ const restore = installBrowserGlobals(harness, {profile});
+
+ expect(typeof globalThis.browser !== "undefined").toBe(hasBrowser);
+ expect(typeof globalThis.opr !== "undefined").toBe(hasOpera);
+ expect(typeof globalThis.safari !== "undefined").toBe(hasSafari);
+ expect(globalThis.window?.location).toBe(globalThis.location);
+
+ restore();
+ });
+
+ test("captures only known listener errors and forwards unknown console errors", () => {
+ const forwarded: unknown[][] = [];
+ const harness = createBrowserHarness();
+ const restoreConsole = installGlobals({consoleError: (...args) => forwarded.push(args)});
+ const restore = installBrowserGlobals(harness, {captureListenerErrors: true});
+ const restoreNested = installBrowserGlobals(harness, {captureListenerErrors: true});
+ const syncError = new Error("sync failure");
+
+ console.error("Listener error:", syncError);
+ console.error("unrelated", 42);
+
+ expect(harness.listenerErrors.entries).toEqual([{args: [], error: syncError, kind: "sync"}]);
+ expect(harness.listenerErrors.raw).toEqual([["unrelated", 42]]);
+ expect(forwarded).toEqual([["unrelated", 42]]);
+
+ restoreNested();
+ restore();
+ restoreConsole();
+ });
+});
diff --git a/src/testing/harness.ts b/src/testing/harness.ts
new file mode 100644
index 0000000..320e0ce
--- /dev/null
+++ b/src/testing/harness.ts
@@ -0,0 +1,350 @@
+import {createBrowserMemoryState} from "./browser-state";
+import {
+ type ConfigurableBrowserControls,
+ type ConfigurableNamespaces,
+ createConfigurableNamespaces,
+} from "./configurable";
+import {createLastErrorController} from "./internal";
+import {createListenerErrorCapture, type ListenerErrorBuffer} from "./listener-errors";
+import {createPermissionsHarness, type PermissionsHarness} from "./permissions";
+import {createRuntimeHarness, type RuntimeHarness} from "./runtime";
+import {createScriptingHarness, type ScriptingHarness} from "./scripting";
+import {createTabsHarness, type TabsHarness} from "./tabs";
+import {createWindowsHarness, type WindowsHarness} from "./windows";
+import type {BrowserMethodCall} from "./method";
+import type {
+ BrowserHarnessCall,
+ BrowserProfile,
+ BrowserTestApi,
+ OperaSidebarActionTestApi,
+ SidebarFlavor,
+} from "./types";
+
+export interface BrowserHarnessOptions {
+ extensionId?: string;
+ manifest?: chrome.runtime.Manifest;
+ permissions?: chrome.permissions.Permissions;
+ contexts?: readonly chrome.runtime.ExtensionContext[];
+ messageSender?: chrome.runtime.MessageSender;
+ tabs?: readonly chrome.tabs.Tab[];
+ windows?: readonly chrome.windows.Window[];
+ registeredContentScripts?: readonly chrome.scripting.RegisteredContentScript[];
+}
+
+export interface BrowserCapabilitiesHarness {
+ set(path: string, enabled: boolean): void;
+ has(path: string): boolean;
+}
+
+export interface SidebarHarness {
+ flavor: SidebarFlavor;
+ readonly sidePanel: ConfigurableBrowserControls["sidePanel"];
+ readonly firefox: ConfigurableBrowserControls["sidebarAction"];
+ readonly opera: ConfigurableBrowserControls["operaSidebarAction"];
+}
+
+export interface ConfigurableHarness {
+ readonly chrome: ConfigurableBrowserControls;
+ readonly browser: ConfigurableBrowserControls;
+ readonly active: ConfigurableBrowserControls;
+ readonly chromeNamespaces: ConfigurableNamespaces;
+ readonly browserNamespaces: ConfigurableNamespaces;
+}
+
+export interface BrowserHarness {
+ readonly chrome: BrowserTestApi;
+ readonly browser: BrowserTestApi;
+ readonly runtime: RuntimeHarness;
+ readonly permissions: PermissionsHarness;
+ readonly tabs: TabsHarness;
+ readonly windows: WindowsHarness;
+ readonly scripting: ScriptingHarness;
+ readonly configurable: ConfigurableHarness;
+ readonly capabilities: BrowserCapabilitiesHarness;
+ readonly sidebar: SidebarHarness;
+ readonly listenerErrors: ListenerErrorBuffer;
+ readonly calls: readonly BrowserHarnessCall[];
+ reset(): void;
+ /** @internal Used by the profile installer. */
+ setActiveProfile(profile: BrowserProfile): void;
+ /** @internal Used by the profile installer without overriding an explicit flavor. */
+ setProfileSidebarFlavor(flavor: SidebarFlavor): void;
+ /** @internal Applies a profile default unless the consumer explicitly changed the capability. */
+ setProfileCapability(path: string, enabled: boolean): void;
+ /** @internal Browser-profile view without changing the harness facades. */
+ createProfileFacade(facade: "chrome" | "browser", includeBrowserInfo: boolean): BrowserTestApi;
+ /** @internal Opera global for the currently selected flavor. */
+ getOperaSidebarAction(): OperaSidebarActionTestApi | undefined;
+ /** @internal Handler installed only when listener capture is requested. */
+ getListenerErrorHandler(forward?: (...args: unknown[]) => void): (...args: unknown[]) => void;
+}
+
+interface NamedMethodCalls {
+ namespace: string;
+ source: Record;
+}
+
+const methodCalls = ({namespace, source}: NamedMethodCalls): BrowserHarnessCall[] =>
+ Object.entries(source).flatMap(([member, control]) => {
+ if (!control || typeof control !== "object" || !("calls" in control)) return [];
+ return (control as {calls: readonly BrowserMethodCall[]}).calls.map(call => ({
+ ...call,
+ api: `${namespace}.${member}`,
+ }));
+ });
+
+const cloneFacade = (api: BrowserTestApi): BrowserTestApi => {
+ const copy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(api)) as BrowserTestApi;
+ for (const [namespace, value] of Object.entries(api)) {
+ if (value && typeof value === "object") {
+ const namespaceCopy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(value));
+ Reflect.set(copy as object, namespace, namespaceCopy);
+ }
+ }
+ return copy;
+};
+
+const sidebarDefaultForProfile = (profile: BrowserProfile): SidebarFlavor => {
+ if (profile === "firefox") return "firefoxSidebarAction";
+ if (profile === "opera") return "operaSidebarAction";
+ if (profile === "chrome") return "sidePanel";
+ return "none";
+};
+
+export const createBrowserHarness = (options: BrowserHarnessOptions = {}): BrowserHarness => {
+ let sequence = 0;
+ const nextSequence = (): number => ++sequence;
+ const lastError = createLastErrorController();
+ const state = createBrowserMemoryState({tabs: options.tabs, windows: options.windows});
+ const configChrome = createConfigurableNamespaces({facade: "chrome", lastError, nextSequence});
+ const configBrowser = createConfigurableNamespaces({facade: "browser", lastError, nextSequence});
+ const runtime = createRuntimeHarness(options, lastError, nextSequence);
+ const permissions = createPermissionsHarness(options.permissions, lastError, nextSequence);
+ const tabs = createTabsHarness(state, lastError, nextSequence);
+ const windows = createWindowsHarness(state, tabs, lastError, nextSequence);
+ const scripting = createScriptingHarness(options.registeredContentScripts, lastError, nextSequence);
+ const listenerCapture = createListenerErrorCapture();
+
+ const mergeDescriptors = (target: object, source: object): void => {
+ Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
+ };
+
+ const chrome = configChrome.api as unknown as BrowserTestApi;
+ const browser = configBrowser.api as unknown as BrowserTestApi;
+ const sidePanelChromeApi = configChrome.api.sidePanel;
+ const sidePanelBrowserApi = configBrowser.api.sidePanel;
+ let activeProfile: BrowserProfile = "chrome";
+ let sidebarFlavor: SidebarFlavor = "sidePanel";
+ let sidebarExplicit = false;
+ const explicitCapabilities = new Set();
+
+ const ownedChrome: Record = {};
+ const ownedBrowser: Record = {};
+
+ const mergeStateful = (): void => {
+ mergeDescriptors(chrome.runtime, runtime.chromeApi);
+ mergeDescriptors(browser.runtime, runtime.browserApi);
+ mergeDescriptors(chrome.permissions, permissions.api);
+ mergeDescriptors(browser.permissions, permissions.api);
+ mergeDescriptors(chrome.tabs, tabs.api);
+ mergeDescriptors(browser.tabs, tabs.api);
+ mergeDescriptors(chrome.windows, windows.api);
+ mergeDescriptors(browser.windows, windows.api);
+ mergeDescriptors(chrome.scripting, scripting.api);
+ mergeDescriptors(browser.scripting, scripting.api);
+ };
+
+ mergeStateful();
+
+ for (const [namespace, chromeNamespace, browserNamespace] of [
+ ["runtime", runtime.chromeApi, runtime.browserApi],
+ ["permissions", permissions.api, permissions.api],
+ ["tabs", tabs.api, tabs.api],
+ ["windows", windows.api, windows.api],
+ ["scripting", scripting.api, scripting.api],
+ ] as const) {
+ for (const [member, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(chromeNamespace))) {
+ ownedChrome[`${namespace}.${member}`] = descriptor;
+ }
+ for (const [member, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(browserNamespace))) {
+ ownedBrowser[`${namespace}.${member}`] = descriptor;
+ }
+ }
+ ownedBrowser["runtime.getBrowserInfo"] = Object.getOwnPropertyDescriptor(
+ runtime.browserApi,
+ "getBrowserInfo"
+ ) as PropertyDescriptor;
+
+ const applySidebarFlavor = (): void => {
+ Reflect.deleteProperty(chrome, "sidePanel");
+ Reflect.deleteProperty(browser, "sidePanel");
+ Reflect.deleteProperty(chrome, "sidebarAction");
+ Reflect.deleteProperty(browser, "sidebarAction");
+
+ if (sidebarFlavor === "sidePanel") {
+ chrome.sidePanel = sidePanelChromeApi;
+ browser.sidePanel = sidePanelBrowserApi;
+ } else if (sidebarFlavor === "firefoxSidebarAction") {
+ browser.sidebarAction = configBrowser.sidebarActionApi;
+ }
+ };
+
+ applySidebarFlavor();
+
+ const setOwnedCapability = (path: string, enabled: boolean): boolean => {
+ if (!(path in ownedChrome) && !(path in ownedBrowser)) return false;
+ const [namespace, member] = path.split(".");
+ const chromeNamespace = (chrome as unknown as Record>)[namespace];
+ const browserNamespace = (browser as unknown as Record>)[namespace];
+ if (enabled) {
+ if (path in ownedChrome) Object.defineProperty(chromeNamespace, member, ownedChrome[path]);
+ if (path in ownedBrowser) Object.defineProperty(browserNamespace, member, ownedBrowser[path]);
+ } else {
+ Reflect.deleteProperty(chromeNamespace, member);
+ Reflect.deleteProperty(browserNamespace, member);
+ }
+ return true;
+ };
+
+ const applyCapability = (path: string, enabled: boolean): void => {
+ if (setOwnedCapability(path, enabled)) return;
+ let recognized = false;
+ for (const config of [configChrome, configBrowser]) {
+ try {
+ config.setCapability(path, enabled);
+ recognized = true;
+ } catch {
+ // The other facade or a stateful namespace may own this path.
+ }
+ }
+ if (!recognized) throw new Error(`Unknown browser capability "${path}"`);
+ };
+
+ const capabilities: BrowserCapabilitiesHarness = {
+ has(path): boolean {
+ const [namespace, member] = path.split(".");
+ const chromeNamespace = (chrome as unknown as Record | undefined>)[
+ namespace
+ ];
+ const browserNamespace = (browser as unknown as Record | undefined>)[
+ namespace
+ ];
+ return (
+ Boolean(chromeNamespace && member in chromeNamespace) ||
+ Boolean(browserNamespace && member in browserNamespace)
+ );
+ },
+ set(path, enabled): void {
+ applyCapability(path, enabled);
+ explicitCapabilities.add(path);
+ },
+ };
+
+ const sidebar: SidebarHarness = {
+ get flavor() {
+ return sidebarFlavor;
+ },
+ set flavor(value: SidebarFlavor) {
+ sidebarFlavor = value;
+ sidebarExplicit = true;
+ applySidebarFlavor();
+ },
+ sidePanel: configChrome.controls.sidePanel,
+ firefox: configBrowser.controls.sidebarAction,
+ opera: configChrome.controls.operaSidebarAction,
+ };
+
+ const configurable: ConfigurableHarness = {
+ chrome: configChrome.controls,
+ browser: configBrowser.controls,
+ get active() {
+ return activeProfile === "firefox" || activeProfile === "safari"
+ ? configBrowser.controls
+ : configChrome.controls;
+ },
+ chromeNamespaces: configChrome,
+ browserNamespaces: configBrowser,
+ };
+
+ const callSources: NamedMethodCalls[] = [
+ {namespace: "runtime", source: runtime as unknown as Record},
+ {namespace: "permissions", source: permissions as unknown as Record},
+ {namespace: "tabs", source: tabs as unknown as Record},
+ {namespace: "windows", source: windows as unknown as Record},
+ {namespace: "scripting", source: scripting as unknown as Record},
+ ];
+
+ return {
+ chrome,
+ browser,
+ runtime,
+ permissions,
+ tabs,
+ windows,
+ scripting,
+ configurable,
+ capabilities,
+ sidebar,
+ listenerErrors: listenerCapture,
+ get calls() {
+ return [...callSources.flatMap(methodCalls), ...configChrome.calls, ...configBrowser.calls].sort(
+ (left, right) => left.sequence - right.sequence
+ );
+ },
+ createProfileFacade(facade, includeBrowserInfo) {
+ const result = cloneFacade(facade === "chrome" ? chrome : browser);
+ if (!includeBrowserInfo) Reflect.deleteProperty(result.runtime, "getBrowserInfo");
+ return result;
+ },
+ getListenerErrorHandler(forward) {
+ if (forward) listenerCapture.setForward(forward);
+ return listenerCapture.handler;
+ },
+ getOperaSidebarAction() {
+ return sidebarFlavor === "operaSidebarAction"
+ ? (configChrome.operaSidebarActionApi as unknown as OperaSidebarActionTestApi)
+ : undefined;
+ },
+ reset(): void {
+ sequence = 0;
+ state.reset();
+ runtime.reset();
+ if (activeProfile === "firefox") runtime.setUrlScheme("moz-extension");
+ else if (activeProfile === "safari") runtime.setUrlScheme("safari-web-extension");
+ permissions.reset();
+ tabs.reset();
+ windows.reset();
+ scripting.reset();
+ configChrome.reset();
+ configBrowser.reset();
+ lastError.reset();
+ listenerCapture.reset();
+ explicitCapabilities.clear();
+ mergeStateful();
+ for (const path of new Set([...Object.keys(ownedChrome), ...Object.keys(ownedBrowser)])) {
+ setOwnedCapability(path, true);
+ }
+ applyCapability("runtime.getBrowserInfo", activeProfile === "firefox");
+ sidebarExplicit = false;
+ sidebarFlavor = sidebarDefaultForProfile(activeProfile);
+ applySidebarFlavor();
+ },
+ setActiveProfile(profile) {
+ activeProfile = profile;
+ if (profile === "firefox") runtime.setUrlScheme("moz-extension");
+ else if (profile === "safari") runtime.setUrlScheme("safari-web-extension");
+ else if (profile !== "custom") runtime.setUrlScheme("chrome-extension");
+ },
+ setProfileCapability(path, enabled) {
+ if (!explicitCapabilities.has(path)) applyCapability(path, enabled);
+ },
+ setProfileSidebarFlavor(flavor) {
+ if (!sidebarExplicit) {
+ sidebarFlavor = flavor;
+ applySidebarFlavor();
+ }
+ },
+ };
+};
+
+export {sidebarDefaultForProfile};
diff --git a/src/testing/index.ts b/src/testing/index.ts
new file mode 100644
index 0000000..ba5740a
--- /dev/null
+++ b/src/testing/index.ts
@@ -0,0 +1,78 @@
+export {
+ EXPECTED_ROOT_RUNTIME_EXPORT_COUNT,
+ EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT,
+ getPublicExportCoverage,
+ getRawCapability,
+ PUBLIC_EXPORT_COVERAGE,
+ RAW_CAPABILITY_COVERAGE,
+ TYPE_ONLY_ROOT_EXPORTS,
+} from "./coverage";
+export {createBrowserEvent} from "./event";
+export {
+ createExtensionContextFixture,
+ createInjectionResultFixture,
+ createInstalledDetailsFixture,
+ createManifestFixture,
+ createMessageSenderFixture,
+ createPermissionsFixture,
+ createTabFixture,
+ createWindowFixture,
+} from "./fixtures";
+export {installBrowserGlobals, installGlobals} from "./globals";
+export {createBrowserHarness} from "./harness";
+export {createBrowserMethod} from "./method";
+export type {
+ PublicExportCoverage,
+ PublicExportCoverageEntry,
+ PublicExportKind,
+ RawCapabilityCoverage,
+ RawCapabilityEntry,
+ RawCapabilityKind,
+ RawFailureChannel,
+ RawMethodInvocation,
+} from "./coverage";
+export type {
+ BrowserEventApi,
+ BrowserEventHarness,
+ BrowserEventListener,
+ BrowserEventRegistration,
+} from "./event";
+export type {
+ InstallBrowserGlobalsOptions,
+ LocationTestValue,
+ NavigatorTestValue,
+ TestGlobalValues,
+ WindowTestValue,
+} from "./globals";
+export type {
+ BrowserCapabilitiesHarness,
+ BrowserHarness,
+ BrowserHarnessOptions,
+ ConfigurableHarness,
+ SidebarHarness,
+} from "./harness";
+export type {ListenerErrorBuffer, ListenerErrorKind, ListenerErrorRecord} from "./listener-errors";
+export type {
+ BrowserMethod,
+ BrowserMethodCall,
+ BrowserMethodCallback,
+ BrowserMethodInvocation,
+ BrowserMethodInvocationStyle,
+ BrowserMethodObservedInvocation,
+ BrowserMethodOptions,
+} from "./method";
+export type {
+ BrowserHarnessCall,
+ BrowserProfile,
+ BrowserTestApi,
+ ExtensionContextKind,
+ FirefoxSidebarActionTestApi,
+ OperaSidebarActionTestApi,
+ PermissionsTestApi,
+ RuntimeTestApi,
+ ScriptingTestApi,
+ SidebarFlavor,
+ SidePanelTestApi,
+ TabsTestApi,
+ WindowsTestApi,
+} from "./types";
diff --git a/src/testing/internal.ts b/src/testing/internal.ts
new file mode 100644
index 0000000..254e6c7
--- /dev/null
+++ b/src/testing/internal.ts
@@ -0,0 +1,88 @@
+import type {BrowserHarnessCall, RuntimeLastErrorController} from "./types";
+
+export const cloneArray = (values: readonly T[] | undefined): T[] | undefined =>
+ values ? values.map(value => cloneRecord(value)) : undefined;
+
+export const cloneRecord = (value: T): T => {
+ if (Array.isArray(value)) {
+ return value.map(item => cloneRecord(item)) as T;
+ }
+
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([key, item]) => [key, cloneRecord(item)])
+ ) as T;
+ }
+
+ return value;
+};
+
+const errorMessage = (error: unknown): string => {
+ if (error instanceof Error) return error.message;
+ if (typeof error === "string") return error;
+
+ return "Unknown browser API error";
+};
+
+export const createLastErrorController = (): RuntimeLastErrorController & {reset(): void} => {
+ let current: chrome.runtime.LastError | undefined;
+
+ return {
+ get current(): chrome.runtime.LastError | undefined {
+ return current;
+ },
+ reset(): void {
+ current = undefined;
+ },
+ runWithLastError(error: unknown, callback: () => T): T {
+ const previous = current;
+ current = {message: errorMessage(error)};
+
+ try {
+ return callback();
+ } finally {
+ current = previous;
+ }
+ },
+ };
+};
+
+export const createCallCollector = () => {
+ const sources = new Map readonly Omit[]>();
+
+ return {
+ add(api: string, calls: () => readonly Omit[]): void {
+ sources.set(api, calls);
+ },
+ all(): BrowserHarnessCall[] {
+ return [...sources.entries()]
+ .flatMap(([api, getCalls]) => getCalls().map(call => ({...call, api})))
+ .sort((left, right) => left.sequence - right.sequence);
+ },
+ };
+};
+
+export const matchesContextFilter = (
+ context: chrome.runtime.ExtensionContext,
+ filter: chrome.runtime.ContextFilter
+): boolean => {
+ const checks: Array<[readonly unknown[] | undefined, unknown]> = [
+ [filter.contextIds, context.contextId],
+ [filter.contextTypes, context.contextType],
+ [filter.documentIds, context.documentId],
+ [filter.documentOrigins, context.documentOrigin],
+ [filter.documentUrls, context.documentUrl],
+ [filter.frameIds, context.frameId],
+ [filter.tabIds, context.tabId],
+ [filter.windowIds, context.windowId],
+ ];
+
+ if (typeof filter.incognito === "boolean" && context.incognito !== filter.incognito) return false;
+
+ return checks.every(([expected, actual]) => !expected || expected.includes(actual));
+};
+
+export const unsupportedApiError = (name: string): Error => new Error(`Browser test API "${name}" is not configured`);
+
+export const missingEntityError = (kind: "tab" | "window", id: number): Error =>
+ new Error(`No ${kind} with id: ${id}.`);
diff --git a/src/testing/listener-errors.ts b/src/testing/listener-errors.ts
new file mode 100644
index 0000000..466ba77
--- /dev/null
+++ b/src/testing/listener-errors.ts
@@ -0,0 +1,54 @@
+export type ListenerErrorKind = "sync" | "promise";
+
+export interface ListenerErrorRecord {
+ kind: ListenerErrorKind;
+ error: unknown;
+ args: readonly unknown[];
+}
+
+export interface ListenerErrorBuffer {
+ readonly entries: ListenerErrorRecord[];
+ readonly raw: Array;
+ reset(): void;
+}
+
+export interface ListenerErrorCapture extends ListenerErrorBuffer {
+ handler: (...args: unknown[]) => void;
+ setForward(forward: (...args: unknown[]) => void): void;
+}
+
+const prefixes: Record = {
+ "Listener error:": "sync",
+ "Listener in promise error:": "promise",
+};
+
+export const createListenerErrorCapture = (forward?: (...args: unknown[]) => void): ListenerErrorCapture => {
+ let original = forward ?? console.error.bind(console);
+ const entries: ListenerErrorRecord[] = [];
+ const raw: Array = [];
+ const handler = (...args: unknown[]): void => {
+ const [prefix, error, ...details] = args;
+ const kind = typeof prefix === "string" ? prefixes[prefix] : undefined;
+
+ if (kind && args.length >= 2) {
+ entries.push({args: details, error, kind});
+ return;
+ }
+
+ raw.push([...args]);
+ original(...args);
+ };
+
+ return {
+ entries,
+ raw,
+ handler,
+ reset(): void {
+ entries.length = 0;
+ raw.length = 0;
+ },
+ setForward(value): void {
+ if (value !== handler) original = (...args) => Reflect.apply(value, console, args);
+ },
+ };
+};
diff --git a/src/testing/method.test.ts b/src/testing/method.test.ts
new file mode 100644
index 0000000..338312b
--- /dev/null
+++ b/src/testing/method.test.ts
@@ -0,0 +1,257 @@
+import {createBrowserMethod} from "./method";
+
+type SyncApi = (value: string) => number;
+type CallbackApi = (value: string, callback: (result: number) => void) => void;
+type PromiseApi = (value: string) => Promise;
+type DualApi = {
+ (value: string): Promise;
+ (value: string, callback: (result: number) => void): void;
+};
+type PromiseTolerantApi = (value: string, callback?: (result: number) => void) => Promise;
+type HybridApi = (value: string, callback: (result: number) => void) => Promise | undefined;
+
+describe("createBrowserMethod", () => {
+ test("records sync calls and returns a persistent result", () => {
+ const method = createBrowserMethod({name: "runtime.sync", invocation: "sync"});
+
+ method.setResult(3);
+
+ expect(method.api("input")).toBe(3);
+ expect(method.calls).toEqual([
+ {
+ sequence: 1,
+ args: ["input"],
+ callback: undefined,
+ invocation: "sync",
+ callbackCalls: [],
+ },
+ ]);
+ expect(Object.isFrozen(method.calls)).toBe(true);
+ expect(Object.isFrozen(method.calls[0].args)).toBe(true);
+ });
+
+ test("names an unconfigured method in sync and Promise errors", async () => {
+ const syncMethod = createBrowserMethod({name: "runtime.sync", invocation: "sync"});
+ const promiseMethod = createBrowserMethod({name: "tabs.query", invocation: "promise"});
+
+ expect(() => syncMethod.api("input")).toThrow(
+ 'Browser method "runtime.sync" was called without a configured result or implementation.'
+ );
+ await expect(promiseMethod.api("input")).rejects.toThrow(
+ 'Browser method "tabs.query" was called without a configured result or implementation.'
+ );
+ });
+
+ test("uses failNext before queued and persistent results", async () => {
+ const method = createBrowserMethod({name: "tabs.get", invocation: "promise"});
+ const failure = new Error("temporary failure");
+
+ method.setResult(4);
+ method.queueResult(2, 3);
+ method.failNext(failure);
+
+ await expect(method.api("first")).rejects.toBe(failure);
+ await expect(method.api("second")).resolves.toBe(2);
+ await expect(method.api("third")).resolves.toBe(3);
+ await expect(method.api("fourth")).resolves.toBe(4);
+ });
+
+ test("turns synchronous implementation errors into Promise rejections", async () => {
+ const failure = new Error("implementation failed");
+ const method = createBrowserMethod({
+ name: "tabs.get",
+ invocation: "promise",
+ implementation: (() => {
+ throw failure;
+ }) as PromiseApi,
+ });
+
+ await expect(method.api("input")).rejects.toBe(failure);
+ });
+
+ test("invokes callback methods and tracks callback calls", () => {
+ const method = createBrowserMethod({
+ name: "tabs.callback",
+ invocation: "callback",
+ });
+ const callback = jest.fn();
+
+ method.setResult(5);
+
+ expect(method.api("input", callback)).toBeUndefined();
+ expect(callback).toHaveBeenCalledWith(5);
+ expect(method.calls[0]).toMatchObject({
+ args: ["input"],
+ callback,
+ invocation: "callback",
+ callbackCalls: [[5]],
+ });
+ });
+
+ test("supports callback argument mapping for void and multiple result callbacks", () => {
+ type MultiCallbackApi = (callback: (left: string, right: number) => void) => void;
+ const method = createBrowserMethod({
+ name: "runtime.multi",
+ invocation: "callback",
+ callbackArgs: value => value,
+ });
+ const callback = jest.fn();
+
+ method.setResult(["value", 7]);
+ method.api(callback);
+
+ expect(callback).toHaveBeenCalledWith("value", 7);
+ });
+
+ test("selects callback or Promise behavior for dual methods at call time", async () => {
+ const method = createBrowserMethod({name: "tabs.dual", invocation: "dual"});
+ const callback = jest.fn();
+
+ method.setResult(8);
+
+ expect(method.api("callback", callback)).toBeUndefined();
+ await expect(method.api("promise")).resolves.toBe(8);
+ expect(callback).toHaveBeenCalledWith(8);
+ expect(method.calls.map(call => call.invocation)).toEqual(["callback", "promise"]);
+ });
+
+ test("promise-tolerant methods ignore a trailing callback and always return a Promise", async () => {
+ const method = createBrowserMethod({
+ name: "runtime.promiseTolerant",
+ invocation: "promise-tolerant",
+ });
+ const callback = jest.fn();
+
+ method.setResult(9);
+
+ await expect(method.api("input", callback)).resolves.toBe(9);
+ expect(callback).not.toHaveBeenCalled();
+ expect(method.calls[0]).toMatchObject({callback, invocation: "promise-tolerant", callbackCalls: []});
+ });
+
+ test("promise-only methods reject callback invocations without consuming configured results", async () => {
+ const method = createBrowserMethod({name: "tabs.promise", invocation: "promise"});
+ const unsafeApi = method.api as unknown as (...args: unknown[]) => Promise;
+
+ method.queueResult(10);
+
+ await expect(unsafeApi("invalid", jest.fn())).rejects.toThrow(
+ 'Browser method "tabs.promise" is promise-only and does not accept a callback argument.'
+ );
+ await expect(method.api("valid")).resolves.toBe(10);
+ });
+
+ test("callback-only methods reject missing callbacks without consuming configured results", () => {
+ const method = createBrowserMethod({name: "tabs.callback", invocation: "callback"});
+ const unsafeApi = method.api as unknown as (...args: unknown[]) => void;
+ const callback = jest.fn();
+
+ method.queueResult(11);
+
+ expect(() => unsafeApi("invalid")).toThrow(
+ 'Browser method "tabs.callback" requires a callback as its final argument.'
+ );
+ method.api("valid", callback);
+ expect(callback).toHaveBeenCalledWith(11);
+ });
+
+ test("lets hybrid implementations call a callback and return a thenable", async () => {
+ const method = createBrowserMethod({name: "identity.getAuthToken", invocation: "hybrid"});
+ const callback = jest.fn();
+ const returned = Promise.resolve(13);
+
+ method.setImplementation((_value, implementationCallback) => {
+ implementationCallback(12);
+ return returned;
+ });
+
+ expect(method.api("input", callback)).toBe(returned);
+ await expect(returned).resolves.toBe(13);
+ expect(callback).toHaveBeenCalledWith(12);
+ expect(method.calls[0].callbackCalls).toEqual([[12]]);
+ });
+
+ test("exposes lastError only while a failed callback runs", () => {
+ let lastError: unknown;
+ const controller = {
+ runWithLastError(error: unknown, callback: () => T): T {
+ lastError = error;
+ try {
+ return callback();
+ } finally {
+ lastError = undefined;
+ }
+ },
+ };
+ const method = createBrowserMethod({
+ name: "tabs.get",
+ invocation: "callback",
+ lastError: controller,
+ });
+ const failure = new Error("missing tab");
+ const observed: unknown[] = [];
+
+ method.failNext(failure);
+ method.api("input", () => observed.push(lastError));
+
+ expect(observed).toEqual([failure]);
+ expect(lastError).toBeUndefined();
+ });
+
+ test("does not silently lose callback failures without a lastError controller", () => {
+ const method = createBrowserMethod({name: "tabs.get", invocation: "callback"});
+
+ method.failNext(new Error("missing tab"));
+
+ expect(() => method.api("input", jest.fn())).toThrow(
+ 'Browser method "tabs.get" cannot expose a callback error without a lastError controller.'
+ );
+ });
+
+ test("keeps a default implementation across reset while clearing user configuration", () => {
+ const defaultImplementation: SyncApi = value => value.length;
+ const method = createBrowserMethod({
+ name: "runtime.default",
+ invocation: "sync",
+ implementation: defaultImplementation,
+ });
+
+ expect(method.hasDefaultImplementation).toBe(true);
+ expect(method.api("abc")).toBe(3);
+
+ method.setResult(20);
+ expect(method.api("x")).toBe(20);
+
+ method.reset();
+
+ expect(method.calls).toEqual([]);
+ expect(method.api("abcd")).toBe(4);
+ expect(method.calls[0].sequence).toBe(1);
+ });
+
+ test("reports whether the current reset baseline has a default implementation", () => {
+ const method = createBrowserMethod({name: "runtime.default", invocation: "sync"});
+
+ expect(method.hasDefaultImplementation).toBe(false);
+ method.setImplementation(value => value.length);
+ expect(method.hasDefaultImplementation).toBe(false);
+ method.setDefaultImplementation(value => value.length + 1);
+ expect(method.hasDefaultImplementation).toBe(true);
+ method.setDefaultImplementation(undefined);
+ expect(method.hasDefaultImplementation).toBe(false);
+ });
+
+ test("supports a shared sequence source", () => {
+ let sequence = 40;
+ const method = createBrowserMethod({
+ name: "runtime.sharedSequence",
+ invocation: "sync",
+ nextSequence: () => ++sequence,
+ });
+
+ method.setResult(1);
+ method.api("input");
+
+ expect(method.calls[0].sequence).toBe(41);
+ });
+});
diff --git a/src/testing/method.ts b/src/testing/method.ts
new file mode 100644
index 0000000..ac69d38
--- /dev/null
+++ b/src/testing/method.ts
@@ -0,0 +1,281 @@
+export type BrowserMethodInvocationStyle = "sync" | "callback" | "promise" | "dual" | "promise-tolerant" | "hybrid";
+
+export type BrowserMethodObservedInvocation = "sync" | "callback" | "promise" | "promise-tolerant" | "hybrid";
+
+export type BrowserMethodInvocation = BrowserMethodObservedInvocation;
+
+export type BrowserMethodCallback = (...args: unknown[]) => unknown;
+
+export interface BrowserMethodLastErrorController {
+ runWithLastError(error: unknown, callback: () => T): T;
+}
+
+export interface BrowserMethodCall {
+ readonly sequence: number;
+ readonly args: readonly unknown[];
+ readonly callback: BrowserMethodCallback | undefined;
+ readonly invocation: BrowserMethodObservedInvocation;
+ readonly callbackCalls: readonly (readonly unknown[])[];
+}
+
+type BrowserMethodFunction = (...args: never[]) => unknown;
+
+export interface BrowserMethodOptions {
+ readonly name: string;
+ readonly invocation: BrowserMethodInvocationStyle;
+ readonly callback?: "last";
+ readonly callbackArgs?: (result: TResult) => readonly unknown[];
+ readonly implementation?: TApi;
+ readonly lastError?: BrowserMethodLastErrorController;
+ readonly nextSequence?: () => number;
+}
+
+export interface BrowserMethod {
+ readonly api: TApi;
+ readonly calls: readonly BrowserMethodCall[];
+ /** Whether the constructor/default baseline supplies an implementation. */
+ readonly hasDefaultImplementation: boolean;
+ setResult(value: TResult): void;
+ queueResult(...values: readonly TResult[]): void;
+ setImplementation(implementation: TApi): void;
+ setDefaultImplementation(implementation: TApi | undefined): void;
+ failNext(error: unknown): void;
+ reset(): void;
+}
+
+interface MutableBrowserMethodCall {
+ sequence: number;
+ args: unknown[];
+ callback: BrowserMethodCallback | undefined;
+ invocation: BrowserMethodObservedInvocation;
+ callbackCalls: unknown[][];
+}
+
+interface ConfiguredResult {
+ configured: true;
+ value: TResult;
+}
+
+const UNCONFIGURED_RESULT = {configured: false} as const;
+
+function methodConfigurationError(name: string, message: string): Error {
+ return new Error(`Browser method "${name}" ${message}`);
+}
+
+function observedInvocation(
+ style: BrowserMethodInvocationStyle,
+ hasCallback: boolean
+): BrowserMethodObservedInvocation {
+ switch (style) {
+ case "dual":
+ return hasCallback ? "callback" : "promise";
+ case "promise-tolerant":
+ return "promise-tolerant";
+ default:
+ return style;
+ }
+}
+
+function immutableCall(call: MutableBrowserMethodCall): BrowserMethodCall {
+ return Object.freeze({
+ sequence: call.sequence,
+ args: Object.freeze([...call.args]),
+ callback: call.callback,
+ invocation: call.invocation,
+ callbackCalls: Object.freeze(call.callbackCalls.map(args => Object.freeze([...args]))),
+ });
+}
+
+/**
+ * Creates a small configurable browser method without depending on a test runner.
+ */
+export function createBrowserMethod(
+ options: BrowserMethodOptions
+): BrowserMethod {
+ let sequence = 0;
+ let calls: MutableBrowserMethodCall[] = [];
+ let queuedResults: TResult[] = [];
+ let queuedErrors: unknown[] = [];
+ let result: ConfiguredResult | typeof UNCONFIGURED_RESULT = UNCONFIGURED_RESULT;
+ let implementation: TApi | undefined;
+ let defaultImplementation = options.implementation;
+
+ const recognizesCallback =
+ options.callback === "last" ||
+ options.invocation === "callback" ||
+ options.invocation === "promise" ||
+ options.invocation === "dual" ||
+ options.invocation === "promise-tolerant" ||
+ options.invocation === "hybrid";
+
+ const invoke = (...rawArgs: unknown[]): unknown => {
+ const possibleCallback = recognizesCallback ? rawArgs.at(-1) : undefined;
+ const callback =
+ typeof possibleCallback === "function" ? (possibleCallback as BrowserMethodCallback) : undefined;
+ const args = callback ? rawArgs.slice(0, -1) : [...rawArgs];
+ const invocation = observedInvocation(options.invocation, callback !== undefined);
+ const call: MutableBrowserMethodCall = {
+ sequence: options.nextSequence?.() ?? ++sequence,
+ args,
+ callback,
+ invocation,
+ callbackCalls: [],
+ };
+ calls.push(call);
+
+ const trackedCallback: BrowserMethodCallback | undefined = callback
+ ? (...callbackArgs) => {
+ call.callbackCalls.push([...callbackArgs]);
+ return callback(...callbackArgs);
+ }
+ : undefined;
+
+ if (options.invocation === "callback" && !callback) {
+ throw methodConfigurationError(options.name, "requires a callback as its final argument.");
+ }
+
+ if (options.invocation === "promise" && callback) {
+ return Promise.reject(
+ methodConfigurationError(options.name, "is promise-only and does not accept a callback argument.")
+ );
+ }
+
+ const isCallbackInvocation =
+ options.invocation === "callback" ||
+ (options.invocation === "dual" && callback !== undefined) ||
+ (options.invocation === "hybrid" && callback !== undefined);
+ const isPromiseInvocation =
+ options.invocation === "promise" ||
+ options.invocation === "promise-tolerant" ||
+ (options.invocation === "dual" && callback === undefined) ||
+ (options.invocation === "hybrid" && callback === undefined);
+
+ if (queuedErrors.length > 0) {
+ const error = queuedErrors.shift();
+
+ if (isCallbackInvocation && trackedCallback) {
+ if (!options.lastError) {
+ throw methodConfigurationError(
+ options.name,
+ "cannot expose a callback error without a lastError controller."
+ );
+ }
+
+ const callWithError = () => trackedCallback();
+ options.lastError.runWithLastError(error, callWithError);
+
+ return undefined;
+ }
+
+ if (isPromiseInvocation) {
+ return Promise.reject(error);
+ }
+
+ throw error;
+ }
+
+ const hasQueuedResult = queuedResults.length > 0;
+ const configuredResult = hasQueuedResult
+ ? ({configured: true, value: queuedResults.shift() as TResult} satisfies ConfiguredResult)
+ : result;
+ const activeImplementation = implementation ?? defaultImplementation;
+
+ if (configuredResult.configured && (hasQueuedResult || !implementation)) {
+ if (isCallbackInvocation && trackedCallback) {
+ const callbackArgs =
+ options.callbackArgs?.(configuredResult.value) ??
+ (typeof configuredResult.value === "undefined" ? [] : [configuredResult.value]);
+ trackedCallback(...callbackArgs);
+ return undefined;
+ }
+
+ if (isPromiseInvocation) {
+ return Promise.resolve(configuredResult.value);
+ }
+
+ return configuredResult.value;
+ }
+
+ if (activeImplementation) {
+ const implementationArgs = [...args];
+
+ if (trackedCallback && options.invocation !== "promise-tolerant" && options.invocation !== "promise") {
+ implementationArgs.push(trackedCallback);
+ }
+
+ let implementationResult: unknown;
+
+ try {
+ implementationResult = Reflect.apply(activeImplementation, undefined, implementationArgs);
+ } catch (error) {
+ if (isPromiseInvocation) {
+ return Promise.reject(error);
+ }
+
+ throw error;
+ }
+
+ if (options.invocation === "hybrid") {
+ return implementationResult;
+ }
+
+ if (isCallbackInvocation) {
+ return undefined;
+ }
+
+ if (isPromiseInvocation) {
+ return Promise.resolve(implementationResult);
+ }
+
+ return implementationResult;
+ }
+
+ const error = methodConfigurationError(
+ options.name,
+ "was called without a configured result or implementation."
+ );
+
+ if (isPromiseInvocation) {
+ return Promise.reject(error);
+ }
+
+ throw error;
+ };
+
+ return {
+ api: invoke as unknown as TApi,
+ get calls() {
+ return Object.freeze(calls.map(immutableCall));
+ },
+ get hasDefaultImplementation() {
+ return defaultImplementation !== undefined;
+ },
+ setResult(value) {
+ implementation = undefined;
+ result = {configured: true, value};
+ },
+ queueResult(...values) {
+ queuedResults.push(...values);
+ },
+ setImplementation(value) {
+ result = UNCONFIGURED_RESULT;
+ implementation = value;
+ },
+ setDefaultImplementation(value) {
+ defaultImplementation = value;
+ },
+ failNext(error) {
+ queuedErrors.push(error);
+ },
+ reset() {
+ // Keep the default implementation: reset restores the current default/stateful
+ // baseline while clearing call history and one-off consumer configuration.
+ sequence = 0;
+ calls = [];
+ queuedResults = [];
+ queuedErrors = [];
+ result = UNCONFIGURED_RESULT;
+ implementation = undefined;
+ },
+ };
+}
diff --git a/src/testing/permissions.ts b/src/testing/permissions.ts
new file mode 100644
index 0000000..aa51253
--- /dev/null
+++ b/src/testing/permissions.ts
@@ -0,0 +1,180 @@
+import {type BrowserEventHarness, createBrowserEvent} from "./event";
+import {createPermissionsFixture} from "./fixtures";
+import {type BrowserMethod, createBrowserMethod} from "./method";
+import type {PermissionsTestApi, RuntimeLastErrorController} from "./types";
+
+type PermissionEventArgs = Parameters[0]>;
+
+export interface PermissionsHarness {
+ readonly api: PermissionsTestApi;
+ readonly addHostAccessRequest: BrowserMethod;
+ readonly contains: BrowserMethod;
+ readonly getAll: BrowserMethod;
+ readonly remove: BrowserMethod;
+ readonly removeHostAccessRequest: BrowserMethod;
+ readonly request: BrowserMethod;
+ readonly onAdded: BrowserEventHarness;
+ readonly onRemoved: BrowserEventHarness;
+ readonly value: chrome.permissions.Permissions;
+ grant(value: chrome.permissions.Permissions): Promise;
+ revoke(value: chrome.permissions.Permissions): Promise;
+ set(value: chrome.permissions.Permissions): void;
+ reset(): void;
+}
+
+const includesAll = (current: Set, expected: readonly T[] | undefined): boolean =>
+ (expected ?? []).every(value => current.has(value));
+
+export const createPermissionsHarness = (
+ initialValue: chrome.permissions.Permissions | undefined,
+ lastError: RuntimeLastErrorController,
+ nextSequence?: () => number
+): PermissionsHarness => {
+ const initial = createPermissionsFixture(initialValue);
+ let permissions = new Set(initial.permissions ?? []);
+ let origins = new Set(initial.origins ?? []);
+
+ const onAdded = createBrowserEvent();
+ const onRemoved = createBrowserEvent();
+
+ const addHostAccessRequest = createBrowserMethod({
+ callback: "last",
+ callbackArgs: () => [],
+ invocation: "dual",
+ lastError,
+ name: "permissions.addHostAccessRequest",
+ nextSequence,
+ });
+ const removeHostAccessRequest = createBrowserMethod({
+ callback: "last",
+ callbackArgs: () => [],
+ invocation: "dual",
+ lastError,
+ name: "permissions.removeHostAccessRequest",
+ nextSequence,
+ });
+ const contains = createBrowserMethod({
+ callback: "last",
+ implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => {
+ const result = includesAll(permissions, value.permissions) && includesAll(origins, value.origins);
+ callback?.(result);
+ return result;
+ }) as unknown as typeof chrome.permissions.contains,
+ invocation: "dual",
+ lastError,
+ name: "permissions.contains",
+ nextSequence,
+ });
+ const getAll = createBrowserMethod({
+ callback: "last",
+ implementation: ((callback?: (result: chrome.permissions.Permissions) => void) => {
+ const result = {origins: [...origins], permissions: [...permissions]};
+ callback?.(result);
+ return result;
+ }) as unknown as typeof chrome.permissions.getAll,
+ invocation: "dual",
+ lastError,
+ name: "permissions.getAll",
+ nextSequence,
+ });
+
+ const apply = async (
+ value: chrome.permissions.Permissions,
+ action: "grant" | "revoke"
+ ): Promise => {
+ const changedPermissions: chrome.runtime.ManifestPermission[] = [];
+ const changedOrigins: string[] = [];
+ const mutate = (set: Set, item: T): boolean => {
+ if (action === "revoke") return set.delete(item);
+ if (set.has(item)) return false;
+ set.add(item);
+ return true;
+ };
+
+ for (const permission of value.permissions ?? []) {
+ if (mutate(permissions, permission)) changedPermissions.push(permission);
+ }
+ for (const origin of value.origins ?? []) {
+ if (mutate(origins, origin)) changedOrigins.push(origin);
+ }
+
+ const changed = {origins: changedOrigins, permissions: changedPermissions};
+ if (changedPermissions.length || changedOrigins.length) {
+ await (action === "grant" ? onAdded : onRemoved).emit(changed);
+ }
+ return changed;
+ };
+
+ const request = createBrowserMethod({
+ callback: "last",
+ implementation: (async (value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => {
+ await apply(value, "grant");
+ callback?.(true);
+ return true;
+ }) as unknown as typeof chrome.permissions.request,
+ invocation: "dual",
+ lastError,
+ name: "permissions.request",
+ nextSequence,
+ });
+ const remove = createBrowserMethod({
+ callback: "last",
+ implementation: (async (value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => {
+ const changed = await apply(value, "revoke");
+ const result = Boolean(changed.permissions?.length || changed.origins?.length);
+ callback?.(result);
+ return result;
+ }) as unknown as typeof chrome.permissions.remove,
+ invocation: "dual",
+ lastError,
+ name: "permissions.remove",
+ nextSequence,
+ });
+
+ const api = {
+ addHostAccessRequest: addHostAccessRequest.api,
+ contains: contains.api,
+ getAll: getAll.api,
+ onAdded: onAdded.api,
+ onRemoved: onRemoved.api,
+ remove: remove.api,
+ removeHostAccessRequest: removeHostAccessRequest.api,
+ request: request.api,
+ } as unknown as PermissionsTestApi;
+
+ const methods = [addHostAccessRequest, contains, getAll, remove, removeHostAccessRequest, request];
+
+ return {
+ api,
+ addHostAccessRequest,
+ contains,
+ getAll,
+ remove,
+ removeHostAccessRequest,
+ request,
+ onAdded,
+ onRemoved,
+ get value() {
+ return {origins: [...origins], permissions: [...permissions]};
+ },
+ async grant(value): Promise {
+ await apply(value, "grant");
+ },
+ async revoke(value): Promise {
+ await apply(value, "revoke");
+ },
+ reset(): void {
+ permissions = new Set(initial.permissions ?? []);
+ origins = new Set(initial.origins ?? []);
+ methods.forEach(method => {
+ method.reset();
+ });
+ onAdded.reset();
+ onRemoved.reset();
+ },
+ set(value): void {
+ permissions = new Set(value.permissions ?? []);
+ origins = new Set(value.origins ?? []);
+ },
+ };
+};
diff --git a/src/testing/production.integration.test.ts b/src/testing/production.integration.test.ts
new file mode 100644
index 0000000..0b8b774
--- /dev/null
+++ b/src/testing/production.integration.test.ts
@@ -0,0 +1,75 @@
+import {BlockDownloadError, download} from "../downloads";
+import {findTabById, getTabUrl} from "../tabs";
+import {getUserScripts} from "../userScripts";
+import {createBrowserHarness, installGlobals} from "./index";
+
+const restorers: Array<() => void> = [];
+
+afterEach(() => {
+ while (restorers.length > 0) restorers.pop()?.();
+});
+
+describe("current production behavior through the browser harness", () => {
+ test("uses the callback-schema getScripts method without weakening promise-only methods", async () => {
+ const harness = createBrowserHarness();
+ restorers.push(installGlobals({browser: undefined, chrome: harness.chrome}));
+ const scripts: chrome.userScripts.RegisteredUserScript[] = [
+ {id: "configured-script", js: [{file: "content.js"}], matches: ["https://example.test/*"]},
+ ];
+ harness.configurable.chrome.userScripts.getScripts.setResult(scripts);
+
+ await expect(getUserScripts(["configured-script"])).resolves.toEqual(scripts);
+ expect(harness.configurable.chrome.userScripts.getScripts.calls[0]).toMatchObject({
+ args: [{ids: ["configured-script"]}],
+ callback: expect.any(Function),
+ invocation: "callback",
+ });
+ });
+
+ test("locks in the current missing-tab rejection cascade", async () => {
+ const harness = createBrowserHarness();
+ restorers.push(installGlobals({browser: undefined, chrome: harness.chrome}));
+
+ // Locks in current behavior; see https://github.com/addon-stack/browser/issues/23.
+ await expect(findTabById(999)).rejects.toThrow("No tab with id: 999.");
+ await expect(getTabUrl(999)).rejects.toThrow("No tab with id: 999.");
+ });
+
+ test("download succeeds after the production 100 ms delay", async () => {
+ const harness = createBrowserHarness();
+ restorers.push(installGlobals({browser: undefined, chrome: harness.chrome}));
+ harness.configurable.chrome.downloads.download.setResult(41);
+ harness.configurable.chrome.downloads.search.setResult([
+ {error: undefined, exists: true, id: 41, state: "in_progress"} as chrome.downloads.DownloadItem,
+ ]);
+ const startedAt = performance.now();
+
+ await expect(download({url: "https://download.example/file.zip"})).resolves.toBe(41);
+
+ expect(performance.now() - startedAt).toBeGreaterThanOrEqual(90);
+ expect(harness.configurable.chrome.downloads.download.calls[0]?.args).toEqual([
+ {conflictAction: "uniquify", url: "https://download.example/file.zip"},
+ ]);
+ });
+
+ test("download preserves the exact BlockDownloadError class after the production delay", async () => {
+ const harness = createBrowserHarness();
+ restorers.push(installGlobals({browser: undefined, chrome: harness.chrome}));
+ harness.configurable.chrome.downloads.download.setResult(42);
+ harness.configurable.chrome.downloads.search.setResult([
+ {error: "USER_CANCELED", exists: true, id: 42, state: "interrupted"} as chrome.downloads.DownloadItem,
+ ]);
+ const startedAt = performance.now();
+
+ let failure: unknown;
+ try {
+ await download({url: "https://download.example/requires-permission.zip"});
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(performance.now() - startedAt).toBeGreaterThanOrEqual(90);
+ expect(failure).toBeInstanceOf(BlockDownloadError);
+ expect(failure).toMatchObject({message: "Requires user permission to upload"});
+ });
+});
diff --git a/src/testing/runtime.messaging.test.ts b/src/testing/runtime.messaging.test.ts
new file mode 100644
index 0000000..c8fe91b
--- /dev/null
+++ b/src/testing/runtime.messaging.test.ts
@@ -0,0 +1,201 @@
+import {onMessage, sendMessage} from "../runtime";
+import {createBrowserHarness, createMessageSenderFixture, createTabFixture, installGlobals} from "./index";
+
+const restorers: Array<() => void> = [];
+const CHANNEL_CLOSED_MESSAGE =
+ 'Browser method "runtime.sendMessage" message channel closed before a response was received.';
+
+const installChromeHarness = (harness: ReturnType): void => {
+ restorers.push(installGlobals({browser: undefined, chrome: harness.chrome}));
+};
+
+afterEach(() => {
+ while (restorers.length > 0) restorers.pop()?.();
+});
+
+describe("stateful runtime messaging", () => {
+ test("routes the real callback wrapper through onMessage with the configured sender", async () => {
+ const harness = createBrowserHarness();
+ const sender = createMessageSenderFixture({id: "sender-id", tab: createTabFixture({id: 42})});
+ const received: Array<{message: unknown; sender: chrome.runtime.MessageSender}> = [];
+
+ harness.runtime.setMessageSender(sender);
+ installChromeHarness(harness);
+ const unsubscribe = onMessage((message, actualSender, sendResponse) => {
+ received.push({message, sender: actualSender});
+ sendResponse({kind: "pong"});
+ });
+
+ await expect(sendMessage({kind: "ping"})).resolves.toEqual({kind: "pong"});
+ expect(received).toEqual([{message: {kind: "ping"}, sender}]);
+ expect(harness.runtime.sendMessage.calls[0]).toMatchObject({
+ args: [{kind: "ping"}],
+ invocation: "callback",
+ callbackCalls: [[{kind: "pong"}]],
+ });
+
+ unsubscribe();
+ });
+
+ test("uses the first actual response while still starting every listener synchronously", async () => {
+ const harness = createBrowserHarness();
+ const calls: string[] = [];
+
+ harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => {
+ calls.push("first");
+ sendResponse("first response");
+ });
+ harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => {
+ calls.push("second");
+ sendResponse("second response");
+ });
+
+ const response = harness.browser.runtime.sendMessage("ping");
+
+ expect(calls).toEqual(["first", "second"]);
+ await expect(response).resolves.toBe("first response");
+
+ const asyncHarness = createBrowserHarness();
+ let resolveSlow: (value: string) => void = () => undefined;
+ asyncHarness.runtime.events.onMessage.on(() => {
+ calls.push("slow started");
+ return new Promise(resolve => {
+ resolveSlow = resolve;
+ });
+ });
+ asyncHarness.runtime.events.onMessage.on(() => {
+ calls.push("fast started");
+ return Promise.resolve("fast response");
+ });
+
+ const asyncResponse = asyncHarness.browser.runtime.sendMessage("ping");
+
+ expect(calls).toEqual(["first", "second", "slow started", "fast started"]);
+ await expect(asyncResponse).resolves.toBe("fast response");
+ resolveSlow("slow response");
+ await Promise.resolve();
+ });
+
+ test("holds the response channel only when a listener returns true", async () => {
+ const heldHarness = createBrowserHarness();
+ heldHarness.runtime.events.onMessage.on((_message, _sender, sendResponse) => {
+ queueMicrotask(() => sendResponse("async response"));
+ return true;
+ });
+
+ await expect(heldHarness.browser.runtime.sendMessage("ping")).resolves.toBe("async response");
+
+ const closedHarness = createBrowserHarness();
+ closedHarness.runtime.events.onMessage.on((_message, _sender, sendResponse) => {
+ queueMicrotask(() => sendResponse("too late"));
+ });
+
+ await expect(closedHarness.browser.runtime.sendMessage("ping")).resolves.toBeUndefined();
+ });
+
+ test("accepts Promise and arbitrary thenable responses", async () => {
+ const promiseHarness = createBrowserHarness();
+ promiseHarness.runtime.events.onMessage.on(() => Promise.resolve({source: "promise"}));
+
+ await expect(promiseHarness.runtime.emitMessage("ping")).resolves.toEqual({source: "promise"});
+
+ const thenableHarness = createBrowserHarness();
+ thenableHarness.runtime.events.onMessage.on(() => ({
+ // biome-ignore lint/suspicious/noThenProperty: this intentionally models a non-Promise thenable.
+ then(resolve: (value: unknown) => void) {
+ resolve({source: "thenable"});
+ },
+ }));
+
+ await expect(thenableHarness.browser.runtime.sendMessage("ping")).resolves.toEqual({source: "thenable"});
+ });
+
+ test("resolves undefined when no listener responds", async () => {
+ const emptyHarness = createBrowserHarness();
+ await expect(emptyHarness.browser.runtime.sendMessage("ping")).resolves.toBeUndefined();
+
+ const silentHarness = createBrowserHarness();
+ silentHarness.runtime.events.onMessage.on(() => "not a WebExtension response channel");
+ await expect(silentHarness.runtime.emitMessage("ping")).resolves.toBeUndefined();
+ });
+
+ test("exposes dispatch failures through callback-scoped runtime.lastError", async () => {
+ const harness = createBrowserHarness();
+ const failure = new Error("listener failed");
+ const observed: Array<{message: string | undefined; response: unknown}> = [];
+ harness.runtime.events.onMessage.on(() => Promise.reject(failure));
+
+ await new Promise(resolve => {
+ harness.chrome.runtime.sendMessage("ping", response => {
+ observed.push({message: harness.chrome.runtime.lastError?.message, response});
+ resolve();
+ });
+ });
+
+ expect(observed).toEqual([{message: "listener failed", response: undefined}]);
+ expect(harness.chrome.runtime.lastError).toBeUndefined();
+ });
+
+ test("explicitly closes every pending message channel and ignores late responses", async () => {
+ const harness = createBrowserHarness();
+ const lateResponses: Array<(response?: unknown) => void> = [];
+ harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => {
+ lateResponses.push(sendResponse);
+ return true;
+ });
+
+ const first = harness.browser.runtime.sendMessage("first");
+ const second = harness.runtime.emitMessage("second");
+ harness.runtime.closeMessageChannels();
+
+ await expect(first).rejects.toThrow(CHANNEL_CLOSED_MESSAGE);
+ await expect(second).rejects.toThrow(CHANNEL_CLOSED_MESSAGE);
+
+ lateResponses.forEach(sendResponse => {
+ sendResponse("too late");
+ });
+ harness.runtime.closeMessageChannels();
+ await expect(first).rejects.toThrow(CHANNEL_CLOSED_MESSAGE);
+ await expect(second).rejects.toThrow(CHANNEL_CLOSED_MESSAGE);
+ });
+
+ test("reports an explicitly closed callback channel through scoped runtime.lastError", async () => {
+ const harness = createBrowserHarness();
+ const observed: Array<{message: string | undefined; response: unknown}> = [];
+ harness.runtime.events.onMessage.on(() => true);
+
+ const callbackFinished = new Promise(resolve => {
+ harness.chrome.runtime.sendMessage("ping", response => {
+ observed.push({message: harness.chrome.runtime.lastError?.message, response});
+ resolve();
+ });
+ });
+
+ harness.runtime.closeMessageChannels();
+ await callbackFinished;
+
+ expect(observed).toEqual([{message: CHANNEL_CLOSED_MESSAGE, response: undefined}]);
+ expect(harness.chrome.runtime.lastError).toBeUndefined();
+ });
+
+ test("reset rejects pending channels but leaves already settled dispatches unchanged", async () => {
+ const harness = createBrowserHarness();
+ let holdOpen = false;
+ harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => {
+ if (holdOpen) return true;
+ sendResponse("settled response");
+ });
+
+ const settled = harness.browser.runtime.sendMessage("settled");
+ await expect(settled).resolves.toBe("settled response");
+
+ holdOpen = true;
+ const pending = harness.browser.runtime.sendMessage("pending");
+ harness.reset();
+
+ await expect(pending).rejects.toThrow(CHANNEL_CLOSED_MESSAGE);
+ await expect(settled).resolves.toBe("settled response");
+ expect(harness.runtime.events.onMessage.listenerCount()).toBe(0);
+ expect(() => harness.runtime.closeMessageChannels()).not.toThrow();
+ });
+});
diff --git a/src/testing/runtime.ts b/src/testing/runtime.ts
new file mode 100644
index 0000000..9d9ed41
--- /dev/null
+++ b/src/testing/runtime.ts
@@ -0,0 +1,522 @@
+import {type BrowserEventHarness, createBrowserEvent} from "./event";
+import {createManifestFixture, createMessageSenderFixture} from "./fixtures";
+import {cloneRecord, matchesContextFilter} from "./internal";
+import {type BrowserMethod, createBrowserMethod} from "./method";
+import type {RuntimeLastErrorController, RuntimeTestApi} from "./types";
+
+type ListenerArgs unknown, ...args: never[]): unknown}> =
+ Parameters[0]>;
+
+type InstalledArgs = ListenerArgs;
+type StartupArgs = ListenerArgs;
+type MessageArgs = ListenerArgs;
+type ConnectArgs = ListenerArgs;
+type ConnectExternalArgs = ListenerArgs;
+type MessageExternalArgs = ListenerArgs;
+type RestartRequiredArgs = ListenerArgs;
+type SuspendArgs = ListenerArgs;
+type SuspendCanceledArgs = ListenerArgs;
+type UpdateAvailableArgs = ListenerArgs;
+type UserScriptConnectArgs = ListenerArgs;
+type UserScriptMessageArgs = ListenerArgs;
+
+interface RequestUpdateCheckResult {
+ status: `${chrome.runtime.RequestUpdateCheckStatus}`;
+ details?: chrome.runtime.UpdateCheckDetails;
+}
+
+export interface RuntimeHarnessOptions {
+ extensionId?: string;
+ manifest?: chrome.runtime.Manifest;
+ contexts?: readonly chrome.runtime.ExtensionContext[];
+ messageSender?: chrome.runtime.MessageSender;
+}
+
+export interface RuntimeEventsHarness {
+ onConnect: BrowserEventHarness;
+ onConnectExternal: BrowserEventHarness;
+ onInstalled: BrowserEventHarness;
+ onMessage: BrowserEventHarness;
+ onMessageExternal: BrowserEventHarness;
+ onRestartRequired: BrowserEventHarness;
+ onStartup: BrowserEventHarness;
+ onSuspend: BrowserEventHarness;
+ onSuspendCanceled: BrowserEventHarness;
+ onUpdateAvailable: BrowserEventHarness;
+ onUserScriptConnect: BrowserEventHarness;
+ onUserScriptMessage: BrowserEventHarness;
+}
+
+export interface RuntimeHarness {
+ readonly chromeApi: RuntimeTestApi;
+ readonly browserApi: RuntimeTestApi;
+ readonly connect: BrowserMethod;
+ readonly connectNative: BrowserMethod;
+ readonly getContexts: BrowserMethod;
+ readonly getManifest: BrowserMethod;
+ readonly getPackageDirectoryEntry: BrowserMethod<
+ typeof chrome.runtime.getPackageDirectoryEntry,
+ FileSystemDirectoryEntry
+ >;
+ readonly getPlatformInfo: BrowserMethod;
+ readonly getBrowserInfo: BrowserMethod;
+ readonly getURL: BrowserMethod;
+ readonly openOptionsPage: BrowserMethod;
+ readonly reload: BrowserMethod;
+ readonly requestUpdateCheck: BrowserMethod;
+ readonly restart: BrowserMethod