browser-extension-tests
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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill browser-extension-testsbrowser-extension-tests
Per the Playwright Chrome extensions docs (opens in new window): 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.
When to use
How to use
Step 1 - Test fixture for extension loading
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 (opens in new window). The extensionId fixture extracts the ID from the service worker URL - needed to navigate to chrome-extension://${extensionId}/popup.html.
Step 2 - Test the 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');
});Step 3 - Test 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);
});Steps 4-6 - Background, storage, and lifecycle recipes
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.
Step 7 - Headless mode
For CI (no display server), use the chromium channel + headless new mode. Per Playwright Chrome extensions docs (opens in new window), headless support landed in modern Chromium. Configure:
const context = await chromium.launchPersistentContext('', {
channel: 'chromium',
headless: true, // 'new' headless required for extensions
args: [...]
});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.
Anti-patterns
| 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) |
Limitations
References
Advanced browser-extension test recipes
View source (opens in new window)Advanced browser-extension test recipes
Background-worker, storage, and MV3 lifecycle recipes for browser-extension-tests. Every test below imports the test / expect fixtures from tests/fixtures.ts (Step 1 of the skill), which supply the persistent context and the resolved extensionId.
Test message passing (popup ↔ 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' });
});Test chrome.storage persistence
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();
});Related skills
pwa-install-flow-tests
Test the Progressive Web App install flow (Web App Manifest validation, `beforeinstallprompt` event handling, installability criteria, install prompt UX). Covers desktop install badge, Android WebAPK minting, iOS Add to Home Screen, and the `appinstalled` event. Use when a site ships a `link rel="manifest"` and users must be able to install it - especially after a manifest, icon, or `start_url` edit that could silently drop install eligibility.
service-worker-tests
Test service workers with Playwright (`context.serviceWorkers()` + `waitForEvent('serviceworker')`) and unit tests via `service-worker-mock`. Covers the MV3 service-worker lifecycle (~30s suspend), cache strategies (cache-first, network-first, stale-while-revalidate), and `evaluate()` continuity across worker restart. Use when a site registers a service worker and its caching / offline behavior is uncovered, or users report stale content surviving a deploy; for the install / add-to-homescreen flow use pwa-install-flow-tests, and to design (not test) the caching policy use sw-cache-strategy-author.
sw-cache-strategy-author
Author service worker cache strategies (cache-first, network-first, stale-while-revalidate, cache-only, network-only) per Workbox conventions, plus generate the matching Playwright assertions to lock the strategy in. Avoids the common "cached forever" pitfall by enforcing TTL + version-bump invalidation. Use when a new route needs defined offline behavior, when a hand-rolled `sw.js` is being moved to Workbox strategies, or when users report seeing stale content after a deploy.
web-vitals-inp-deep
Deep INP (Interaction to Next Paint) testing: decomposes input delay, processing duration, and presentation delay via the web-vitals/attribution build, asserts per-interaction INP budgets in Playwright using PerformanceObserver plus the web-vitals visibilitychange flush, and identifies long tasks blocking the main thread. Use when a page feels unresponsive while LCP and CLS are green, or to gate key interactions (form submit, modal open, route change) under an INP budget in CI. Covers interactions only: for service-worker cache-strategy latency use service-worker-tests.