Skip to main content
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 continuous integration (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: 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 that Extension.js generates:
  • dist/extension-js/<browser>/ready.json
  • dist/extension-js/<browser>/events.ndjson (newline-delimited JSON for 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

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.

Development mode vs test mode

  • dev is for watch-mode iteration (extension dev --no-browser + extension dev --wait).
  • start is 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 step that tells Playwright when the extension is ready.
--no-browser produces build output. The wait step confirms readiness before tests proceed. If your environment cannot run a second CLI process, poll ready.json directly. 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 timeouts.
  • 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

Your repository includes a Playwright configuration at:
  • playwright.config.ts

Next steps