Playwright Extension Fixtures
ASecurityAuthor 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-e...
- 10 stars
- 0 votes
- 0 copies
- 1 view
- Added October 12, 2026
Works with
Security analysis
100/100Pro scans all 5 files and shows the line behind each finding
npx -y skills add testland/qa --skill playwright-extension-fixtures --agent claude-codeAre you the author of Playwright Extension Fixtures?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/testland-playwright-extension-fixtures)---
name: playwright-extension-fixtures
description: "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."
metadata:
keywords: "playwright, chromium, launchpersistentcontext, test-fixtures, browser-extension"
version: "1.2.7"
---
# playwright-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][pw-ext], 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](references/extension-surface-tests.md)
and builds on this fixture.
[pw-ext]: https://playwright.dev/docs/chrome-extensions
Composes with:
- [references/extension-surface-tests.md](references/extension-surface-tests.md) - per-surface assertion recipes (popup, content script, messaging, storage persistence, auto-suspend) run from this fixture.
- [references/reload-matrix.md](references/reload-matrix.md) - which edits require which reload; the manual `chrome://extensions` gesture this fixture automates.
- [references/storage-tests.md](references/storage-tests.md) - the `chrome.storage` area / quota / event test suite run from this fixture's SW context.
- `manifest-v3-test-surface-reference` - the manifest fields the fixture is testing against.
## 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].
- 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], `fixtures.ts`:
```typescript
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] |
|---|---|
| `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 |
### Usage in spec files
Per [pw-ext]:
```typescript
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
```bash
npx playwright test
```
Defaults to headed for extensions (per [pw-ext], non-`chromium`
channels require headed mode).
### Local + CI headless
```bash
npx playwright test --headed=false
```
Requires `channel: 'chromium'` in the fixture per [pw-ext]:
> "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]:
> "Google Chrome and Microsoft Edge removed the command-line flags
> needed to side-load extensions."
### MV2 fallback for extensionId
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:
```typescript
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:
```typescript
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], 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:
```typescript
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] |
| `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:
```yaml
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].
## Anti-patterns
| 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 |
## Limitations
- **Chromium-only.** Per [pw-ext], the fixture covers
Chromium-family extensions only. Firefox WebExtensions test
differently - see the `web-ext` reference in
`manifest-v3-test-surface-reference`
(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
`manifest-v3-test-surface-reference`).
- **Custom args at own risk.** Per [pw-ext]: *"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
- Playwright Chrome extensions docs (the fixture pattern, channel
guidance, MV3 SW behaviour, headless support) - [pw-ext].
- `chromium.launchPersistentContext` API -
[playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context](https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context).
- [references/extension-surface-tests.md](references/extension-surface-tests.md) -
the popup / content-script / messaging assertion playbook this
fixture feeds.
- [references/reload-matrix.md](references/reload-matrix.md) - the
load / reload matrix and its automation equivalents.
- [references/storage-tests.md](references/storage-tests.md) - the
`chrome.storage` quota / area / event test suite.
- Composes:
`manifest-v3-test-surface-reference`.
Files in this skill
- .tessl-plugin/plugin.json
- SKILL.md
- references/extension-surface-tests.md
- references/reload-matrix.md
- references/storage-tests.md
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.