Test Chromium browser extensions (MV3) with Playwright via `launchPersistentContext` + `--load-extension` / `--disable-extensions-except` flags. Cover service worker, popup pages, content scripts, message passing, and `chrome.runtime` API mocking. Service worker auto-suspends ~30s; Playwright keeps the Worker object alive across restarts. Use when a repo builds a Chromium extension (a `manifest.json` with `manifest_version: 3` and a built `dist/`) and its popup, content-script injection, background worker, or `chrome.storage` behavior needs automated coverage.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Per the Playwright Chrome extensions docs: load extensions via
launchPersistentContext with --disable-extensions-except +
--load-extension args. "Google Chrome and Microsoft Edge removed
the command-line flags needed to side-load extensions" - use the
bundled Chromium browser, not Chrome channel.
chrome.storage reads/writes survive reload.dist/ holds the MV3 manifest.json
(manifest_version: 3) and bundled popup.html / content scripts.tests/fixtures.ts extension-loading fixture (Step 1) so every
test receives a persistent context and the resolved extensionId.chrome-extension://${extensionId}/popup.html and
content-script injection on matching page URLs (Steps 2-3).chrome.storage, and
service-worker auto-suspend - with
references/advanced-recipes.md.chromium channel with headless: true for
CI runs (Step 7).npx playwright test; consult the Anti-patterns table when the
extension fails to load or the service worker is missing.tests/fixtures.ts:
import { test as base, chromium, type BrowserContext } from '@playwright/test';
import path from 'path';
const pathToExtension = path.resolve(__dirname, '..', 'dist');
export const test = base.extend<{
context: BrowserContext;
extensionId: string;
}>({
context: async ({}, use) => {
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
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;Per Playwright Chrome extensions docs. The extensionId fixture
extracts the ID from the service worker URL - needed to navigate to
chrome-extension://${extensionId}/popup.html.
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');
});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 ↔ background), chrome.storage persistence across
reload, and surviving MV3 service-worker auto-suspend (~30s) are covered as
ready-to-paste recipes in
references/advanced-recipes.md. Each reuses
the Step 1 fixture.
For CI (no display server), use the chromium channel + headless
new mode. Per Playwright Chrome extensions docs, headless support
landed in modern Chromium. Configure:
const context = await chromium.launchPersistentContext('', {
channel: 'chromium',
headless: true, // 'new' headless required for extensions
args: [...]
});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.
npm run build emits dist/manifest.json (manifest_version: 3) plus
popup.html and content.js.tests/fixtures.ts from Step 1; the extensionId fixture reads the SW
URL, e.g. chrome-extension://abcdefghijklmnop/.chrome-extension://${extensionId}/popup.html,
clicks [data-testid="increment"], and asserts the count reads 1.https://example.com/, waits for the
injected marker, and asserts three mark[data-extension-marker] nodes.{ pref: 'dark' }, reloads the popup,
and asserts the value survives.npx playwright test runs headed locally; CI reruns it with
channel: 'chromium', headless: true (Step 7).Result: a green run confirms popup rendering, content-script injection, and storage persistence across reload - the extension's core surfaces are covered.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Use chromium.launch() (non-persistent) | Extension never loads; persistent context required | Always launchPersistentContext (Step 1) |
Use chrome channel | Side-load flags removed in stable Chrome / Edge | Use bundled chromium channel (Step 7) |
Hardcode extensionId from local install | ID changes per build / per machine | Extract from SW URL (Step 1 fixture) |
| Test in MV2 mode | Deprecated; production extensions are MV3 | Always test against the manifest version you ship |
| Skip waitForEvent('serviceworker') | Race: SW not yet registered | Always await the event (Step 1) |
web-ext tooling).chrome.declarativeNetRequest) cannot be
fully unit-tested without a browser; integration tests are required.