Testland
Browse all skills & agents

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-tests
View source

browser-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

  • Testing a browser extension popup page interaction.
  • Validating content-script injection on matching URLs.
  • Exercising background service worker (alarms, listeners, message routing).
  • Verifying chrome.storage reads/writes survive reload.

How to use

  1. Build the extension so dist/ holds the MV3 manifest.json (manifest_version: 3) and bundled popup.html / content scripts.
  2. Add the tests/fixtures.ts extension-loading fixture (Step 1) so every test receives a persistent context and the resolved extensionId.
  3. Test the popup at chrome-extension://${extensionId}/popup.html and content-script injection on matching page URLs (Steps 2-3).
  4. Cover background behavior - message passing, chrome.storage, and service-worker auto-suspend - with references/advanced-recipes.md.
  5. Switch the fixture to the chromium channel with headless: true for CI runs (Step 7).
  6. Run npx playwright test; consult the Anti-patterns table when the extension fails to load or the service worker is missing.

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.

  1. npm run build emits dist/manifest.json (manifest_version: 3) plus popup.html and content.js.
  2. Add tests/fixtures.ts from Step 1; the extensionId fixture reads the SW URL, e.g. chrome-extension://abcdefghijklmnop/.
  3. The popup test (Step 2) opens chrome-extension://${extensionId}/popup.html, clicks [data-testid="increment"], and asserts the count reads 1.
  4. The content-script test (Step 3) visits https://example.com/, waits for the injected marker, and asserts three mark[data-extension-marker] nodes.
  5. The storage recipe (Steps 4-6) 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 (Step 7).

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-patternWhy it failsFix
Use chromium.launch() (non-persistent)Extension never loads; persistent context requiredAlways launchPersistentContext (Step 1)
Use chrome channelSide-load flags removed in stable Chrome / EdgeUse bundled chromium channel (Step 7)
Hardcode extensionId from local installID changes per build / per machineExtract from SW URL (Step 1 fixture)
Test in MV2 modeDeprecated; production extensions are MV3Always test against the manifest version you ship
Skip waitForEvent('serviceworker')Race: SW not yet registeredAlways await the event (Step 1)

Limitations

  • Playwright Chrome extension support targets Chromium; Firefox WebExtensions use a different test approach (see Mozilla's web-ext tooling).
  • 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

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.