playwright-extension-fixtures
Author the Playwright fixture layer 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 - plus, in references/, the extension load / reload matrix (which edit re-evaluates which surface), the `chrome.storage` test suite (area selection, quota-exceeded, `storage.onChanged`, managed read-only), and the per-surface assertion recipes (popup, content script, background messaging, MV3 auto-suspend survival). Use when authoring or debugging the fixture a Chromium extension test imports, when an edit appears to have no effect and you need the reload matrix, or when authoring popup / content-script / storage tests.
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.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
Extension surface test recipes
View source (opens in new window)Extension surface test recipes
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
Extension reload matrix - what a code edit re-evaluates
View source (opens in new window)Extension reload matrix - what a code edit re-evaluates
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 new window)chrome.storage test suite - area selection, quotas, events
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
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.
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); references/ carry Mozilla's web-ext CLI (lint via addons-linter, run on firefox-desktop / firefox-android / chromium targets, deterministic build, AMO sign). Use as the manifest-surface reference when authoring extension tests across both browsers, or when driving Firefox runs / AMO signing with web-ext.