playwright-extension-fixtures

Use when writing or debugging Playwright tests for a Chromium browser extension: the persistent-context fixture that loads the extension and finds its ID, why an edit seems to have no effect (reload matrix), and popup, content-script, storage and MV3 service-worker tests.

/plugin install qa-browser-extension@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

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 a Playwright test against any unpacked Chromium extension (popup, content script, service worker, options page, side panel, devtools page).
  • Headless CI runs of an extension - the channel: 'chromium' selection is what unlocks headless extension support per pw-ext (opens in new window).
  • Diagnosing a "tests pass locally headed, fail headless" bug - the channel / flag matrix in this skill is the first checkpoint.
  • Sharing the fixture across multiple spec files in the same repository (a fixtures.ts that every spec imports).

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

ElementWhy 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=$DIRPrevents any pre-installed extension from also loading and confusing assertions
--load-extension=$DIRLoads 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 test

Defaults to headed for extensions (per pw-ext (opens in new window), non-chromium channels require headed mode).

Local + CI headless

npx playwright test --headed=false

Requires channel: 'chromium' in the fixture per pw-ext (opens in new window):

"Headless mode for extensions is supported only when using the chromium channel."

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:

SymptomLikely causeFix
waitForEvent('serviceworker') times outManifest invalid or background.service_worker field missingLoad unpacked via chrome://extensions manually first and read the red Errors card
Extension loads in headed but not headlessChannel set to chrome / msedge instead of chromiumUse channel: 'chromium' per pw-ext (opens in new window)
extensionId extracts an empty stringURL 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:

  • npx playwright install --with-deps chromium - installs the bundled Chromium that the channel: 'chromium' fixture uses.
  • No xvfb needed - headless Chromium extensions work per pw-ext (opens in new window).

Anti-patterns

Anti-patternWhy it failsFix
chromium.launch() instead of launchPersistentContextExtension 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 failUse channel: 'chromium'
Hardcoding extensionId from a local installID changes per build / per machineExtract from SW URL via the fixture
Skipping the waitForEvent('serviceworker') fallbackRace: SW not yet registered → [] from serviceWorkers() → undefined IDAlways include the if-empty branch per pw-ext (opens in new window)
Persistent userDataDir in CIAuth state leaks between runs; flakyUse '' for CI determinism
Catching "Service worker restarted" as a permanent failurePer pw-ext (opens in new window) this is recoverable; the SW resumesRetry the evaluate once
Using extra --args without verifying they don't conflictPer pw-ext (opens in new window): "some of them may break Playwright functionality"Add browser args defensively, one at a time

Limitations

  • Chromium-only. Per pw-ext (opens in new window), the fixture covers Chromium-family extensions only. Firefox WebExtensions test differently - see the web-ext reference in references/manifest-v3-test-surface.md (references/web-ext-firefox.md) for the Mozilla-side runner.
  • MV3 SW lifecycle is asynchronous. The 30s auto-suspend is non-deterministic; tests timing-sensitive to it should use chrome.alarms (see references/manifest-v3-test-surface.md).
  • Custom args at own risk. Per pw-ext (opens in new window): "Use custom browser args at your own risk, as some of them may break Playwright functionality."
  • --disable-extensions-except requires absolute path. Relative paths silently fail to load - always pass path.resolve(...).
  • devtools_page / side-panel test surfaces aren't covered here. The base fixture works; the assertion patterns for those surfaces are extension-specific.

References

Reference

Extension surface test recipes

View source (opens in a new tab) (opens in new window)

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.

  1. npm run build emits dist/manifest.json (manifest_version: 3) plus popup.html and content.js.
  2. Add the fixture from the main skill; the extensionId fixture reads the SW URL, e.g. chrome-extension://abcdefghijklmnop/.
  3. The popup test opens chrome-extension://${extensionId}/popup.html, clicks [data-testid="increment"], and asserts the count reads 1.
  4. The content-script test visits https://example.com/, waits for the injected marker, and asserts three mark[data-extension-marker] nodes.
  5. The storage recipe sets { pref: 'dark' }, reloads the popup, and asserts the value survives.
  6. npx playwright test runs headed locally; CI reruns it with channel: 'chromium', headless: true per the main skill.

Result: a green run confirms popup rendering, content-script injection, and storage persistence across reload - the extension's core surfaces are covered.

Limitations

References

Reference

Manifest matrix + field shapes

View source (opens in a new tab) (opens in new window)

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.

KeyMV2MV3FirefoxChromeTest note
manifest_versionyesyesyesyesMandatory; first assertion
nameyesyesyesyesMandatory
versionyesyesyesyesMandatory; semver in Firefox AMO
actionnoyesyesyesUnified popup slot
browser_actionyesnoyes (MV2 only)yes (MV2 only)Renames to action in MV3
page_actionyesnoyes (MV2; different in MV3)yes (MV2 only)Firefox keeps a different page-action shape
backgroundyesyesyesyesShape changes per above
browser_specific_settingsyesyesyesnoTests asserting extension ID stability rely on this in Firefox
content_scriptsyesyesyesyesSame shape
content_security_policyyesyesyesyesShape changes (object in MV3)
declarative_net_requestyesyesyesyesReplaces webRequest-blocking surface
externally_connectableyesyesnoyesCross-origin runtime messaging - Chrome only
host_permissionsnoyesyesyesNew in MV3; permission-prompt test surface
offline_enabledyesyesnoyesChrome-only manifest key
optional_host_permissionsnoyesyesyesRuntime host grants
optional_permissionsyesyesyesyesRuntime API grants
permissionsyesyesyesyesAPI permissions only in MV3
protocol_handlersyesyesyes (Firefox only)noFirefox-only register-protocol surface
sidebar_actionyesyesyes (Firefox/Opera)noSidebar UI - not in Chrome
storage (as manifest key)yesyesnoyesNote: the storage API works in both
theme_experimentyesyesyes (Firefox only)noExperimental theming
user_scripts (manifest key)yesnoyes (MV2 only)yes (MV2 only)MV3 replaces with userScripts API
web_accessible_resourcesyesyesyesyesShape 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

  • Authoring a new MV3 extension test - which manifest fields exist, which produce permission prompts, which are Firefox-only.
  • Migrating an existing MV2 test suite - the field-for-field rename table.
  • Triaging a behaviour gap between Chrome and Firefox - the divergence matrix.
  • Reviewing a PR that adds a new manifest field - does it need a Firefox counterpart, a host-permission consequence, or a CSP adjustment.

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).

FieldMV2MV3Test implication
manifest_version23First 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_actionbrowser_action or page_actionaction (unified)Popup-rendering tests target the unified action slot
permissionsAPI + host strings mixedAPI strings onlyAPI-permission tests stay; host-permission tests move (see next row)
host_permissions(did not exist)match patterns moved here from permissionsEach host pattern is a runtime permission prompt - testable as a user gesture flow
optional_host_permissions(did not exist)runtime-requestable hostsTests must drive permissions.request from a user gesture
web_accessible_resourcesflat string arrayarray of { resources: [...], matches: [...] } objectsCross-origin fetch of an extension resource is only allowed from a matching matches pattern
content_security_policystringobject with extension_pages / sandbox keysNo 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).

ConstraintMV2MV3Test implication
DOM / windowavailableunavailableAnything touching DOM moves to an offscreen document (chrome.offscreen.createDocument)
XMLHttpRequestavailableunavailable - use fetch()XHR-using test fixtures must be rewritten
localStorageavailableunavailable - use chrome.storage.localTests asserting persisted state must use chrome.storage.* (see storage-tests.md (opens in new window))
setTimeout / setIntervalreliablecancelled when worker terminates - use chrome.alarmsTests timing background work must use alarms, not timers
Listener registrationtop-level or asyncmust be synchronous at top levelAsync-registered listeners are "not guaranteed to work in Manifest V3"
Lifecyclepersistentephemeral (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:

  • externally_connectable, offline_enabled, storage (manifest key) - Chrome only; gate Chrome assertions on browser detection.
  • browser_specific_settings - Firefox requires it (stable gecko.id for AMO signing); Chrome silently ignores it.
  • protocol_handlers, sidebar_action, theme_experiment - Firefox only.
  • user_scripts (manifest key) - MV2 only; MV3 replaces it with the userScripts API.
  • page_action - Firefox keeps a different page-action shape in MV3; Chrome drops it for the unified action.

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-patternWhy it failsFix
Assuming MV3 permissions still accepts host match patternsLint passes; runtime drops them silentlyMove all host patterns to host_permissions
Testing background.scripts array under MV3Field doesn't exist in MV3; manifest fails to loadUse background.service_worker single string
Using localStorage in service-worker testsThrows in MV3Use chrome.storage.local (see storage-tests.md (opens in new window))
Registering chrome.runtime.onMessage inside a promiseListener may not fire after worker restart in MV3Register synchronously at top level
Testing flat-string web_accessible_resources under MV3Resources unreachable from page contextUse object form { resources, matches }
Treating Firefox MV3 as Chrome MV3externally_connectable, offline_enabled not supported; browser_specific_settings requiredRun Firefox tests with web-ext (see web-ext-firefox.md (opens in new window)) and gate Chrome-only assertions
Using setTimeout to delay background workCancelled on worker terminationUse chrome.alarms.create
Polling via 1s heartbeat to keep SW aliveChrome explicitly limits keepalive abuseRestructure to event-driven; offscreen document for long DOM work

Limitations

  • Firefox MV3 parity is incomplete. Several MV3 APIs landed on Firefox after Chrome; check MDN's per-API browser-compat tables (opens in new window) before asserting cross-browser behaviour.
  • declarativeNetRequest rule shape detail lives on a separate Chrome migration page (not extracted here) - fetch developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest before authoring rule-equivalence tests.
  • No coverage of content_security_policy.extension_pages exact-string syntax. The MV3 content_security_policy object shape is documented separately at developer.chrome.com/docs/extensions/reference/manifest/content-security-policy.
  • Edge / Opera / Brave coverage. Chromium-fork browsers track Chrome's MV3 timeline but may apply different store-side policies (Edge add-ons store, Opera add-ons). Cite the upstream Chrome source and add per-store rerun if needed.

References

Reference

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 editedReload action required
manifest.jsonClick refresh on the extension card
Service worker (background.service_worker)Click refresh on the extension card
Content scriptsClick refresh on the extension card plus refresh the host page
Popup HTML / JSNone, next open re-evaluates
Options pageNone, next open re-evaluates
Other extension HTML pagesNone

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:

  • Editing a content script and expecting the next page load to pick it up - content scripts need the card refresh and a host-page refresh per cr-hello (opens in new window).
  • Reloading the card after a popup-only edit - the popup re-evaluates on next open per cr-hello (opens in new window); the reload is wasted time and resets service-worker state.
Reference

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):

AreaQUOTA_BYTESPer-itemLifetimeCross-deviceWriter
storage.local10,485,760 (10 MB)none documenteduntil extension removalnoextension
storage.sync102,400 (~100 KB)8,192 (8 KB)persistent, syncedyes (when user signed in)extension
storage.session10,485,760 (10 MB)none documentedcleared on disable, reload, update, or browser restart; MV3-onlynoextension
storage.managed--as long as policy is in effectvariesadmin 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

TestAsserts
storage.sync total-quotaa 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 shapelistener receives (changes, areaName) with changes[key] = { oldValue?, newValue? } - same shape on Firefox per mdn-storage (opens in new window)
multi-area isolationa storage.local write is invisible to storage.sync.get
storage.managed read-onlyany managed.set rejects ("Trying to modify this namespace results in an error" per mdn-storage (opens in new window))
session lifetimestorage.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 write

Firefox-Chrome divergences

ConcernChromeFirefox
storage.sync quotas102,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-onlyYes per cr-storage (opens in new window)Yes per mdn-storage (opens in new window) - skip session tests when targeting MV2
storage.managedAvailable; admin-configured per OSAvailable per mdn-storage (opens in new window); policy injection mechanism differs per OS
Sync sign-inChrome account requiredFirefox 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

  • Happy-path-only set + get - quota silently drops past 8 KB / 100 KB; author the quota tests above.
  • Asserting exact error message strings - wording isn't pinned; match /quota/i.
  • storage.sync for binary / image data - the 100 KB total + 8 KB per-item caps aren't designed for blobs; use storage.local.
  • storage.session for cross-restart data - passes in a single session, fails in prod on cold start.
  • Hard-coding the storage.local limit - read the live constant, not a literal (5 MB vs 10 MB across Chrome versions).
  • Per mdn-storage (opens in new window), no area encrypts at rest ("Storage area is not encrypted, and shouldn't store confidential information").
  • Sync round-trip propagation needs two signed-in profiles - not testable in headless CI without account credentials.
  • Throughput caps are enforced server-side; tests can only assert the extension's own batching stays under the cap.

References

Reference

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 determinism

Mozilla 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.gz

Channel 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-patternWhy it failsFix
web-ext sign without --channelv8 requires it per we-cmd (opens in new window); command refuses to runAlways specify listed or unlisted
Re-using a profile without --keep-profile-changesProfile changes lost between runs; tests appear non-deterministicAdd the flag per we-cmd (opens in new window)
web-ext lint without -w in CIWarnings silently pass; AMO submission still rejects--warnings-as-errors
Signing without an extension ID in the manifestUpdate 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

Limitations

  • Linter is not tests. web-ext lint catches manifest issues and AMO policy violations; it does not exercise behaviour - pair with playwright-extension-fixtures.
  • Chromium target is run-only. No AMO-style signing path for Chrome; the Chrome Web Store has its own publishing flow.
  • No Firefox MV3 parity check. web-ext lint does not warn when a key is MV3-supported in Chrome but not Firefox (or vice versa); cross-reference the SKILL.md matrix manually.

References