Use whenever an Electron desktop app needs end-to-end tests, or a Spectron suite must be migrated: Playwright's _electron.launch, renderer windows as Pages, main-process checks via electronApp.evaluate, and IPC and DOM assertions from one suite.
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
The canonical home for this skill is testland/electron-playwright
Playwright ships a first-class _electron namespace that launches a
packaged Electron app by executablePath and drives both the main
process (Node.js, IPC, native modules) and renderer windows
(Chromium DOMs) from a single test (pwelectron). Per
Electron's own automated-testing tutorial (electrontest),
Playwright is one of three sanctioned test stacks (alongside WebdriverIO
and Selenium) for modern Electron projects.
Differentiation: unlike playwright-testing (which drives a running
Chromium / Firefox / WebKit browser via the browser / context / page
namespaces), electron-playwright uses the separate _electron namespace to
launch a packaged binary by path and probe the main process via
electronApp.evaluate() (pwelectronapp). Renderer-side page
patterns (Page Object, accessibility-first locators, trace viewer) carry over.
For legacy Spectron suites, see
references/spectron-migration.md; for the
strategic frame, desktop-test-strategy-reference.
_electron as the
modern default per electrontest.app.isPackaged, BrowserWindow count, IPC channel payloads) in
addition to renderer DOM.Per electrontest:
npm install --save-dev @playwright/testPlaywright's _electron module is bundled inside playwright /
@playwright/test - no extra package is needed
(pwelectron). Supported Electron versions per
pwelectron: "Electron v12.2.0+, v13.4.0+, and v14+".
The canonical example from electrontest:
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). For a packaged app, pass
executablePath to the packaged binary instead - its default per
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).electronApp.firstWindow() "waits for the first application
window to be opened" (pwelectronapp) and returns
a Playwright Page - every standard
playwright-testing
locator (getByRole, getByLabel) works on it.For tests of the packaged app (the artifact users install):
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).
Multi-surface assertion - main process owns app lifecycle, renderer owns DOM:
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();
});electronApp.evaluate(pageFunction) returns the function's value and awaits a
returned Promise, so async main-process queries work naturally
(pwelectronapp).
When a test needs the underlying BrowserWindow object for a window
(to assert size, fullscreen state, devtools open, etc.):
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 for a page as a
JSHandle; multi-window apps iterate electronApp.windows()
(pwelectronapp). Full API table:
references/electron-ci-and-api.md.
The 'window', 'console', and 'close' events fire for each new window,
main-process console writes, and process termination respectively
(pwelectronapp); event payloads and the full API table are in
references/electron-ci-and-api.md.
// 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();// 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.)
# 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.zipThe 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).
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).
Run across a Windows / macOS / Linux matrix; Linux needs an xvfb-run virtual
display because Electron requires a display server, while macOS and Windows
GitHub-hosted runners are display-capable out of the box. Full workflow:
references/electron-ci-and-api.md.
| 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) |
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) |
| Mixing Spectron and Playwright assertions in the same suite | Two driver lifecycles compete for the same Electron process | Migrate file-by-file per references/spectron-migration.md |
Asserting on Electron internal IDs (__electron_id) for locators | Internal; changes between Electron versions | Use accessibility-first locators (getByRole / getByLabel) per playwright-testing |
_electron is documented as experimental historically; per
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.app.getRecentDocuments() deprecations,
etc.); pin Electron in package.json and update intentionally.<canvas>, accelerated video) is
opaque to renderer-side accessibility queries - same caveat as in
desktop-test-strategy-reference.userData per-test (via app.setPath('userData', …) in test
fixture) - otherwise a second launch races the first.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._electron API - pwelectron.ElectronApplication API - pwelectronapp.desktop-test-strategy-reference.playwright-testing - page-automation patterns that carry over to the renderer surface.testland/qa
aa16752
Canonical home
since Jul 23, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.