Testing Extensions in Firefox with web-ext

Run and automate a Manifest V3 add-on in Firefox: web-ext run with a persistent profile, Marionette-driven Playwright sessions, event-page quirks, and CI on headless Firefox.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

Your Playwright suite launches Chromium with --load-extension, drives the popup by extension id, and proves nothing about Firefox — where the flag does not exist, the extension id is a per-installation UUID, and the background context is an event page rather than a service worker. Every assumption the Chromium harness makes is wrong there. This page is part of End-to-End Testing Automation for MV3 Extensions and builds the Firefox half of a cross-browser suite.

Root cause: Firefox installs add-ons, it does not load directories

Chromium takes a directory on the command line and treats it as an extension with a path-derived id. Firefox has no equivalent flag: an add-on is installed into a profile, temporarily via the remote debugging protocol or permanently after signing. web-ext is the official tool that does the temporary installation for you, and everything else follows from where it puts the add-on.

How each engine gets your build under testChromium loads a directory by flag with a stable id; Firefox installs temporarily through the remote debugging protocol and assigns a per-profile UUID.Chromium--load-extension=dist/chromePath-derived idstable with a manifest keychrome-extension://<id>/popup.htmlconstructibleFirefox takes a different route entirelyweb-ext runinstalls into a profilePer-install UUIDnew on every profilemoz-extension://<uuid>/popup.htmlmust be discovered
The UUID is the reason a Firefox harness must discover the extension URL rather than construct it.

Step 1. Run it interactively first

Before automating anything, confirm the build actually installs. web-ext reports manifest problems that a silent failure would hide.

1npx web-ext run \
2  --source-dir dist/firefox \
3  --firefox-profile .fx-profile \
4  --profile-create-if-missing \
5  --keep-profile-changes \
6  --browser-console \
7  --start-url "about:debugging#/runtime/this-firefox"

Execution context: A developer machine with Firefox installed. --keep-profile-changes with a checked-out profile directory is what makes the run repeatable: without it every launch gets a fresh temporary profile, and any state your tests need — a granted permission, a completed first run — is gone. --browser-console surfaces the event page’s logs, which are otherwise buried in about:debugging. Run this against the Firefox artefact produced by the manifest build in shipping one manifest for Chrome and Firefox, not against the Chrome one — the service_worker background key is rejected outright.

Step 2. Lint before you test

web-ext lint is the same validator AMO runs, so it catches submission blockers before they cost you a review cycle.

1npx web-ext lint --source-dir dist/firefox --warnings-as-errors

Execution context: CI or a local shell. --warnings-as-errors is worth turning on early: the warnings are almost all things that will eventually be errors, such as an unknown permission or a missing browser_specific_settings.gecko.id. Running the linter as a separate CI step from the browser tests gives a fast failure — it takes a second and needs no browser at all, so it belongs at the front of the pipeline described in building a GitHub Actions pipeline for extensions.

Step 3. Drive it from Playwright through a prepared profile

Playwright’s Firefox support cannot install an add-on directly, so prepare a profile with web-ext and hand it over.

 1// tests/firefox-fixture.js
 2import { firefox } from "@playwright/test";
 3import { execFile } from "node:child_process";
 4import { promisify } from "node:util";
 5
 6const run = promisify(execFile);
 7
 8export async function launchWithAddon() {
 9  // Build a signed-free temporary install into a profile Playwright will reuse.
10  await run("npx", ["web-ext", "build", "--source-dir", "dist/firefox",
11                    "--artifacts-dir", ".fx-artifacts", "--overwrite-dest"]);
12
13  const context = await firefox.launchPersistentContext(".fx-profile", {
14    headless: true,
15    firefoxUserPrefs: {
16      "extensions.autoDisableScopes": 0,          // do not disable side-loaded add-ons
17      "xpinstall.signatures.required": false,     // temporary installs are unsigned
18      "devtools.debugger.remote-enabled": true,
19    },
20  });
21
22  return context;
23}

Execution context: Node, in the test harness. The two preference overrides are the ones that decide whether the add-on is active at all: autoDisableScopes left at its default disables anything installed outside the profile, and unsigned add-ons are refused unless signature checking is relaxed. Both are safe in a throwaway test profile and must never appear in a user-facing configuration. Firefox Nightly and Developer Edition allow unsigned add-ons without the second preference, which is why a suite that passes locally can fail on a release-channel CI image.

Step 4. Discover the extension URL rather than constructing it

Finding the add-on's moz-extension origin at runtimeThe harness opens the debugging page, reads the internal UUID for the add-on id, and builds page URLs from it.Testabout:debuggingAdd-onPopup pageopen the runtime pagelocate the add-on by its gecko idinternal UUIDchanges per profilegoto moz-extension://<uuid>/popup.htmlassertions run against a real document
Hard-coding a UUID works exactly once, on the machine where it was copied.
 1// tests/firefox-fixture.js
 2export async function extensionOrigin(context, geckoId) {
 3  const page = await context.newPage();
 4  await page.goto("about:debugging#/runtime/this-firefox");
 5
 6  const card = page.locator(".card", { hasText: geckoId });
 7  await card.waitFor({ state: "visible", timeout: 15_000 });
 8
 9  const internalUuid = await card.locator("dd", { hasText: /^[0-9a-f-]{36}$/ }).first().innerText();
10  await page.close();
11  return `moz-extension://${internalUuid.trim()}`;
12}
13
14// usage
15const origin = await extensionOrigin(context, "tab-curator@example.com");
16const popup = await context.newPage();
17await popup.goto(`${origin}/popup.html`);
18await popup.getByRole("button", { name: "Sync now" }).click();

Execution context: Playwright test process driving Firefox. Reading the UUID from the debugging page is the portable approach — the alternative, reading extensions.webextensions.uuids from the profile’s preferences file, is faster but depends on an internal format. Opening the popup as an ordinary page is the same technique used in the Chromium harness and gives you the real document with real listeners; what it does not give you is the popup’s anchoring or its automatic dismissal, so behaviours that depend on the popup closing need a separate check.

Step 5. Account for the event page, not a service worker

Firefox’s Manifest V3 background is a non-persistent event page. It has a DOM-less global like a worker, but it is reached differently and it is listed differently in tooling.

The same harness concern, in each engineHow the extension is installed, how its origin is obtained, and how the background context is reached, compared between Chromium and Firefox.Harness concernChromiumFirefoxInstall method--load-extensionweb-ext temporaryExtension originConstructible idDiscovered UUIDBackground contextserviceWorkers()Not exposedSignature requirementNonePreference overrideHeadless supportNew headless onlyYes
Only the fixture differs — the specs themselves can be shared verbatim.
 1// tests/background.spec.js
 2test("the event page answers a message after being woken", async ({}, testInfo) => {
 3  const context = await launchWithAddon();
 4  const origin = await extensionOrigin(context, "tab-curator@example.com");
 5
 6  // Firefox does not expose background contexts through context.serviceWorkers().
 7  // Drive the background through a page that messages it, which is what real code does.
 8  const driver = await context.newPage();
 9  await driver.goto(`${origin}/popup.html`);
10
11  const reply = await driver.evaluate(() => chrome.runtime.sendMessage({ type: "PING" }));
12  expect(reply).toEqual({ ok: true });
13
14  await context.close();
15});

Execution context: Playwright test against Firefox. context.serviceWorkers() returns nothing here because there is no service worker — the Chromium harness’s habit of grabbing the worker and calling into it directly has no Firefox equivalent. Messaging the background from an extension page is both the portable technique and a more honest test, because it exercises the same path the product uses. The lifecycle differences behind this are set out in background script support across browsers.

Cross-browser variation

  • Chrome/Edge: --load-extension plus --disable-extensions-except, a constructible chrome-extension:// origin, and background contexts exposed as service workers to the automation protocol.
  • Firefox: temporary installs through web-ext, a per-profile moz-extension:// UUID, an event-page background, and two preferences that must be relaxed for an unsigned build.
  • Safari: no comparable automation path — the extension lives in a native app, so coverage there is manual or driven through XCUITest against the container.
  • All engines: headless mode changes behaviour for anything involving a real window. Popups, the side panel and permission prompts are the usual casualties; run those specs headed even if the rest of the suite is headless.

Verification

  1. Run web-ext run interactively and confirm the add-on appears under about:debugging with its gecko id.
  2. Run web-ext lint --warnings-as-errors and confirm a clean pass — fix anything it reports before automating.
  3. Run the Playwright suite twice against a deleted .fx-profile. Both runs should pass, proving nothing depends on a cached UUID.
  4. Break the manifest deliberately — restore the service_worker key — and confirm the harness fails with a clear installation error rather than a timeout.
  5. Run the same specs against Chromium and Firefox in one CI job and confirm only the fixture differs.

FAQ

Can Playwright install an add-on without web-ext?

Not through its public API. Preparing the profile out of band and launching persistently is the supported path.

Do I need to sign the add-on for testing?

No. Temporary installs are unsigned, which is why the signature preference has to be relaxed on release-channel Firefox.

Why does my test pass headed and fail headless?

Almost always a real window is involved — a popup, the sidebar, or a permission prompt. Mark those specs as headed rather than working around the difference.

Is Selenium a better fit for Firefox?

Selenium’s install_addon is convenient and works well if your suite is already Selenium. For a suite that is otherwise Playwright, one shared fixture is less overhead than a second framework.

Other Testing, Debugging & Performance Optimization Resources