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.
87%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
The canonical home for this skill is testland/playwright-extension-fixtures
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, 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:
chrome://extensions gesture this fixture automates.chrome.storage area / quota / event test suite run from this fixture's SW context.channel: 'chromium'
selection is what unlocks headless extension support per
pw-ext.fixtures.ts that every spec imports).Per pw-ext, 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;| Element | Why it matters per pw-ext |
|---|---|
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 |
--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 |
Per pw-ext:
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.
npx playwright testDefaults to headed for extensions (per pw-ext, non-chromium
channels require headed mode).
npx playwright test --headed=falseRequires channel: 'chromium' in the fixture per pw-ext:
"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:
"Google Chrome and Microsoft Edge removed the command-line flags needed to side-load extensions."
Per pw-ext, 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().
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.
Per pw-ext, 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;
}
}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 |
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 |
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.xvfb needed - headless Chromium extensions work per pw-ext.| Anti-pattern | Why it fails | Fix |
|---|---|---|
chromium.launch() instead of launchPersistentContext | Extension never loads per pw-ext | Always use the persistent variant |
channel: 'chrome' or channel: 'msedge' | Side-load flags removed per pw-ext; 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 |
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 this is recoverable; the SW resumes | Retry the evaluate once |
Using extra --args without verifying they don't conflict | Per pw-ext: "some of them may break Playwright functionality" | Add browser args defensively, one at a time |
web-ext reference in
references/manifest-v3-test-surface.md
(references/web-ext-firefox.md) for the Mozilla-side runner.chrome.alarms (see
references/manifest-v3-test-surface.md).--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.chromium.launchPersistentContext API -
playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context.chrome.storage quota / area / event test suite.testland/qa
aa16752
Canonical home
since Jul 23, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.