Testland
Browse all skills & agents

playwright-extension-fixtures

Author the Playwright fixture layer 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 - plus, in references/, the extension load / reload matrix (which edit re-evaluates which surface), the `chrome.storage` test suite (area selection, quota-exceeded, `storage.onChanged`, managed read-only), and the per-surface assertion recipes (popup, content script, background messaging, MV3 auto-suspend survival). Use when authoring or debugging the fixture a Chromium extension test imports, when an edit appears to have no effect and you need the reload matrix, or when authoring popup / content-script / storage tests.

Install with skills.sh (any agent)

npx skills add testland/qa --skill playwright-extension-fixtures
View source

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

This SKILL.md is the "how to launch" layer - the Playwright fixture pattern (launchPersistentContext + --disable-extensions-except + --load-extension) shared by all extension tests. The "what to assert" playbook per surface - popup, content script, background messaging, storage, MV3 auto-suspend - lives in references/extension-surface-tests.md and builds on this fixture.

Composes with:

  • references/extension-surface-tests.md - per-surface assertion recipes (popup, content script, messaging, storage persistence, auto-suspend) run from this fixture.
  • references/reload-matrix.md - which edits require which reload; the manual chrome://extensions gesture this fixture automates.
  • references/storage-tests.md - the chrome.storage area / quota / event test suite run from this fixture's SW context.
  • 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 (opens in new window).
  • 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 (opens in new window), 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 (opens in new window)
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 (opens in new window)
--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 (opens in new window):

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 (opens in new window), non-chromium channels require headed mode).

Local + CI headless

npx playwright test --headed=false

Requires channel: 'chromium' in the fixture per pw-ext (opens in new window):

"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 (opens in new window):

"Google Chrome and Microsoft Edge removed the command-line flags needed to side-load extensions."

MV2 fallback for extensionId

Per pw-ext (opens in new window), 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 (opens in new window), 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 missingLoad unpacked via chrome://extensions manually first and read the red Errors card
Extension loads in headed but not headlessChannel set to chrome / msedge instead of chromiumUse channel: 'chromium' per pw-ext (opens in new window)
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 (opens in new window).

Anti-patterns

Anti-patternWhy it failsFix
chromium.launch() instead of launchPersistentContextExtension never loads per pw-ext (opens in new window)Always use the persistent variant
channel: 'chrome' or channel: 'msedge'Side-load flags removed per pw-ext (opens in new window); 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 (opens in new window)
Persistent userDataDir in CIAuth state leaks between runs; flakyUse '' for CI determinism
Catching "Service worker restarted" as a permanent failurePer pw-ext (opens in new window) this is recoverable; the SW resumesRetry the evaluate once
Using extra --args without verifying they don't conflictPer pw-ext (opens in new window): "some of them may break Playwright functionality"Add browser args defensively, one at a time

Limitations

  • Chromium-only. Per pw-ext (opens in new window), the fixture covers Chromium-family extensions only. Firefox WebExtensions test differently - see the web-ext reference in manifest-v3-test-surface-reference (references/web-ext-firefox.md) 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 (opens in new window): "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

Extension surface test recipes

View source (opens in new window)

Extension surface test recipes

Assertion recipes for playwright-extension-fixtures: what to test on each Chromium-extension surface (popup, content script, background service worker, messaging, chrome.storage) once the fixture from the main skill is in place. Every test below imports test / expect from the fixture file, which supplies the persistent context and the resolved extensionId.

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');
});

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);
});

Message passing (popup to 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' });
});

chrome.storage persistence

Quota, area-selection, and storage.onChanged tests are in storage-tests.md (opens in new window); the baseline persistence check:

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();
});

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.

  1. npm run build emits dist/manifest.json (manifest_version: 3) plus popup.html and content.js.
  2. Add the fixture from the main skill; the extensionId fixture reads the SW URL, e.g. chrome-extension://abcdefghijklmnop/.
  3. The popup test opens chrome-extension://${extensionId}/popup.html, clicks [data-testid="increment"], and asserts the count reads 1.
  4. The content-script test visits https://example.com/, waits for the injected marker, and asserts three mark[data-extension-marker] nodes.
  5. The storage recipe sets { pref: 'dark' }, reloads the popup, and asserts the value survives.
  6. npx playwright test runs headed locally; CI reruns it with channel: 'chromium', headless: true per the main skill.

Result: a green run confirms popup rendering, content-script injection, and storage persistence across reload - the extension's core surfaces are covered.

Limitations

  • These recipes target Chromium; Firefox WebExtensions use Mozilla's web-ext tooling (see manifest-v3-test-surface-reference, references/web-ext-firefox.md).
  • Some extension APIs (chrome.declarativeNetRequest) cannot be fully unit-tested without a browser; integration tests are required.
  • Auto-suspend timing varies by Chromium version - verify against the Playwright Chrome extensions docs (opens in new window).

References

Extension reload matrix - what a code edit re-evaluates

View source (opens in new window)

Extension reload matrix - what a code edit re-evaluates

Companion reference for playwright-extension-fixtures. Consult when an edit appears to have no effect and you need to know which extension surface requires an explicit reload - the manual chrome://extensions card-refresh gesture this fixture automates.

Per the Chrome Extensions "Hello World" tutorial (opens in new window):

Component editedReload action required
manifest.jsonClick refresh on the extension card
Service worker (background.service_worker)Click refresh on the extension card
Content scriptsClick refresh on the extension card plus refresh the host page
Popup HTML / JSNone, next open re-evaluates
Options pageNone, next open re-evaluates
Other extension HTML pagesNone

An automated harness reproduces the "click refresh on the card" gesture either by toggling chrome.management.setEnabled(id, false) then setEnabled(id, true) (requires the "management" permission per the chrome.management reference (opens in new window); management.getSelf is the no-permission exception), or by closing and re-launching the persistent browser context.

Anti-patterns the matrix prevents:

  • Editing a content script and expecting the next page load to pick it up - content scripts need the card refresh and a host-page refresh per cr-hello (opens in new window).
  • Reloading the card after a popup-only edit - the popup re-evaluates on next open per cr-hello (opens in new window); the reload is wasted time and resets service-worker state.

chrome.storage test suite - area selection, quotas, events

View source (opens in new window)

chrome.storage test suite - area selection, quotas, events

Companion reference for playwright-extension-fixtures. Consult when an extension persists state and no test proves the chosen storage area survives its quota limits. Every template runs from this fixture's service-worker context (sw.evaluate(...)).

chrome.storage has four areas with non-overlapping quotas, persistence semantics, and enterprise-policy posture. Picking the wrong one is a class of bug the type checker can't catch: storage.sync silently rejects writes past its limits, storage.session evaporates on browser restart, and storage.managed is read-only and throws on write.

Area decision matrix

All constants per cr-storage (opens in new window):

AreaQUOTA_BYTESPer-itemLifetimeCross-deviceWriter
storage.local10,485,760 (10 MB)none documenteduntil extension removalnoextension
storage.sync102,400 (~100 KB)8,192 (8 KB)persistent, syncedyes (when user signed in)extension
storage.session10,485,760 (10 MB)none documentedcleared on disable, reload, update, or browser restart; MV3-onlynoextension
storage.managed--as long as policy is in effectvariesadmin only - read-only for the extension

storage.sync throughput caps: MAX_ITEMS = 512, MAX_WRITE_OPERATIONS_PER_MINUTE = 120, MAX_WRITE_OPERATIONS_PER_HOUR = 1,800. MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE is deprecated ("no longer has a sustained write operation quota" per cr-storage (opens in new window)) - drop any assertion on it.

Version-specific floors - pin tests to the live constant, never a hard-coded number: storage.local was 5 MB in Chrome 113 and earlier; storage.session was 1 MB in Chrome 111 and earlier (per cr-storage (opens in new window)).

Quota-exceeded template (per-item)

Quota-exceeded writes fail immediately and set runtime.lastError (callback form) or reject the Promise (async form); drive both paths:

test('storage.sync rejects on per-item quota exceeded', async ({ context }) => {
  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');

  const result = await sw.evaluate(async () => {
    const oversized = 'x'.repeat(9 * 1024); // 9 KB > 8 KB limit
    try {
      await chrome.storage.sync.set({ big: oversized });
      return { ok: true };
    } catch (e: any) {
      return { ok: false, message: e.message };
    }
  });

  expect(result.ok).toBe(false);
  // exact message is unstable; assert quota-shaped text
  expect(result.message).toMatch(/quota|QUOTA_BYTES/i);
});

Callback-path equivalence:

const err = await sw.evaluate(() => new Promise<string | null>(resolve => {
  const oversized = 'x'.repeat(9 * 1024);
  chrome.storage.sync.set({ big: oversized }, () => {
    resolve(chrome.runtime.lastError?.message ?? null);
  });
}));
expect(err).toMatch(/quota|QUOTA_BYTES/i);

The remaining suite cells

TestAsserts
storage.sync total-quotaa batch write past QUOTA_BYTES = 102,400 rejects (e.g. 13 items x ~8 KB)
MAX_ITEMS (512)the 513th unique key rejects
storage.onChanged shapelistener receives (changes, areaName) with changes[key] = { oldValue?, newValue? } - same shape on Firefox per mdn-storage (opens in new window)
multi-area isolationa storage.local write is invisible to storage.sync.get
storage.managed read-onlyany managed.set rejects ("Trying to modify this namespace results in an error" per mdn-storage (opens in new window))
session lifetimestorage.session data does not survive browser restart

storage.onChanged shape test:

const event = await sw.evaluate(() => new Promise<any>(resolve => {
  chrome.storage.onChanged.addListener(function listener(changes, area) {
    chrome.storage.onChanged.removeListener(listener);
    resolve({ changes, area });
  });
  chrome.storage.local.set({ theme: 'dark' });
}));
expect(event.area).toBe('local');
expect(event.changes.theme.newValue).toBe('dark');
expect(event.changes.theme.oldValue).toBeUndefined(); // first write

Firefox-Chrome divergences

ConcernChromeFirefox
storage.sync quotas102,400 / 8,192 / 512 / 1,800 per hour per cr-storage (opens in new window)MDN does not enumerate quota numbers on the high-level page; align to the StorageArea sub-page values and verify on Firefox stable
storage.session MV3-onlyYes per cr-storage (opens in new window)Yes per mdn-storage (opens in new window) - skip session tests when targeting MV2
storage.managedAvailable; admin-configured per OSAvailable per mdn-storage (opens in new window); policy injection mechanism differs per OS
Sync sign-inChrome account requiredFirefox account required - skip sync round-trip tests on machines without sign-in

Firefox's storage.local "persists even when users clear browsing history/data (unlike localStorage)" per mdn-storage (opens in new window) - useful for a clear-history regression test.

Anti-patterns and limits

  • Happy-path-only set + get - quota silently drops past 8 KB / 100 KB; author the quota tests above.
  • Asserting exact error message strings - wording isn't pinned; match /quota/i.
  • storage.sync for binary / image data - the 100 KB total + 8 KB per-item caps aren't designed for blobs; use storage.local.
  • storage.session for cross-restart data - passes in a single session, fails in prod on cold start.
  • Hard-coding the storage.local limit - read the live constant, not a literal (5 MB vs 10 MB across Chrome versions).
  • Per mdn-storage (opens in new window), no area encrypts at rest ("Storage area is not encrypted, and shouldn't store confidential information").
  • Sync round-trip propagation needs two signed-in profiles - not testable in headless CI without account credentials.
  • Throughput caps are enforced server-side; tests can only assert the extension's own batching stays under the cap.

References

Related skills

chrome-extension-messaging-tests

Asserts Chrome extension message-passing behaviour against a running extension: one-shot `chrome.runtime.sendMessage` plus the literal `return true` that holds the response channel open for an async `sendResponse`, `chrome.tabs.sendMessage` into one tab's content script, long-lived `chrome.runtime.connect` ports and their `onDisconnect` triggers, web-page messages gated by `externally_connectable`, and `chrome.runtime.connectNative` native-messaging hosts. Covers the payload rules a test must respect (Chrome uses JSON serialization rather than structured clone, so `Map` / `Set` / `Date` do not round-trip; maximum message size is 64 MiB) and the first-listener-wins rule when several `onMessage` listeners are registered. Scope is messaging behaviour on an already-running extension, not the install or reload step. Use when a message reaches no listener, a `sendResponse` callback never fires, a port disconnects mid-test, or a page origin has to be proven allowed before publishing.

manifest-v3-test-surface-reference

Pure-reference catalog of the Manifest V3 test surface for Firefox + Chromium browser extensions. Maps each manifest field that changed from MV2 (manifest_version, background.service_worker vs background.scripts, action vs browser_action / page_action, host_permissions split, web_accessible_resources object-form, content_security_policy object-form), the runtime restrictions service workers impose (no DOM, no XMLHttpRequest, no localStorage, ephemeral lifecycle, synchronous listener registration, alarms instead of setTimeout), and the Firefox-vs-Chrome key matrix (browser_specific_settings.gecko, externally_connectable / offline_enabled gaps, MV2-only user_scripts manifest key); references/ carry Mozilla's web-ext CLI (lint via addons-linter, run on firefox-desktop / firefox-android / chromium targets, deterministic build, AMO sign). Use as the manifest-surface reference when authoring extension tests across both browsers, or when driving Firefox runs / AMO signing with web-ext.