Playwright E2E

Validate extension behavior across browsers with repeatable end-to-end tests.

Extension.js projects can use Playwright to test extension flows, UI rendering, and integration behavior in CI and local environments.

Playwright testing capabilities

CapabilityWhat it gives you
Cross-browser runtime checksValidate core flows on Chromium and Firefox engines
UI and interaction coverageTest popup/options/content-script behavior with real browser contexts
CI-ready reportsCapture traces/screenshots/videos for failed tests
Regression safetyCatch integration bugs that unit tests usually miss

Why use it

  • catch runtime regressions that unit tests miss
  • validate extension behavior on real browser engines
  • verify multi-browser changes before release

Typical setup

Install Playwright test dependencies in your project:

npm
yarn
pnpm
bun
deno
npm install -D @playwright/test

Create a playwright.config.ts and define browser projects and reporting.

import {defineConfig, devices} from '@playwright/test'

export default defineConfig({
  testDir: 'e2e',
  retries: process.env.CI ? 2 : 0,
  reporter: [['html'], ['list']],
  projects: [
    {name: 'chromium', use: {...devices['Desktop Chrome']}},
    {name: 'firefox', use: {...devices['Desktop Firefox']}}
  ]
})

For deterministic automation, do not parse terminal text. Use the metadata files generated by Extension.js:

  • dist/extension-js/<browser>/ready.json
  • dist/extension-js/<browser>/events.ndjson (watch/rebuild events)

ready.json includes stable fields intended for scripts/agents:

  • status: starting | ready | error
  • command: dev | start | preview
  • browser
  • distPath
  • manifestPath
  • port
  • pid
  • runId
  • startedAt
  • compiledAt
  • errors

Use this contract as the source of truth for readiness and failures.

Running tests

npm
pnpm
yarn
npx playwright test

Canonical Playwright flow (AI-friendly)

  1. Start Extension.js in no-browser mode.
  2. Wait until ready.json reports status: "ready".
  3. Launch Playwright with the extension output from distPath.
  4. Run tests and shut down.

Dev friend vs test friend

  • dev is the dev friend for watch-mode iteration (extension dev --no-browser + extension dev --wait).
  • start is the test friend for production-style checks (extension start --no-browser + extension start --wait).

Important distinction: run mode vs readiness gate

  • --no-browser is the run mode: it starts the extension pipeline without launching a browser.
  • A readiness gate (extension dev --wait --browser=<browser>) is the synchronization primitive for Playwright/AI.

In other words, --no-browser produces build/watch output, and your wait step consumes the machine contract before tests proceed. If your environment cannot run a second CLI process, you can fall back to directly polling ready.json.

Use this two-process pattern:

  1. Process A: extension dev --no-browser
  2. Process B: extension dev --wait --browser=<browser> --wait-format=json
  3. Start Playwright only after the wait step exits successfully

Production-oriented variant:

  1. Process A: extension start --no-browser
  2. Process B: extension start --wait --browser=<browser> --wait-format=json
  3. Start Playwright only after the wait step exits successfully
import {spawn} from 'node:child_process'
import {chromium} from '@playwright/test'

const browserName = 'chromium'

const child = spawn(
  'pnpm',
  ['extension', 'dev', '--no-browser', `--browser=${browserName}`],
  {
    stdio: 'inherit',
    env: process.env
  }
)

async function waitForReady() {
  return await new Promise<any>((resolve, reject) => {
    let stdout = ''
    let stderr = ''
    const wait = spawn(
      'pnpm',
      [
        'extension',
        'dev',
        '--wait',
        `--browser=${browserName}`,
        '--wait-timeout=60000',
        '--wait-format=json'
      ],
      {stdio: ['ignore', 'pipe', 'pipe'], env: process.env}
    )
    wait.stdout.on('data', (chunk) => (stdout += chunk.toString()))
    wait.stderr.on('data', (chunk) => (stderr += chunk.toString()))
    wait.on('error', reject)
    wait.on('close', (code) => {
      if ((code ?? 1) !== 0) {
        reject(
          new Error(stderr || `wait command failed with code ${String(code)}`)
        )
        return
      }
      const payload = JSON.parse(stdout.trim())
      resolve(payload.results[0])
    })
  })
}

const ready = await waitForReady()

const context = await chromium.launchPersistentContext('', {
  headless: false,
  args: [
    `--disable-extensions-except=${ready.distPath}`,
    `--load-extension=${ready.distPath}`
  ]
})

// run tests using context/pages...

await context.close()
child.kill('SIGTERM')

Practical guidance for extensions

  • Keep test fixtures deterministic; extension startup can be sensitive to profile state.
  • Prefer explicit waits on extension UI conditions over fixed sleeps.
  • Run Chromium and Firefox projects in CI for cross-engine confidence.
  • Capture traces/screenshots/videos on failure for faster debugging.
  • Prefer ready.json/events.ndjson over stdout parsing for machine reliability.

Common pitfalls

  • relying on fixed timeouts instead of state-based waits
  • running only one browser target in CI
  • skipping artifact upload for failed runs
  • coupling tests to local-only profile or environment assumptions

Repository reference

This repository includes a Playwright config at:

  • playwright.config.ts

Next steps