Calling chromium.launch() and then page.goto('chrome-extension://...') is a trap — Chrome silently ignores extension URLs when the browser was not launched with an extension loaded, returning a blank page with no error. The correct entry point is chromium.launchPersistentContext() with two required flags: --load-extension and --disable-extensions-except. This guide walks through that recipe in full, including service worker retrieval after idle eviction and extracting the extension ID without a hardcoded manifest key. This is part of the end-to-end testing automation guide for MV3 extensions.
Why persistent context, not launch
In MV3, the extension’s service worker, popup, content scripts, and options page all run within a single Chrome profile. That profile is what Playwright’s launchPersistentContext maps to. A normal launch() call creates an incognito-like ephemeral session that has no concept of loaded extensions. The persistent context corresponds to a directory on disk — Chrome reads and writes the extension registry there, which is what makes the --load-extension flag effective.
A second constraint: Chrome forbids loading extensions in --headless=new mode (the default headless mode since Chrome 112). The persistent context must be launched with headless: false. In CI, a virtual display (xvfb) satisfies this requirement without opening a visible window on the host machine.
Step 1 — Install dependencies and set up the launch helper
1npm install --save-dev @playwright/test
2npx playwright install chromium
Execution context: Shell, run from your project root. Only the Chromium channel is needed — extension loading on Firefox uses a different profile mechanism covered in the cross-browser section below.*
Create a reusable launch helper that encapsulates all the flag knowledge in one place:
1// tests/helpers/launchExtension.ts
2import { chromium, BrowserContext } from '@playwright/test';
3import path from 'node:path';
4import fs from 'node:fs';
5
6export const EXTENSION_PATH = path.resolve(__dirname, '../../dist');
7export const PROFILE_DIR = path.resolve(__dirname, '../../.tmp/chrome-profile');
8
9export async function launchExtension(): Promise<BrowserContext> {
10 // Wipe the profile before each launch to prevent state bleed between runs
11 if (fs.existsSync(PROFILE_DIR)) {
12 fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
13 }
14
15 return chromium.launchPersistentContext(PROFILE_DIR, {
16 headless: false,
17 args: [
18 `--disable-extensions-except=${EXTENSION_PATH}`,
19 `--load-extension=${EXTENSION_PATH}`,
20 '--no-sandbox', // required inside Docker and GitHub Actions runners
21 '--disable-dev-shm-usage', // prevents shared memory exhaustion in CI containers
22 ],
23 });
24}
Execution context: Node.js test helper, imported by test files. EXTENSION_PATH must point to the directory that contains manifest.json at its root — not the project root or a subdirectory with only source files. The profile wipe ensures a clean extension registry each run; omit it only if you need to preserve installed extension state across a test session.*
Step 2 — Retrieve the service worker
The service worker registration happens asynchronously within a few hundred milliseconds of launch. Two retrieval paths exist depending on timing:
1// tests/helpers/launchExtension.ts (continued)
2import { Worker } from '@playwright/test';
3
4export async function getServiceWorker(
5 context: BrowserContext,
6 timeoutMs = 5_000,
7): Promise<Worker> {
8 // Fast path: the worker may already be registered by the time this runs
9 const existing = context.serviceWorkers();
10 if (existing.length > 0) return existing[0];
11
12 // Slow path: wait for the first registration event
13 return context.waitForEvent('serviceworker', { timeout: timeoutMs });
14}
Execution context: Node.js. context.serviceWorkers() returns Playwright Worker objects — lightweight proxies that let you call worker.evaluate(fn) to execute code inside the actual service worker V8 context. The waitForEvent('serviceworker') call resolves on the first activate event for any service worker in the context. If you have installed multiple extensions, filter by URL to select the right worker.*
If your extension uses "type": "module" in the background declaration, the service worker may take slightly longer to parse and execute. Increase timeoutMs to 10 000 ms for module-based workers in CI where disk I/O is slower.
The extension ID is embedded in the service worker’s URL. Parsing it avoids the need to hardcode the ID or add a "key" field to manifest.json.
1// tests/helpers/launchExtension.ts (continued)
2export function getExtensionId(worker: Worker): string {
3 // Worker URL format: chrome-extension://<extensionId>/<script-path>
4 const url = new URL(worker.url());
5 if (url.protocol !== 'chrome-extension:') {
6 throw new Error(`Unexpected worker URL scheme: ${worker.url()}`);
7 }
8 return url.hostname; // the 32-character extension ID
9}
Execution context: Node.js, synchronous. Throws if the worker URL has an unexpected format — this surfaces misconfigured extension paths early rather than producing a silent wrong-ID bug downstream. On Firefox, url.protocol will be moz-extension: and url.hostname is a UUID rather than a 32-character hash.*
Step 4 — Open extension pages
With the ID in hand, navigate to popup, options, or any other extension HTML page as a normal Playwright page:
1// tests/e2e/popup.spec.ts
2import { test, expect } from '@playwright/test';
3import {
4 launchExtension,
5 getServiceWorker,
6 getExtensionId,
7} from '../helpers/launchExtension';
8
9test('popup reads storage written by the service worker', async () => {
10 const context = await launchExtension();
11
12 try {
13 const worker = await getServiceWorker(context);
14 const extId = getExtensionId(worker);
15
16 // Seed storage via the service worker before the popup opens
17 await worker.evaluate(async () => {
18 await chrome.storage.local.set({ greeting: 'hello from worker' });
19 });
20
21 const popup = await context.newPage();
22 await popup.goto(`chrome-extension://${extId}/popup.html`);
23 await popup.waitForLoadState('domcontentloaded');
24
25 await expect(popup.locator('#greeting')).toHaveText('hello from worker');
26 } finally {
27 await context.close();
28 }
29});
Execution context: The popup page runs in Chrome’s extension privileged context and has direct access to chrome.* APIs. Playwright interacts with it via the standard CDP protocol, so all locator, screenshot, and evaluation APIs work normally. The finally block ensures the context closes even when the test assertion fails, preventing orphaned Chromium processes.*
Step 5 — Handle service worker eviction mid-test
MV3 service workers are evicted after approximately 30 seconds of inactivity. Long test suites that pause between test cases will encounter an inactive worker. Re-acquiring the worker after eviction requires watching for re-registration:
1// tests/helpers/launchExtension.ts (continued)
2export async function getActiveServiceWorker(
3 context: BrowserContext,
4): Promise<Worker> {
5 const workers = context.serviceWorkers();
6 // Filter to workers whose URL matches our extension to skip Chrome built-ins
7 const ours = workers.filter(w => w.url().includes('chrome-extension://'));
8
9 if (ours.length > 0) {
10 // Probe liveness: a dead worker throws on evaluate
11 try {
12 await ours[0].evaluate(() => true);
13 return ours[0];
14 } catch {
15 // Worker was evicted; wait for it to re-register on next event
16 }
17 }
18
19 return context.waitForEvent('serviceworker', { timeout: 10_000 });
20}
Execution context: Node.js. The evaluate(() => true) probe is the cheapest valid CDP call — it wakes the worker without side effects. If the worker has been evicted, Chrome re-registers it on the first chrome.* API call; the waitForEvent then catches the re-registration. Avoid calling getActiveServiceWorker in a hot loop — each probe extends the worker’s idle timer and can mask eviction-related bugs in your extension code.*
Step 6 — GitHub Actions CI configuration
The headed requirement means CI needs a virtual display. The xvfb-run wrapper provides one without installing a full desktop environment:
1# .github/workflows/e2e.yml
2name: E2E Extension Tests
3
4on: [push, pull_request]
5
6jobs:
7 e2e:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11
12 - uses: actions/setup-node@v4
13 with:
14 node-version: '20'
15 cache: 'npm'
16
17 - name: Install dependencies
18 run: npm ci
19
20 - name: Build extension
21 run: npm run build # must produce dist/ before tests run
22
23 - name: Install Playwright browsers
24 run: npx playwright install chromium --with-deps
25
26 - name: Run E2E tests (headed via xvfb)
27 run: xvfb-run --auto-servernum -- npx playwright test
28 env:
29 DISPLAY: ':99'
Execution context: GitHub Actions runner (Ubuntu). xvfb-run --auto-servernum allocates a free display number automatically, preventing conflicts when multiple jobs run on the same host. The DISPLAY env var is set for completeness but xvfb-run handles it implicitly. On macOS runners, headed mode works natively without xvfb.*
Cross-browser variation
- Chrome/Edge — The
--load-extension and --disable-extensions-except flags are Chromium-only. Edge accepts them identically; point to the Edge binary via executablePath and keep all other configuration unchanged. - Firefox — Playwright Firefox does not accept the
--load-extension flag. Use the firefox.launchPersistentContext approach with a firefoxUserPrefs object that points to a web-ext-generated temporary profile, or use the web-ext run --playwright integration. The service worker URL scheme changes to moz-extension://; waitForEvent('serviceworker') still works. - Safari — No Playwright support for Safari extensions. Safari extension E2E testing requires
safaridriver via WebDriver or XCTest automation; the recipe in this guide does not apply.
Verification
After wiring up the helper, run this smoke check before writing any real assertions:
- Run
npx playwright test --headed locally. A Chromium window should open with the extension icon visible in the toolbar. - Add a
console.log to the top level of your service worker (background.js) and confirm it appears in the Playwright terminal output via worker.on('console', msg => console.log(msg.text())). - Navigate to
chrome://extensions inside the test via a page and check that the extension shows “Enabled” — use page.goto('chrome://extensions') and inspect the page source. - Confirm
getExtensionId returns a 32-character alphanumeric string:
1const id = getExtensionId(worker);
2console.assert(/^[a-p]{32}$/.test(id), `Unexpected extension ID format: ${id}`);
Execution context: Node.js assertion, runs in the test process. Extension IDs in Chrome are 32-character strings using only the letters a–p (base-16 encoding of a SHA-256 hash). This assertion catches profile-directory misconfiguration before URL navigation fails silently.*
FAQ
Why does the extension fail to load even with –load-extension set?
The most common cause is pointing EXTENSION_PATH at the project root rather than the built output directory. Chrome looks for manifest.json at the exact path you provide — not recursively. Confirm by running ls ${EXTENSION_PATH}/manifest.json in your terminal before the test run.
No. page.route() intercepts Fetch and XMLHttpRequest calls from a specific page context. Service worker fetch events bypass Playwright’s route interception entirely. To mock network responses seen by the service worker, use a real intercepting proxy (e.g., msw in proxy mode) or inject the mock via worker.evaluate() before the fetch is made.
How do I test both the popup and the options page in the same test session?
Open each as a separate page within the same context. Both share the same extension storage and messaging bus. The pattern is identical to the popup example: context.newPage() followed by page.goto('chrome-extension://${extId}/options.html'). Open the popup page first if you need to verify that options changes are reflected there.
Does launchPersistentContext preserve cookies and localStorage between test runs?
Yes — that is the definition of a persistent context. If you need a clean slate for every test, call fs.rmSync(PROFILE_DIR, { recursive: true }) in a global beforeEach teardown, or include the wipe in the launchExtension helper as shown in Step 1.