electron-playwright · git:20260724.d4db756 · 2026-07-24 · sha256 8a01f703ef8553fc
electron-playwright git:20260724.d4db756A
Immutable. This exact content never changes and is served at /api/v1/blob/8a01f703ef8553fc.
---
name: electron-playwright
description: "Authors Playwright `_electron` tests for packaged Electron desktop apps - launches the app via `electron.launch({ args })`, returns an `ElectronApplication` handle, drives renderer windows as Playwright `Page` objects, and probes the main process via `electronApp.evaluate(({ app, BrowserWindow }) => …)`. Distinct from ordinary browser page automation: this wraps the `_electron` API for launching packaged Electron apps and probing main + renderer processes. Use for end-to-end tests of Electron apps where main-process state, IPC, and renderer DOM must all be asserted from one suite."
metadata:
keywords: "electron, playwright, desktop, main-process, renderer"
---
# electron-playwright
## Overview
Playwright ships a first-class `_electron` namespace for launching
packaged Electron apps and driving both the **main process** (Node.js,
IPC, native modules) and **renderer windows** (Chromium DOMs) from a
single test. Per
[Playwright's `_electron` API page][pwelectron]:
[pwelectron]: https://playwright.dev/docs/api/class-electron
> "Launches electron application specified with the executablePath."
Per Electron's own automated-testing tutorial
([electrontest][electrontest]), Playwright is one of three sanctioned
test stacks (alongside WebdriverIO and Selenium) for modern Electron
projects.
[electrontest]: https://www.electronjs.org/docs/latest/tutorial/automated-testing
**Differentiation:** this skill is **distinct from**
`playwright-testing`,
which drives a running Chromium / Firefox / WebKit browser via the
`browser`, `context`, and `page` namespaces. `electron-playwright`
wraps the separate `_electron` namespace ([pwelectron][pwelectron]) -
it launches a packaged Electron binary by path, returns an
`ElectronApplication` handle, and exposes the main process via
`electronApp.evaluate()` ([pwelectronapp][pwelectronapp]). Page
automation patterns (Page Object, accessibility-first locators, trace
viewer) carry over for the renderer side; main-process testing is the
new surface this skill adds.
[pwelectronapp]: https://playwright.dev/docs/api/class-electronapplication
For legacy Spectron-based suites being migrated, see
`electron-spectron` (the legacy
reference) and the strategic frame in
`desktop-test-strategy-reference`.
## When to use
- New Electron desktop app - pick Playwright `_electron` as the
modern default per [electrontest][electrontest].
- Existing Electron suite still on Spectron - see migration shopping
list in `electron-spectron`.
- Tests need to assert **main-process** state (e.g.,
`app.isPackaged`, `BrowserWindow` count, IPC channel payloads) in
addition to renderer DOM.
- Tests need to exercise **packaged-app** behaviour (file
associations, single-instance lock, custom protocol handlers) that
is unreachable from browser-only Playwright.
## Step 1 - Install
Per [electrontest][electrontest]:
```bash
npm install --save-dev @playwright/test
```
Playwright's `_electron` module is bundled inside `playwright` /
`@playwright/test` - no extra package is needed
([pwelectron][pwelectron]). Supported Electron versions per
[pwelectron][pwelectron]: "Electron v12.2.0+, v13.4.0+, and v14+".
## Step 2 - Author the first test
The canonical example from [electrontest][electrontest]:
```typescript
import { test, expect, _electron as electron } from '@playwright/test';
test('app launches and is not packaged in dev', async () => {
const electronApp = await electron.launch({ args: ['.'] });
// Main-process assertion: probe the Electron `app` module
const isPackaged = await electronApp.evaluate(async ({ app }) => {
return app.isPackaged;
});
expect(isPackaged).toBe(false);
// Renderer assertion: take a screenshot of the first window
const window = await electronApp.firstWindow();
await window.screenshot({ path: 'intro.png' });
await electronApp.close();
});
```
What's going on:
- `electron.launch({ args: ['.'] })` launches Electron with the
current directory as the main-script argument
([pwelectron][pwelectron]). For a packaged app, pass
`executablePath` to the packaged binary instead - its default per
[pwelectron][pwelectron] is `node_modules/.bin/electron`.
- `electronApp.evaluate(pageFunction)` runs `pageFunction` **inside
the main process**; the first argument is "always the result of
the `require('electron')` in the main app script"
([pwelectronapp][pwelectronapp]).
- `electronApp.firstWindow()` "waits for the first application
window to be opened" ([pwelectronapp][pwelectronapp]) and returns
a Playwright `Page` - every standard
`playwright-testing`
locator (`getByRole`, `getByLabel`) works on it.
## Step 3 - Launching a packaged binary
For tests of the **packaged** app (the artifact users install):
```typescript
import path from 'node:path';
import { _electron as electron } from '@playwright/test';
const PACKAGED_BIN = process.platform === 'win32'
? path.resolve('dist/win-unpacked/MyApp.exe')
: process.platform === 'darwin'
? path.resolve('dist/mac/MyApp.app/Contents/MacOS/MyApp')
: path.resolve('dist/linux-unpacked/myapp');
const electronApp = await electron.launch({
executablePath: PACKAGED_BIN,
args: [],
env: { ...process.env, NODE_ENV: 'test' },
recordVideo: { dir: 'test-results/videos' },
});
```
The `executablePath`, `env`, `cwd`, `recordVideo`, `recordHar`, and
`timeout` options are documented on the `_electron` launch reference
([pwelectron][pwelectron]).
## Step 4 - Probing main + renderer in one test
Multi-surface assertion - main process owns app lifecycle, renderer
owns DOM:
```typescript
test('opening a project loads it into the renderer', async () => {
const electronApp = await electron.launch({ args: ['.'] });
const window = await electronApp.firstWindow();
// Renderer-side action via Playwright Page API
await window.getByRole('button', { name: /open project/i }).click();
await window.getByLabel('Project path').fill('/tmp/demo-project');
await window.getByRole('button', { name: /confirm/i }).click();
// Renderer-side assertion
await expect(window.getByRole('heading', { name: /demo-project/i })).toBeVisible();
// Main-process assertion: recent-projects state mutated
const recents: string[] = await electronApp.evaluate(({ app }) => {
return app.getRecentDocuments();
});
expect(recents).toContain('/tmp/demo-project');
await electronApp.close();
});
```
Per [pwelectronapp][pwelectronapp], `electronApp.evaluate(pageFunction)`
returns the value of `pageFunction`, and "if the function passed to
the electronApplication.evaluate() returns a Promise, then
electronApplication.evaluate() would wait for the promise to resolve
and return its value" - so async main-process queries work
naturally.
## Step 5 - Mapping renderer windows to main-process BrowserWindow
When a test needs the underlying `BrowserWindow` object for a window
(to assert size, fullscreen state, devtools open, etc.):
```typescript
const window = await electronApp.firstWindow();
const bwHandle = await electronApp.browserWindow(window);
const isFullScreen = await bwHandle.evaluate((bw) => bw.isFullScreen());
expect(isFullScreen).toBe(false);
```
`electronApp.browserWindow(page)` "returns the BrowserWindow object
that corresponds to the given Playwright page"
([pwelectronapp][pwelectronapp]) as a `JSHandle`. Multi-window apps
iterate `electronApp.windows()`, which is described as a "convenience
method that returns all the opened windows" ([pwelectronapp][pwelectronapp]).
## Step 6 - Waiting for new windows + console output
Async events fire when modal dialogs, secondary windows, or main-
process console writes happen. Per [pwelectronapp][pwelectronapp]:
- The `'window'` event is "emitted when every window that is created
and loaded in Electron" with a `Page` payload.
- The `'console'` event is emitted when "main process calls console
methods" with a `ConsoleMessage` payload.
- The `'close'` event fires "when the application process has been
terminated".
```typescript
// Wait for a secondary window to open after clicking
const [secondary] = await Promise.all([
electronApp.waitForEvent('window'),
window.getByRole('button', { name: /preferences/i }).click(),
]);
await expect(secondary.getByRole('heading', { name: /preferences/i })).toBeVisible();
```
## Step 7 - Configuration
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/electron',
fullyParallel: false, // Electron launches are heavy; serialize
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [
['html'],
['junit', { outputFile: 'reports/electron-junit.xml' }],
],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
```
Electron-launch tests typically run with `workers: 1` because each
launch spawns an Electron process with its own GPU/IPC stack; full
parallel launches collide on the user-data directory and on
GPU-shared-memory regions. (Web-only Playwright defaults to parallel
per
`playwright-testing`.)
## Step 8 - Running
```bash
# All Electron tests
npx playwright test --config=playwright.electron.config.ts
# A specific test file
npx playwright test tests/electron/launch.spec.ts
# Headed (the Electron window stays visible)
npx playwright test --headed
# Trace viewer for a failing run
npx playwright show-trace test-results/<…>/trace.zip
```
The trace viewer shows DOM snapshots of the renderer windows and the
`evaluate` calls into the main process side-by-side - debug parity
with normal Playwright traces (the trace viewer surface is part of
the shared Playwright toolchain per
`playwright-testing`).
## Step 9 - Parsing results
JUnit XML output (`reports/electron-junit.xml` from Step 7) feeds
`junit-xml-analysis`
for aggregation. The HTML reporter is identical to web-Playwright
([pwelectron][pwelectron]).
## Step 10 - CI integration
```yaml
# .github/workflows/electron-e2e.yml
jobs:
test:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: npm ci
- run: npm run build:electron
# On Linux, headless Electron needs a virtual display
- name: Run E2E (Linux with Xvfb)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum npx playwright test --config=playwright.electron.config.ts
- name: Run E2E (Windows/macOS)
if: runner.os != 'Linux'
run: npx playwright test --config=playwright.electron.config.ts
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.os }}
path: playwright-report/
```
Linux runners need `Xvfb` or another virtual framebuffer because
Electron requires a display server; `xvfb-run` is the standard
wrapper. macOS + Windows runners on GitHub-hosted are display-capable
out of the box.
## Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Driving Electron via plain `chromium.launch()` from web-only Playwright | Misses main-process surface entirely; can't query `app.*` or `BrowserWindow.*` | Use `_electron.launch()` ([pwelectron][pwelectron]) |
| Hard-coded `executablePath` checked into the repo for a single OS | Cross-OS CI matrix breaks | Resolve per `process.platform` (Step 3) |
| Tests that share a single `electronApp` across many tests without cleanup | One leaked window state contaminates the next test | Per-test `electronApp = await electron.launch(...)` + `await electronApp.close()` |
| Running Electron tests with default `workers > 1` | GPU / user-data directory collisions; flaky launches | `workers: 1` (Step 7) |
| Forgetting `xvfb-run` on hosted Linux CI | "Failed to initialize display" launch failure | `xvfb-run --auto-servernum` wrapper (Step 10) |
| Probing main-process modules from `window.evaluate()` | `window.evaluate()` runs in the renderer; doesn't see main-process globals | Use `electronApp.evaluate()` ([pwelectronapp][pwelectronapp]) |
| Mixing Spectron and Playwright assertions in the same suite | Two driver lifecycles compete for the same Electron process | Migrate file-by-file per `electron-spectron` |
| Asserting on Electron internal IDs (`__electron_id`) for locators | Internal; changes between Electron versions | Use accessibility-first locators (`getByRole` / `getByLabel`) per `playwright-testing` |
## Limitations
- **`_electron` is documented as experimental** historically; per
[electrontest][electrontest] it is one of the three recommended
paths but Playwright's own docs do not warrant the same stability
guarantees as the browser API.
- **Electron version drift.** A new Electron major can change main-
process module shapes (`app.getRecentDocuments()` deprecations,
etc.); pin Electron in `package.json` and update intentionally.
- **GPU-rendered content** (WebGL, `<canvas>`, accelerated video) is
opaque to renderer-side accessibility queries - same caveat as in
`desktop-test-strategy-reference`.
- **Multi-instance apps** with single-instance-lock need a custom
`userData` per-test (via `app.setPath('userData', …)` in test
fixture) - otherwise a second launch races the first.
- **Native OS dialogs** (Win32 file picker, macOS `NSSavePanel`) are
outside the Electron renderer; tests should stub `dialog.showOpenDialog`
via `electronApp.evaluate()` rather than try to click through them.
- **`workers: 1` slows the suite.** For large suites, shard across
CI jobs (`--shard=1/4`) rather than raise per-job concurrency.
## References
- Playwright `_electron` API - [pwelectron][pwelectron].
- Playwright `ElectronApplication` API - [pwelectronapp][pwelectronapp].
- Electron Automated Testing tutorial - [electrontest][electrontest].
- Sibling skill: `electron-spectron` -
legacy migration reference.
- Strategic frame:
`desktop-test-strategy-reference`.
- Web-side neighbour:
`playwright-testing` - page-automation patterns that carry over to the renderer surface.