Loading an Unpacked Extension in Playwright
Exact Playwright launchPersistentContext recipe for MV3 extensions: flags, service worker retrieval, extension ID extraction, and opening chrome-extension:// pages reliably.
Table of Contents
- Why persistent context, not launch
- Step 1 — Install dependencies and set up the launch helper
- Step 2 — Retrieve the service worker
- Step 3 — Extract the extension ID
- Step 4 — Open extension pages
- Step 5 — Handle service worker eviction mid-test
- Step 6 — GitHub Actions CI configuration
- Cross-browser variation
- Verification
- FAQ
- Related
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.
Step 3 — Extract the extension ID
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-extensionand--disable-extensions-exceptflags are Chromium-only. Edge accepts them identically; point to the Edge binary viaexecutablePathand keep all other configuration unchanged. - Firefox — Playwright Firefox does not accept the
--load-extensionflag. Use thefirefox.launchPersistentContextapproach with afirefoxUserPrefsobject that points to aweb-ext-generated temporary profile, or use theweb-ext run --playwrightintegration. The service worker URL scheme changes tomoz-extension://;waitForEvent('serviceworker')still works. - Safari — No Playwright support for Safari extensions. Safari extension E2E testing requires
safaridrivervia 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 --headedlocally. A Chromium window should open with the extension icon visible in the toolbar. - Add a
console.logto the top level of your service worker (background.js) and confirm it appears in the Playwright terminal output viaworker.on('console', msg => console.log(msg.text())). - Navigate to
chrome://extensionsinside the test via a page and check that the extension shows “Enabled” — usepage.goto('chrome://extensions')and inspect the page source. - Confirm
getExtensionIdreturns 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.
Can I use page.route() to mock network calls from the service worker?
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.
Related
- End-to-end testing automation — the parent guide covering the full E2E layer including content scripts and CI.
- Debugging extension contexts — manual debugging when automated tests expose a failure.
- Debugging a service worker that won’t start — diagnose registration failures that block
waitForEvent('serviceworker')from resolving.