CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/playwright-extension-fixtures

Author the lower-level Playwright fixture pattern that 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. This is the launch-and-load layer shared by every extension test, not the assertions run on top of it. Use when authoring or debugging the fixture a Chromium extension test imports - popup, content script, service worker, options page, or side panel.

79

Quality

99%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files
name:
playwright-extension-fixtures
description:
Author the lower-level Playwright fixture pattern that 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. This is the launch-and-load layer shared by every extension test, not the assertions run on top of it. Use when authoring or debugging the fixture a Chromium extension test imports - popup, content script, service worker, options page, or side panel.
metadata:
{"keywords":"playwright, chromium, launchpersistentcontext, test-fixtures, browser-extension"}

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, this fixture is the contract the assertion-level skills depend on - it's the foundation, not the test logic.

This skill is distinct from browser-extension-tests (MV3 popup + content-script assertions); this is the lower-level Playwright fixture pattern (launchPersistentContext + --disable-extensions-except + --load-extension) shared by all extension tests. The neighbour skill is the "what to assert" playbook; this skill is the "how to launch" reference that any test - extension popup, content script, service worker, options page, side panel - needs to import first.

Composes with:

  • chrome-extension-test-loader - the manual chrome://extensions flow this fixture automates.
  • 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:

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

ElementWhy 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=$DIRPrevents any pre-installed extension from also loading and confusing assertions
--load-extension=$DIRLoads 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:

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

npx playwright test

Defaults to headed for extensions (per pw-ext, non-chromium channels require headed mode).

Local + CI headless

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:

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:

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:

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:

SymptomLikely causeFix
waitForEvent('serviceworker') times outManifest invalid or background.service_worker field missingCheck chrome://extensions manually first via chrome-extension-test-loader
Extension loads in headed but not headlessChannel set to chrome / msedge instead of chromiumUse channel: 'chromium' per pw-ext
extensionId extracts an empty stringURL not chrome-extension://... shape (e.g., about:blank listener fired)Filter on serviceWorker.url().startsWith('chrome-extension://') before split

CI integration

GitHub Actions example:

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-patternWhy it failsFix
chromium.launch() instead of launchPersistentContextExtension never loads per pw-extAlways use the persistent variant
channel: 'chrome' or channel: 'msedge'Side-load flags removed per pw-ext; headless will failUse channel: 'chromium'
Hardcoding extensionId from a local installID changes per build / per machineExtract from SW URL via the fixture
Skipping the waitForEvent('serviceworker') fallbackRace: SW not yet registered → [] from serviceWorkers() → undefined IDAlways include the if-empty branch per pw-ext
Persistent userDataDir in CIAuth state leaks between runs; flakyUse '' for CI determinism
Catching "Service worker restarted" as a permanent failurePer pw-ext this is recoverable; the SW resumesRetry the evaluate once
Using extra --args without verifying they don't conflictPer 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 web-ext-cli-mozilla 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.
  • Composes: chrome-extension-test-loader, manifest-v3-test-surface-reference.
  • Distinct neighbour: browser-extension-tests - the popup / content-script / messaging assertion playbook this fixture feeds.
Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/playwright-extension-fixtures badge