Overview
Every Playwright-driven Chromium-extension test starts the same way: a persistent context launched with two flags, a service-worker race, and an extension-ID extraction. Per the Playwright Chrome extensions docs (opens in new window), this fixture is the contract the assertion-level skills depend on - it's the foundation, not the test logic.
This SKILL.md is the "how to launch" layer - the Playwright fixture pattern (launchPersistentContext + --disable-extensions-except + --load-extension) shared by all extension tests. The "what to assert" playbook per surface - popup, content script, background messaging, storage, MV3 auto-suspend - lives in references/extension-surface-tests.md and builds on this fixture.
Composes with:
When to use
Authoring
The verbatim fixture file
Per pw-ext (opens in new window), fixtures.ts:
import { test as base, chromium, type BrowserContext } from '@playwright/test';
import path from 'path';
export const test = base.extend<{
context: BrowserContext;
extensionId: string;
}>({
context: async ({ }, use) => {
const pathToExtension = path.join(__dirname, 'my-extension');
const context = await chromium.launchPersistentContext('', {
channel: 'chromium',
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
// for manifest v3:
let [serviceWorker] = context.serviceWorkers();
if (!serviceWorker)
serviceWorker = await context.waitForEvent('serviceworker');
const extensionId = serviceWorker.url().split('/')[2];
await use(extensionId);
},
});
export const expect = test.expect;Field-by-field rationale
| Element | Why it matters per pw-ext (opens in new window) |
|---|---|
chromium.launchPersistentContext('') | "Extensions require a persistent context in Chromium" - launch() is non-persistent and extensions never load |
'' (userDataDir) | Empty string = ephemeral temp dir (Playwright cleans up); replace with a fixed path to persist auth state across runs |
channel: 'chromium' | The bundled Chromium channel; unlocks headless extension support per pw-ext (opens in new window) |
--disable-extensions-except=$DIR | Prevents any pre-installed extension from also loading and confusing assertions |
--load-extension=$DIR | Loads the unpacked extension at $DIR (where manifest.json lives) |
context.serviceWorkers() | Synchronous accessor - may be empty if the SW hasn't registered yet |
context.waitForEvent('serviceworker') | Race-safe fallback when SW isn't yet up |
serviceWorker.url().split('/')[2] | Service-worker URL is chrome-extension://<id>/<path>; index 2 is the ID |
Usage in spec files
Per pw-ext (opens in new window):
import { test, expect } from './fixtures';
test('example test', async ({ page }) => {
await page.goto('https://example.com');
await expect(page.locator('body')).toHaveText('Changed by my-extension');
});
test('popup page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(page.locator('body')).toHaveText('my-extension popup');
});The fixture-injected page automatically belongs to the persistent context - any content scripts the extension declares for the navigated URL will already be attached.
Running
Local headed
npx playwright testDefaults to headed for extensions (per pw-ext (opens in new window), non-chromium channels require headed mode).
Local + CI headless
npx playwright test --headed=falseRequires channel: 'chromium' in the fixture per pw-ext (opens in new window):
"Headless mode for extensions is supported only when using the
chromiumchannel."
Edge channel and Chrome channel will fail extension load in headless because, quoting pw-ext (opens in new window):
"Google Chrome and Microsoft Edge removed the command-line flags needed to side-load extensions."
MV2 fallback for extensionId
Per pw-ext (opens in new window), the SW fixture is MV3-specific. For an MV2 extension where the background context is a background page (not a service worker), extract the ID from a background-page event instead:
extensionId: async ({ context }, use) => {
// for manifest v2:
let [background] = context.backgroundPages();
if (!background) background = await context.waitForEvent('backgroundpage');
const extensionId = background.url().split('/')[2];
await use(extensionId);
},backgroundPages() is the MV2 analogue of serviceWorkers().
Pinning the temp profile
Replace '' with a fixed userDataDir to persist auth across runs:
const userDataDir = path.join(__dirname, '.pw-profile');
const context = await chromium.launchPersistentContext(userDataDir, { ... });Trade-off: stateful runs become non-deterministic; do this only for debugging an auth flow, not for CI.
Parsing results
Service-worker restart errors
Per pw-ext (opens in new window), MV3 service workers auto-suspend after ~30s idle. Playwright keeps "the same Worker object alive" across the restart, but "an in-flight evaluate() at the moment of suspension will throw" with the message:
"Service worker restarted"
Handle in tests by retrying the evaluate:
async function swEvaluate<T>(sw: any, fn: () => T): Promise<T> {
try { return await sw.evaluate(fn); }
catch (e: any) {
if (e.message.includes('Service worker restarted')) {
return await sw.evaluate(fn); // re-run once
}
throw e;
}
}Extension-load failures
If serviceWorker never fires and waitForEvent times out, typical causes:
| Symptom | Likely cause | Fix |
|---|---|---|
waitForEvent('serviceworker') times out | Manifest invalid or background.service_worker field missing | Load unpacked via chrome://extensions manually first and read the red Errors card |
| Extension loads in headed but not headless | Channel set to chrome / msedge instead of chromium | Use channel: 'chromium' per pw-ext (opens in new window) |
extensionId extracts an empty string | URL not chrome-extension://... shape (e.g., about:blank listener fired) | Filter on serviceWorker.url().startsWith('chrome-extension://') before split |
CI integration
GitHub Actions example:
name: extension-e2e
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 'lts/*' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Build extension
run: npm run build:extension
- name: E2E
run: npx playwright test
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Key choices:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
chromium.launch() instead of launchPersistentContext | Extension never loads per pw-ext (opens in new window) | Always use the persistent variant |
channel: 'chrome' or channel: 'msedge' | Side-load flags removed per pw-ext (opens in new window); headless will fail | Use channel: 'chromium' |
Hardcoding extensionId from a local install | ID changes per build / per machine | Extract from SW URL via the fixture |
Skipping the waitForEvent('serviceworker') fallback | Race: SW not yet registered → [] from serviceWorkers() → undefined ID | Always include the if-empty branch per pw-ext (opens in new window) |
Persistent userDataDir in CI | Auth state leaks between runs; flaky | Use '' for CI determinism |
Catching "Service worker restarted" as a permanent failure | Per pw-ext (opens in new window) this is recoverable; the SW resumes | Retry the evaluate once |
Using extra --args without verifying they don't conflict | Per pw-ext (opens in new window): "some of them may break Playwright functionality" | Add browser args defensively, one at a time |
Limitations
References
Assertion recipes for playwright-extension-fixtures: what to test on each Chromium-extension surface (popup, content script, background service worker, messaging, chrome.storage) once the fixture from the main skill is in place. Every test below imports test / expect from the fixture file, which supplies the persistent context and the resolved extensionId.
Popup page
import { test, expect } from './fixtures';
test('popup renders and increments counter', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await page.click('[data-testid="increment"]');
await expect(page.locator('[data-testid="count"]')).toHaveText('1');
});Content script injection
test('content script highlights matched terms', async ({ page }) => {
await page.goto('https://example.com/');
// Content script runs at document_idle by default
await page.waitForFunction(() =>
document.querySelector('[data-extension-marker]') !== null
);
await expect(page.locator('mark[data-extension-marker]')).toHaveCount(3);
});Message passing (popup to background)
test('popup sends message; background responds', async ({ context, extensionId }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
// Eval in service worker context
const swReady = await sw.evaluate(() => {
return new Promise<string>((resolve) => {
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
sendResponse({ echo: msg.text });
return true;
});
resolve('ready');
});
});
expect(swReady).toBe('ready');
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
const reply = await popup.evaluate(async () => {
return chrome.runtime.sendMessage({ text: 'hello' });
});
expect(reply).toEqual({ echo: 'hello' });
});chrome.storage persistence
Quota, area-selection, and storage.onChanged tests are in storage-tests.md (opens in new window); the baseline persistence check:
test('storage value persists across popup reload', async ({ context, extensionId }) => {
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
await popup.evaluate(async () => {
await chrome.storage.local.set({ pref: 'dark' });
});
await popup.reload();
const value = await popup.evaluate(async () => {
const { pref } = await chrome.storage.local.get('pref');
return pref;
});
expect(value).toBe('dark');
});Survive MV3 service-worker auto-suspend
Per Playwright Chrome extensions docs (opens in new window): Chrome auto-suspends MV3 service workers after ~30s of inactivity. Playwright keeps the same Worker object alive - evaluate() calls continue transparently without requiring new event handlers.
test('alarm survives service worker restart', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
await sw.evaluate(() => chrome.alarms.create('hourly', { periodInMinutes: 60 }));
// Simulate idle
await new Promise(r => setTimeout(r, 35_000));
// Same sw object; evaluate still works post-restart
const alarms = await sw.evaluate(() => chrome.alarms.getAll());
expect(alarms.find((a: any) => a.name === 'hourly')).toBeDefined();
});Worked example
Scenario: a "Reader" extension whose popup increments a counter and toggles a pref written to chrome.storage, with a content script that marks matched terms on visited pages.
Result: a green run confirms popup rendering, content-script injection, and storage persistence across reload - the extension's core surfaces are covered.
Limitations
References
Deep-lookup detail for the summary tables in SKILL.md: exact MV2/MV3 field shapes, the full Firefox/Chrome key matrix, and the two service-worker constructs (offscreen documents, keep-alive). Sources are consolidated in the link list at the bottom.
Exact field shapes
background
MV2:
{
"background": {
"scripts": ["backgroundContextMenus.js", "backgroundOauth.js"],
"persistent": false
}
}MV3:
{
"background": {
"service_worker": "service_worker.js",
"type": "module"
}
}The MV3 service_worker field is a single string (not an array); type is optional and only valid as "module". The MV2 persistent flag is removed entirely.
host_permissions split
"Host permissions in Manifest V3 are a separate field; you don't specify them in
"permissions"or in"optional_permissions"."
MV2:
"permissions": ["tabs", "bookmarks", "https://www.blogger.com/"],
"optional_permissions": ["unlimitedStorage", "*://*/*"]MV3:
"permissions": ["tabs", "bookmarks"],
"optional_permissions": ["unlimitedStorage"],
"host_permissions": ["https://www.blogger.com/"],
"optional_host_permissions": ["*://*/*"]"content_scripts[].matches" is unchanged between MV2 and MV3.
web_accessible_resources shape change
MV2 (flat string array):
"web_accessible_resources": [
"images/*",
"style/extension.css",
"script/extension.js"
]MV3 (array of objects, each scoping resources to URL patterns or extension IDs):
"web_accessible_resources": [
{ "resources": ["images/*"], "matches": ["*://*/*"] },
{
"resources": ["style/extension.css", "script/extension.js"],
"matches": ["https://example.com/*"]
}
]Test implication: a page-context fetch(chrome.runtime.getURL('...')) that worked under MV2 may 404 under MV3 if the requester's origin isn't covered by a matches pattern.
Firefox vs Chrome manifest key matrix
Every documented manifest key with Firefox / Chrome availability and MV2 / MV3 status per mdn-manifest (opens in new window). Tests must gate on browser detection or split into per-browser fixtures when a key is "not supported" in one column.
| Key | MV2 | MV3 | Firefox | Chrome | Test note |
|---|---|---|---|---|---|
manifest_version | yes | yes | yes | yes | Mandatory; first assertion |
name | yes | yes | yes | yes | Mandatory |
version | yes | yes | yes | yes | Mandatory; semver in Firefox AMO |
action | no | yes | yes | yes | Unified popup slot |
browser_action | yes | no | yes (MV2 only) | yes (MV2 only) | Renames to action in MV3 |
page_action | yes | no | yes (MV2; different in MV3) | yes (MV2 only) | Firefox keeps a different page-action shape |
background | yes | yes | yes | yes | Shape changes per above |
browser_specific_settings | yes | yes | yes | no | Tests asserting extension ID stability rely on this in Firefox |
content_scripts | yes | yes | yes | yes | Same shape |
content_security_policy | yes | yes | yes | yes | Shape changes (object in MV3) |
declarative_net_request | yes | yes | yes | yes | Replaces webRequest-blocking surface |
externally_connectable | yes | yes | no | yes | Cross-origin runtime messaging - Chrome only |
host_permissions | no | yes | yes | yes | New in MV3; permission-prompt test surface |
offline_enabled | yes | yes | no | yes | Chrome-only manifest key |
optional_host_permissions | no | yes | yes | yes | Runtime host grants |
optional_permissions | yes | yes | yes | yes | Runtime API grants |
permissions | yes | yes | yes | yes | API permissions only in MV3 |
protocol_handlers | yes | yes | yes (Firefox only) | no | Firefox-only register-protocol surface |
sidebar_action | yes | yes | yes (Firefox/Opera) | no | Sidebar UI - not in Chrome |
storage (as manifest key) | yes | yes | no | yes | Note: the storage API works in both |
theme_experiment | yes | yes | yes (Firefox only) | no | Experimental theming |
user_scripts (manifest key) | yes | no | yes (MV2 only) | yes (MV2 only) | MV3 replaces with userScripts API |
web_accessible_resources | yes | yes | yes | yes | Shape changes (object array in MV3) |
browser_specific_settings.gecko
Firefox-specific metadata (extension ID, min Firefox version) lives under browser_specific_settings.gecko. Chrome silently ignores this key:
{
"browser_specific_settings": {
"gecko": {
"id": "@addon-example",
"strict_min_version": "42.0"
}
}
}Test note: AMO submission validation requires a stable gecko.id for signing - a test asserting the built zip carries a deterministic ID prevents accidental ID drift across builds.
Service-worker constructs
Offscreen documents
DOM-requiring work in MV3 goes to an offscreen document:
chrome.offscreen.createDocument({
url: chrome.runtime.getURL('offscreen.html'),
reasons: ['CLIPBOARD'],
justification: 'testing the offscreen API',
});Offscreen documents communicate with the service worker via runtime.sendMessage / runtime.onMessage only - they don't share other extension APIs.
Keep-alive heartbeat
The keep-alive pattern (calling chrome.runtime.getPlatformInfo on a ~25s interval to reset the idle timer, or writing to chrome.storage.local every 20s) is documented but Chrome explicitly limits its use to enterprise/education managed extensions, "reserves the right to take action" against others, and notes a waitUntil()-style API is under discussion in the W3C WebExtensions Community Group (WECG).
Overview
Manifest V3 (MV3) is the current packaging contract for Chromium-family browser extensions; Firefox supports it as a peer with a documented divergence list. The manifest is the only declarative input both browsers see - every test surface (background lifecycle, permission prompt, content-script injection, web-accessible-resource fetch) hangs off a manifest field. Knowing which field maps to which runtime behaviour is what lets a test author decide whether a behaviour is testable in unit, integration, or full-browser scope.
This reference backs the fixtures in the main skill and its reload-matrix and storage-tests references. The summary tables below are the quick lookup; exact MV2/MV3 field shapes, the full Firefox/Chrome key matrix, and the offscreen-document
For Playwright-driven MV3 popup / content-script fixtures and assertion recipes see the main skill (Chromium-only) and extension-surface-tests.md (opens in new window). This reference is browser-agnostic, manifest-field-keyed, and covers the Firefox column explicitly.
When to read this
Manifest fields that change between MV2 and MV3
Summary lookup; the exact MV2/MV3 shapes for background, host_permissions, and web_accessible_resources are in manifest-matrix.md (opens in new window).
| Field | MV2 | MV3 | Test implication |
|---|---|---|---|
manifest_version | 2 | 3 | First assertion in any conformance test |
background | { "scripts": [...], "persistent": false } | { "service_worker": "sw.js", "type": "module"? } | Lifecycle test moves from "always running" to "wake on event, terminate idle" |
action / browser_action / page_action | browser_action or page_action | action (unified) | Popup-rendering tests target the unified action slot |
permissions | API + host strings mixed | API strings only | API-permission tests stay; host-permission tests move (see next row) |
host_permissions | (did not exist) | match patterns moved here from permissions | Each host pattern is a runtime permission prompt - testable as a user gesture flow |
optional_host_permissions | (did not exist) | runtime-requestable hosts | Tests must drive permissions.request from a user gesture |
web_accessible_resources | flat string array | array of { resources: [...], matches: [...] } objects | Cross-origin fetch of an extension resource is only allowed from a matching matches pattern |
content_security_policy | string | object with extension_pages / sandbox keys | No inline <script>, no remotely hosted code, no eval - testable via load-time CSP violations |
Service-worker runtime restrictions (MV3-only test surface)
The MV3 background context is a service worker - not a persistent page - and inherits the standard service-worker constraints plus a few extension-specific ones. The offscreen-document construct and the keep-alive heartbeat are detailed in manifest-matrix.md (opens in new window).
| Constraint | MV2 | MV3 | Test implication |
|---|---|---|---|
DOM / window | available | unavailable | Anything touching DOM moves to an offscreen document (chrome.offscreen.createDocument) |
XMLHttpRequest | available | unavailable - use fetch() | XHR-using test fixtures must be rewritten |
localStorage | available | unavailable - use chrome.storage.local | Tests asserting persisted state must use chrome.storage.* (see storage-tests.md (opens in new window)) |
setTimeout / setInterval | reliable | cancelled when worker terminates - use chrome.alarms | Tests timing background work must use alarms, not timers |
| Listener registration | top-level or async | must be synchronous at top level | Async-registered listeners are "not guaranteed to work in Manifest V3" |
| Lifecycle | persistent | ephemeral (start -> run -> terminate, repeated) | Globals reset; storage is source of truth |
Two load-bearing quotes from cr-mig-sw (opens in new window): "Registering a listener asynchronously (for example inside a promise or callback) is not guaranteed to work in Manifest V3," and "[Service workers] are ephemeral, which means they'll likely start, run, and terminate repeatedly."
Firefox vs Chrome manifest key matrix
The full 23-key availability matrix (MV2 / MV3 x Firefox / Chrome) and the browser_specific_settings.gecko example are in manifest-matrix.md (opens in new window). The divergences that most often break a cross-browser test:
MV3 platform availability
Manifest V3 is supported generally in Chrome 88 or later, with some replacement APIs landing after 88. minimum_chrome_version in the manifest pins a floor for users on stable channels.
The MV2 deprecation timeline lives on a separate Chrome "Manifest V2 support timeline" page - cite by stable URL (developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) and read live, as dates have shifted multiple times.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Assuming MV3 permissions still accepts host match patterns | Lint passes; runtime drops them silently | Move all host patterns to host_permissions |
Testing background.scripts array under MV3 | Field doesn't exist in MV3; manifest fails to load | Use background.service_worker single string |
Using localStorage in service-worker tests | Throws in MV3 | Use chrome.storage.local (see storage-tests.md (opens in new window)) |
Registering chrome.runtime.onMessage inside a promise | Listener may not fire after worker restart in MV3 | Register synchronously at top level |
Testing flat-string web_accessible_resources under MV3 | Resources unreachable from page context | Use object form { resources, matches } |
| Treating Firefox MV3 as Chrome MV3 | externally_connectable, offline_enabled not supported; browser_specific_settings required | Run Firefox tests with web-ext (see web-ext-firefox.md (opens in new window)) and gate Chrome-only assertions |
Using setTimeout to delay background work | Cancelled on worker termination | Use chrome.alarms.create |
| Polling via 1s heartbeat to keep SW alive | Chrome explicitly limits keepalive abuse | Restructure to event-driven; offscreen document for long DOM work |
Limitations
References
Extension reload matrix - what a code edit re-evaluates
View source (opens in a new tab) (opens in new window)Companion reference for playwright-extension-fixtures. Consult when an edit appears to have no effect and you need to know which extension surface requires an explicit reload - the manual chrome://extensions card-refresh gesture this fixture automates.
Per the Chrome Extensions "Hello World" tutorial (opens in new window):
| Component edited | Reload action required |
|---|---|
manifest.json | Click refresh on the extension card |
Service worker (background.service_worker) | Click refresh on the extension card |
| Content scripts | Click refresh on the extension card plus refresh the host page |
| Popup HTML / JS | None, next open re-evaluates |
| Options page | None, next open re-evaluates |
| Other extension HTML pages | None |
An automated harness reproduces the "click refresh on the card" gesture either by toggling chrome.management.setEnabled(id, false) then setEnabled(id, true) (requires the "management" permission per the chrome.management reference (opens in new window); management.getSelf is the no-permission exception), or by closing and re-launching the persistent browser context.
Anti-patterns the matrix prevents:
chrome.storage test suite - area selection, quotas, events
View source (opens in a new tab) (opens in new window)Companion reference for playwright-extension-fixtures. Consult when an extension persists state and no test proves the chosen storage area survives its quota limits. Every template runs from this fixture's service-worker context (sw.evaluate(...)).
chrome.storage has four areas with non-overlapping quotas, persistence semantics, and enterprise-policy posture. Picking the wrong one is a class of bug the type checker can't catch: storage.sync silently rejects writes past its limits, storage.session evaporates on browser restart, and storage.managed is read-only and throws on write.
Area decision matrix
All constants per cr-storage (opens in new window):
| Area | QUOTA_BYTES | Per-item | Lifetime | Cross-device | Writer |
|---|---|---|---|---|---|
storage.local | 10,485,760 (10 MB) | none documented | until extension removal | no | extension |
storage.sync | 102,400 (~100 KB) | 8,192 (8 KB) | persistent, synced | yes (when user signed in) | extension |
storage.session | 10,485,760 (10 MB) | none documented | cleared on disable, reload, update, or browser restart; MV3-only | no | extension |
storage.managed | - | - | as long as policy is in effect | varies | admin only - read-only for the extension |
storage.sync throughput caps: MAX_ITEMS = 512, MAX_WRITE_OPERATIONS_PER_MINUTE = 120, MAX_WRITE_OPERATIONS_PER_HOUR = 1,800. MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE is deprecated ("no longer has a sustained write operation quota" per cr-storage (opens in new window)) - drop any assertion on it.
Version-specific floors - pin tests to the live constant, never a hard-coded number: storage.local was 5 MB in Chrome 113 and earlier; storage.session was 1 MB in Chrome 111 and earlier (per cr-storage (opens in new window)).
Quota-exceeded template (per-item)
Quota-exceeded writes fail immediately and set runtime.lastError (callback form) or reject the Promise (async form); drive both paths:
test('storage.sync rejects on per-item quota exceeded', async ({ context }) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const result = await sw.evaluate(async () => {
const oversized = 'x'.repeat(9 * 1024); // 9 KB > 8 KB limit
try {
await chrome.storage.sync.set({ big: oversized });
return { ok: true };
} catch (e: any) {
return { ok: false, message: e.message };
}
});
expect(result.ok).toBe(false);
// exact message is unstable; assert quota-shaped text
expect(result.message).toMatch(/quota|QUOTA_BYTES/i);
});Callback-path equivalence:
const err = await sw.evaluate(() => new Promise<string | null>(resolve => {
const oversized = 'x'.repeat(9 * 1024);
chrome.storage.sync.set({ big: oversized }, () => {
resolve(chrome.runtime.lastError?.message ?? null);
});
}));
expect(err).toMatch(/quota|QUOTA_BYTES/i);The remaining suite cells
| Test | Asserts |
|---|---|
storage.sync total-quota | a batch write past QUOTA_BYTES = 102,400 rejects (e.g. 13 items x ~8 KB) |
MAX_ITEMS (512) | the 513th unique key rejects |
storage.onChanged shape | listener receives (changes, areaName) with changes[key] = { oldValue?, newValue? } - same shape on Firefox per mdn-storage (opens in new window) |
| multi-area isolation | a storage.local write is invisible to storage.sync.get |
storage.managed read-only | any managed.set rejects ("Trying to modify this namespace results in an error" per mdn-storage (opens in new window)) |
| session lifetime | storage.session data does not survive browser restart |
storage.onChanged shape test:
const event = await sw.evaluate(() => new Promise<any>(resolve => {
chrome.storage.onChanged.addListener(function listener(changes, area) {
chrome.storage.onChanged.removeListener(listener);
resolve({ changes, area });
});
chrome.storage.local.set({ theme: 'dark' });
}));
expect(event.area).toBe('local');
expect(event.changes.theme.newValue).toBe('dark');
expect(event.changes.theme.oldValue).toBeUndefined(); // first writeFirefox-Chrome divergences
| Concern | Chrome | Firefox |
|---|---|---|
storage.sync quotas | 102,400 / 8,192 / 512 / 1,800 per hour per cr-storage (opens in new window) | MDN does not enumerate quota numbers on the high-level page; align to the StorageArea sub-page values and verify on Firefox stable |
storage.session MV3-only | Yes per cr-storage (opens in new window) | Yes per mdn-storage (opens in new window) - skip session tests when targeting MV2 |
storage.managed | Available; admin-configured per OS | Available per mdn-storage (opens in new window); policy injection mechanism differs per OS |
| Sync sign-in | Chrome account required | Firefox account required - skip sync round-trip tests on machines without sign-in |
Firefox's storage.local "persists even when users clear browsing history/data (unlike localStorage)" per mdn-storage (opens in new window) - useful for a clear-history regression test.
Anti-patterns and limits
References
web-ext - Mozilla's CLI for Firefox WebExtension testing
View source (opens in a new tab) (opens in new window)Companion reference for manifest-v3-test-surface.md (opens in new window). Consult when the extension targets Firefox (signing is mandatory for distribution) or when cross-browser test runs need a single CLI that drives both Firefox and Chromium against the same source tree.
web-ext is Mozilla's reference CLI for WebExtension development. Per the mozilla/web-ext README (opens in new window) it bundles run, lint, sign, build, and docs; v8 added dump-config and reworked signing to use the AMO submission API by default (per the web-ext command reference (opens in new window)). --source-dir / -s is a global option; --artifacts-dir (default ./web-ext-artifacts) controls where build / sign write zips.
Install
npm install --save-dev web-ext # per-project, preferred for CI determinismMozilla recommends the current Node LTS (per we-readme (opens in new window)). A web-ext-config.cjs file pins flags per run; web-ext dump-config (v8, per we-cmd (opens in new window)) prints the resolved configuration as JSON for diffing.
Lint
web-ext lint wraps mozilla/addons-linter (opens in new window) and emits AMO-compatible warnings. CI-friendly invocation:
web-ext lint \
--source-dir ./dist \
--output json \
--pretty \
--warnings-as-errors > lint-report.json--warnings-as-errors / -w is the right CI default - per we-cmd (opens in new window) it escalates lint warnings (unsupported manifest fields, MV2-only APIs flagged for MV3) into a non-zero exit code. Other flags: --metadata, --self-hosted (disables AMO-related messages - never on AMO-bound builds), --boring.
Output shape: { "count", "summary": { "errors", "notices", "warnings" }, "metadata", "errors": [{ "code", "message", "file", "line", "column" }], ... } (per addons-linter; spot-check its changelog before pinning a parser). Parse with jq '.summary.errors + .summary.warnings'.
Run
web-ext run builds the extension, installs it into a fresh temporary Firefox profile, then watches the source directory and reloads on edit (per we-cmd (opens in new window)):
web-ext run \
--source-dir ./dist \
--firefox=firefox \
--start-url 'about:debugging#/runtime/this-firefox'--firefox aliases (per we-cmd (opens in new window)): firefox, beta, nightly, deved / firefoxdeveloperedition, or a binary path. To pin a profile: --firefox-profile=qa-profile --profile-create-if-missing --keep-profile-changes (the last persists profile modifications across runs per we-cmd (opens in new window)).
Targets (per we-cmd (opens in new window) --target): firefox-desktop, firefox-android (wired via the --adb-* flag family), and chromium:
web-ext run --source-dir ./dist --target chromium --chromium-binary "$(which chromium)"The chromium target is the lowest-effort parity smoke-test; deep Chromium test surface lives in playwright-extension-fixtures.
Build
web-ext build -s ./dist -a ./build/artifacts --overwrite-dest \
--filename 'my-extension-{version}.zip'Per we-cmd (opens in new window), --filename defaults to {name}-{version}.zip; --overwrite-dest is required when the artefact path already exists. Build runs lint first and exits non-zero on lint failure.
Sign
Per we-cmd (opens in new window), web-ext sign v8 uses the AMO submission API by default and --channel is required:
export WEB_EXT_API_KEY='user:12345:1'
export WEB_EXT_API_SECRET='abcdef...'
web-ext sign -s ./dist --channel listed \
--amo-metadata ./amo-metadata.json \
--upload-source-code ./source.tar.gzChannel semantics (quoted from we-cmd (opens in new window)): with listed the extension "gets submitted for public listing"; with unlisted it "gets submitted for signing for self-distribution." Key flags: --api-key / --api-secret (JWT for the AMO API), --amo-metadata (required for a first listed version), --timeout (default 300000 ms), --approval-timeout (default 900000 ms, v8) - AMO can take longer in queue, requiring follow-up polling. Update submissions require an extension ID in the manifest (browser_specific_settings.gecko.id) per we-cmd (opens in new window). The signed .xpi lands in --artifacts-dir.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
web-ext sign without --channel | v8 requires it per we-cmd (opens in new window); command refuses to run | Always specify listed or unlisted |
Re-using a profile without --keep-profile-changes | Profile changes lost between runs; tests appear non-deterministic | Add the flag per we-cmd (opens in new window) |
web-ext lint without -w in CI | Warnings silently pass; AMO submission still rejects | --warnings-as-errors |
| Signing without an extension ID in the manifest | Update submission fails per we-cmd (opens in new window) | Add browser_specific_settings.gecko.id (see SKILL.md matrix) |
Committing web-ext-artifacts/ | Repo bloats with binaries | .gitignore + CI artefact upload |