createBrowserHarness() owns the fake state and call history. installBrowserGlobals() chooses how that state is
exposed to production code.
import {getAllPermissions, requestPermissions} from "@addon-core/browser";
import {
createBrowserHarness,
createPermissionsFixture,
installBrowserGlobals,
} from "@addon-core/browser/testing";
const harness = createBrowserHarness({
permissions: createPermissionsFixture({permissions: ["tabs"]}),
});
const restore = installBrowserGlobals(harness, {
context: "serviceWorker",
profile: "chrome",
});
try {
await requestPermissions({permissions: ["downloads"]});
const current = await getAllPermissions();
// current.permissions contains tabs and downloads
} finally {
restore();
}Profiles are chrome, firefox, opera, safari, and custom. Contexts are extensionPage, serviceWorker,
backgroundPage, contentScript, and none. A profile installs a coherent set of chrome, browser, opr,
safari, navigator, window, and location markers and temporarily removes conflicting markers.
The default environment: "simulate" mode creates execution-context markers, not a DOM. The contentScript context uses the deterministic host-page URL https://example.test/content/page.html, while
extension pages use an extension-style /index.html path.
The Chrome and browser facades are different objects backed by the same harness state. In a Firefox profile,
production wrappers normally route to harness.browser because browser.runtime.id exists. harness.chrome remains
available for explicit compatibility tests, but ordinary wrapper calls do not reach it. Sidebar helpers and browser
detection also inspect globals directly.
Use environment: "preserve" when Node, Jest/jsdom or the application fixture already supplies the host environment:
const restore = installBrowserGlobals(harness, {
profile: "chrome",
environment: "preserve",
});
try {
// The real document, URL, navigator.locks, prototypes and event listeners remain intact.
// Browser API wrappers use this harness.
} finally {
restore();
}This mode never reads, replaces or removes window, document, location or navigator. Missing globals stay
missing. It rejects a simultaneous context option or overrides of those four globals, including explicit
undefined. Use environment: "simulate" (the default) for synthetic context markers instead. The simulated mode
does not create or remove a DOM document; supplying a DOM remains the test environment's responsibility.
Profiles still select browser APIs and vendor markers. Browser detection can also inspect the preserved user agent,
so an API profile alone does not guarantee a particular guessBrowser() result in this mode.
Both installers use descriptor-based transactions. A failed installation rolls back prior successful changes.
installBrowserGlobals() also restores installation-owned harness settings: active profile, URL scheme, sidebar,
the runtime.getBrowserInfo capability and console forwarding. It does not reset fixtures or call history.
Nested installations must be restored in reverse order, even when using different harnesses. An out-of-order restore throws before changing anything; restore the inner installation first and then retry.
Once an in-order restore starts, it attempts all saved global descriptors and installation-owned harness settings, even if an individual step fails. Failures are reported together, and the installation is removed from the stack so outer installations can still restore. This cleanup attempt is terminal: subsequent calls to the same restore function are no-ops, including after failure, and cannot overwrite a later installation. Only an out-of-order attempt is retryable.
Cleanup completion does not imply every property was restored. Making an installed property non-configurable can make exact restoration impossible; the error identifies the failed property, and the kit does not silently claim success. Tests that deliberately make irreversible global changes should run in an isolated process.
harness.contexts owns contexts and documents. harness.runtime, harness.permissions, harness.tabs, harness.windows, harness.storage, harness.offscreen, and harness.scripting expose the stateful
controls and their methods/events. A namespace can still contain configurable members: for example,
Root tabs.sendMessage and all tabs.connect calls record arguments without routing messages or ports. For tab/context
delivery, opt into context-bound messaging. Complex
namespaces are explicit configurable stubs:
harness.configurable.chrome.downloads.search.setResult([]);
harness.configurable.chrome.downloads.download.failNext(new Error("Downloads disabled"));
// Chronological calls across stateful and configurable methods:
harness.calls;Use .browser instead of .chrome when configuring a Firefox or Safari profile. harness.configurable.active follows
the currently installed profile; restoring an inner installation returns to the outer profile.
The retained configurable.*.offscreen controls are aliases to the shared stateful Offscreen adapter,
so either facade's alias configures the same method. Use harness.offscreen.beforeCreate/beforeClose to delay or fail
an operation while preserving registry updates.
harness.messaging.forContext(context) provides context-bound message routing, including stateful
tabs.sendMessage in that view. Pass messageContext to installBrowserGlobals to bind real package wrappers.
This leaves unbound/root method behavior unchanged; the message context is separate from simulated DOM markers.
tabs.query selects fixture URLs with a documented match-pattern subset. permissions.contains checks whether
explicitly granted origins cover the requested patterns; manifest declarations do not grant access automatically.
See URL patterns and host permissions for examples, validation, and scope limits.
The production download(options) helper waits 100 ms before checking the created download. A fresh harness preserves
that default. To skip only this validation wait deterministically, opt in through the test-only delay control:
harness.delays.downloadValidation.setResult(undefined);The real download(options) function still runs, and its raw downloads.download and downloads.search results must
be configured as usual. After it reaches validation, harness.delays.downloadValidation.calls records the requested
delay with args: [100].
Use setImplementation() to hold validation until the test releases it:
let releaseValidation = () => {};
const validationGate = new Promise<void>(resolve => {
releaseValidation = resolve;
});
harness.delays.downloadValidation.setImplementation(() => validationGate);
// Start download(options) with configured raw results.
// After it reaches the delay hook, release validation when ready:
releaseValidation();The hook belongs to this harness's fake facades. It does not replace global timers, affect unrelated harnesses, or add
a scheduler argument to the production API. harness.reset() clears its calls and configuration and restores the real
100 ms default. This control does not model browser download completion, permissions, or lifecycle timing.
Use installGlobals() for low-level scenarios:
import {installGlobals} from "@addon-core/browser/testing";
const restore = installGlobals({
browser: undefined, // temporarily remove it
chrome: customChromeApi,
navigator: {userAgent: "Test Browser/1"},
});
try {
// run application code
} finally {
restore();
}An omitted field is untouched; an explicitly supplied undefined temporarily removes that global. Restoration is
idempotent and restores exact original property descriptors, including globals that were originally absent, unless
external descriptor changes prevent it. The terminal-cleanup and error-reporting rules above apply to both installers.
Capabilities are method-level. Disabling one physically removes the member so feature detection remains meaningful:
harness.capabilities.set("runtime.getBrowserInfo", false);An unknown capability name fails explicitly. harness.reset() restores state, methods, events, call history, and the
default capability set.
runtime.sendMessage is connected to the harness runtime.onMessage event. Listeners receive the configured sender,
may call sendResponse, return a Promise or thenable response, or return true to keep the response channel open for
a later sendResponse. The first response wins. harness.runtime.emitMessage() uses the same dispatch path and
returns its response.
import {onMessage, sendMessage} from "@addon-core/browser";
const unsubscribe = onMessage((_message, _sender, sendResponse) => {
queueMicrotask(() => sendResponse({ok: true}));
return true;
});
const response = await sendMessage({kind: "ping"});
unsubscribe();
// response is {ok: true}A listener that returns true without eventually calling sendResponse() leaves the test Promise pending, just as it
leaves the modeled response channel open. Close such channels explicitly when testing teardown or failure behavior:
import assert from "node:assert/strict";
const unsubscribe = harness.runtime.events.onMessage.on(() => true);
const pendingResponse = harness.browser.runtime.sendMessage({kind: "ping"});
harness.runtime.closeMessageChannels();
await assert.rejects(pendingResponse, {
message: 'Browser method "runtime.sendMessage" message channel closed before a response was received.',
});
unsubscribe();harness.reset() closes pending channels as part of reset. Synchronous listener returns other than literal true are
ignored; use sendResponse() or return a Promise/thenable for a response. With no listeners, the harness resolves
undefined instead of reproducing Chrome's Receiving end does not exist error.
For a callback-style method configured to fail, chrome.runtime.lastError exists only during the callback. The same
failure through a Promise rejects without setting lastError.
let observed: chrome.runtime.LastError | undefined;
harness.tabs.query.failNext(new Error("Tabs unavailable"));
harness.chrome.tabs.query({}, () => {
observed = harness.chrome.runtime.lastError;
});
// observed?.message === "Tabs unavailable"
// harness.chrome.runtime.lastError === undefined after the callbackcaptureListenerErrors: true is opt-in sugar over a temporary console.error wrapper. Known safeListener prefixes
are recorded as structured listener errors. Unknown calls are retained as raw entries and forwarded to the original
console.error; the default does not intercept or silence the console.
When a harness is reused, reset all state and controls explicitly:
harness.reset();
// call history and listeners are empty; initial fixtures and capabilities are restored