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.
Table of Contents
- Root cause: Firefox installs add-ons, it does not load directories
- Step 1. Run it interactively first
- Step 2. Lint before you test
- Step 3. Drive it from Playwright through a prepared profile
- Step 4. Discover the extension URL rather than constructing it
- Step 5. Account for the event page, not a service worker
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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
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.
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-extensionplus--disable-extensions-except, a constructiblechrome-extension://origin, and background contexts exposed as service workers to the automation protocol. - Firefox: temporary installs through
web-ext, a per-profilemoz-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
- Run
web-ext runinteractively and confirm the add-on appears underabout:debuggingwith its gecko id. - Run
web-ext lint --warnings-as-errorsand confirm a clean pass — fix anything it reports before automating. - Run the Playwright suite twice against a deleted
.fx-profile. Both runs should pass, proving nothing depends on a cached UUID. - Break the manifest deliberately — restore the
service_workerkey — and confirm the harness fails with a clear installation error rather than a timeout. - 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.
Related
- Loading an unpacked extension in Playwright — the Chromium harness this mirrors.
- Background script support across browsers — why the background context is reached differently.
- Building a GitHub Actions pipeline for extensions — running both harnesses on every push.
- End-to-End Testing Automation for MV3 Extensions — the guide this page belongs to.