playwright-extension-fixtures
Author the lower-level Playwright fixture pattern that every Chromium extension test depends on - `chromium.launchPersistentContext` with `--disable-extensions-except=$DIR` + `--load-extension=$DIR`, the `channel: 'chromium'` selection that unlocks headless extension support, the `context.serviceWorkers()` + `waitForEvent('serviceworker')` race-handling pattern, and the `extensionId = serviceWorker.url().split('/')[2]` extraction recipe. This is the launch-and-load layer shared by every extension test, not the assertions run on top of it. Use when authoring or debugging the fixture a Chromium extension test imports - popup, content script, service worker, options page, or side panel.
Install with skills.sh (any agent)
npx skills add testland/qa --skill playwright-extension-fixturesplaywright-extension-fixtures
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 is distinct from browser-extension-tests (MV3 popup + content-script assertions); this is the lower-level Playwright fixture pattern (launchPersistentContext + --disable-extensions-except + --load-extension) shared by all extension tests. The neighbour skill is the "what to assert" playbook; this skill is the "how to launch" reference that any test - extension popup, content script, service worker, options page, side panel - needs to import first.
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 | Check chrome://extensions manually first via chrome-extension-test-loader |
| 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
Related skills
chrome-extension-messaging-tests
Asserts Chrome extension message-passing behaviour against a running extension: one-shot `chrome.runtime.sendMessage` plus the literal `return true` that holds the response channel open for an async `sendResponse`, `chrome.tabs.sendMessage` into one tab's content script, long-lived `chrome.runtime.connect` ports and their `onDisconnect` triggers, web-page messages gated by `externally_connectable`, and `chrome.runtime.connectNative` native-messaging hosts. Covers the payload rules a test must respect (Chrome uses JSON serialization rather than structured clone, so `Map` / `Set` / `Date` do not round-trip; maximum message size is 64 MiB) and the first-listener-wins rule when several `onMessage` listeners are registered. Scope is messaging behaviour on an already-running extension, not the install or reload step. Use when a message reaches no listener, a `sendResponse` callback never fires, a port disconnects mid-test, or a page origin has to be proven allowed before publishing.
chrome-extension-test-loader
Loads an unpacked Chrome / Chromium extension for testing through the `chrome://extensions` Developer-mode flow: the minimum loadable `manifest.json`, the Load-unpacked directory-not-file selection, toolbar pinning, and the reload matrix deciding what a code edit actually re-evaluates (`manifest.json`, the background service worker, and content scripts need an explicit card refresh, content scripts additionally need a host-page refresh, while popup / options / other extension HTML pages re-evaluate on next open). Also covers reading the red Errors card, where service-worker and content-script logs surface, and the `--load-extension` and `web-ext --target chromium` equivalents that move the same load into CI. Scope is getting a build directory loaded and reloaded, not the runtime behaviour asserted afterwards. Use when a freshly built extension directory has to go into Chrome for the first time, or when an edit appears to have no effect and you need to know which surface requires an explicit reload.
extension-storage-test-author
Build-an-X workflow that emits a `chrome.storage` test suite. Picks the right area (`storage.local` 10 MB / `storage.sync` 100 KB total + 8 KB per item + 512 items + 1,800 writes/hour / `storage.session` 10 MB in-memory MV3-only / `storage.managed` read-only enterprise-policy) per access pattern, then generates tests for quota-exceeded behavior (`runtime.lastError` callback path + rejected promise async path), `storage.sync` per-item + total quotas, `storage.onChanged` event payload shape, multi-area write isolation, and Firefox-Chrome divergences (Firefox `storage.sync` quotas align with Chrome per MDN; Firefox `storage.managed` available; Firefox `storage.session` MV3-only). Output: a per-extension storage test file + matrix asserting the right area was chosen. Use when an extension persists state across sessions or devices and no test proves the chosen storage area survives its quota limits.
manifest-v3-test-surface-reference
Pure-reference catalog of the Manifest V3 test surface for Firefox + Chromium browser extensions. Maps each manifest field that changed from MV2 (manifest_version, background.service_worker vs background.scripts, action vs browser_action / page_action, host_permissions split, web_accessible_resources object-form, content_security_policy object-form), the runtime restrictions service workers impose (no DOM, no XMLHttpRequest, no localStorage, ephemeral lifecycle, synchronous listener registration, alarms instead of setTimeout), and the Firefox-vs-Chrome key matrix (browser_specific_settings.gecko, externally_connectable / offline_enabled gaps, MV2-only user_scripts manifest key). Use as the manifest-surface reference when authoring extension tests across both browsers.
mv2-to-mv3-migration-test-checklist
Build-an-X workflow that emits a per-extension MV2 → MV3 migration test checklist. Walks the six canonical migration sections (manifest, service worker, API calls, declarative net request, security, publication) per the Chrome migration checklist, then for each one inventories the source MV2 manifest, names the MV3 replacement field / API, and emits the verification test cases. Covers the Firefox-Chrome divergence cells (page_action retained in Firefox, event pages allowed in Firefox 106+, host-permission install-prompt behavior changed in Firefox 127, web_accessible_resources `use_dynamic_url` Chromium-only). Output: a checklist artifact with per-section test cases the migrating extension must pass before publishing the MV3 build. Use when migrating an MV2 extension to Manifest V3 and the team needs section-by-section evidence the migration is complete.
web-ext-cli-mozilla
Author, lint, run, build, and sign a Firefox / Chromium WebExtension using Mozilla's `web-ext` CLI v8. Covers `web-ext lint` (addons-linter wrapper, JSON output for CI), `web-ext run` (temporary install in firefox-desktop / firefox-android / chromium targets with hot-reload), `web-ext build` (deterministic zip), and `web-ext sign` (AMO submission API, listed vs unlisted channels, JWT credentials). Use 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.