Testland
Browse all skills & agents

electron-playwright

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. Includes the legacy Spectron reference and Spectron-to-Playwright migration shopping list (references/spectron-migration.md). Use for end-to-end tests of Electron apps where main-process state, IPC, and renderer DOM must all be asserted from one suite, or when migrating a deprecated Spectron suite.

Install with skills.sh (any agent)

npx skills add testland/qa --skill electron-playwright
View source

electron-playwright

Overview

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 (opens in new window)). Per Electron's own automated-testing tutorial (electrontest (opens in new window)), 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 (opens in new window)). 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.

When to use

  • New Electron desktop app - pick Playwright _electron as the modern default per electrontest (opens in new window).
  • Existing Electron suite still on Spectron - see the migration shopping list in references/spectron-migration.md.
  • 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 (opens in new window):

npm install --save-dev @playwright/test

Playwright's _electron module is bundled inside playwright / @playwright/test - no extra package is needed (pwelectron (opens in new window)). Supported Electron versions per pwelectron (opens in new window): "Electron v12.2.0+, v13.4.0+, and v14+".

Step 2 - Author the first test

The canonical example from electrontest (opens in new window):

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 (opens in new window)). For a packaged app, pass executablePath to the packaged binary instead - its default per pwelectron (opens in new window) 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 (opens in new window)).
  • electronApp.firstWindow() "waits for the first application window to be opened" (pwelectronapp (opens in new window)) 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):

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 (opens in new window)).

Step 4 - Probing main + renderer in one test

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 (opens in new window)).

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.):

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 (opens in new window)). Full API table: references/electron-ci-and-api.md.

Step 6 - Waiting for new windows + console output

The 'window', 'console', and 'close' events fire for each new window, main-process console writes, and process termination respectively (pwelectronapp (opens in new window)); 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();

Step 7 - Configuration

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

# 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 (opens in new window)).

Step 10 - CI integration

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

Anti-patternWhy it failsFix
Driving Electron via plain chromium.launch() from web-only PlaywrightMisses main-process surface entirely; can't query app.* or BrowserWindow.*Use _electron.launch() (pwelectron (opens in new window))
Hard-coded executablePath checked into the repo for a single OSCross-OS CI matrix breaksResolve per process.platform (Step 3)
Tests that share a single electronApp across many tests without cleanupOne leaked window state contaminates the next testPer-test electronApp = await electron.launch(...) + await electronApp.close()
Running Electron tests with default workers > 1GPU / user-data directory collisions; flaky launchesworkers: 1 (Step 7)
Forgetting xvfb-run on hosted Linux CI"Failed to initialize display" launch failurexvfb-run --auto-servernum wrapper (Step 10)
Probing main-process modules from window.evaluate()window.evaluate() runs in the renderer; doesn't see main-process globalsUse electronApp.evaluate() (pwelectronapp (opens in new window))
Mixing Spectron and Playwright assertions in the same suiteTwo driver lifecycles compete for the same Electron processMigrate file-by-file per references/spectron-migration.md
Asserting on Electron internal IDs (__electron_id) for locatorsInternal; changes between Electron versionsUse accessibility-first locators (getByRole / getByLabel) per playwright-testing

Limitations

  • _electron is documented as experimental historically; per electrontest (opens in new window) 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

electron-playwright - ElectronApplication API and CI

View source (opens in new window)

electron-playwright - ElectronApplication API and CI

Full ElectronApplication API detail and the cross-OS CI workflow, kept out of the SKILL spine. See SKILL.md (opens in new window) for the core launch-and-assert flow.

Sources: Playwright _electron launch reference (opens in new window) and ElectronApplication API (opens in new window).

ElectronApplication API

MemberBehaviour
electron.launch({ args, executablePath, env, cwd, recordVideo, recordHar, timeout })Launches Electron; executablePath defaults to node_modules/.bin/electron (pwelectron (opens in new window)).
electronApp.evaluate(fn)Runs fn in the main process; its first argument is the result of require('electron'), and a returned Promise is awaited (pwelectronapp (opens in new window)).
electronApp.firstWindow()Waits for the first window and returns a Playwright Page (pwelectronapp (opens in new window)).
electronApp.browserWindow(page)Returns the BrowserWindow JSHandle for a page (pwelectronapp (opens in new window)).
electronApp.windows()Returns all opened windows (pwelectronapp (opens in new window)).

Events (pwelectronapp (opens in new window)):

  • 'window' - fires for every window created and loaded; payload is a Page.
  • 'console' - fires when the main process calls console methods; payload is a ConsoleMessage.
  • 'close' - fires when the application process terminates.

Supported Electron versions: v12.2.0+, v13.4.0+, and v14+ (pwelectron (opens in new window)).

Cross-OS CI workflow

Linux runners need Xvfb (or another virtual framebuffer) because Electron requires a display server; xvfb-run is the standard wrapper. macOS and Windows GitHub-hosted runners are display-capable out of the box.

# .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/

Spectron - legacy reference and migration to Playwright `_electron`

View source (opens in new window)

Spectron - legacy reference and migration to Playwright _electron

Spectron was a Node.js library that drove Electron applications through ChromeDriver + the legacy WebDriverIO API - for several years the only sanctioned end-to-end driver for Electron apps. Per the Spectron repository (opens in new window): "Spectron is officially deprecated as of February 1, 2022." The final release was v19.0.0 (published 2022-02-02), pinned to Electron ^19.0.0; the repository is archived read-only. No new project should start on Spectron - see SKILL.md (opens in new window) for the Playwright _electron workflow.

Why Spectron was deprecated

  1. ChromeDriver was the wrong substrate. Electron's main process (Node.js, native modules, IPC, packaged-app lifecycle, file dialogs) sits outside the ChromeDriver model, so Spectron bridged it with bespoke RPC that grew progressively harder to keep aligned with Electron's multi-process model.
  2. The WebDriverIO sync API was retired. Spectron's API shape depended on the WDIO sync API, dropped in WDIO 6+; migrating was a breaking change, so Spectron's surface froze.
  3. Native testing tools matured. Per Electron's automated-testing guide (opens in new window), Electron now recommends Playwright, WebDriverIO (modern async), and Selenium - each with native Electron support paths.

Before: a Spectron test

// Legacy Spectron - DO NOT use for new code
const Application = require('spectron').Application;
const app = new Application({
  path: '/path/to/electron/MyApp.app/Contents/MacOS/MyApp',
});

before(async () => {
  await app.start();
});

after(async () => {
  if (app && app.isRunning()) {
    await app.stop();
  }
});

it('opens a window', async () => {
  const count = await app.client.getWindowCount();
  assert.strictEqual(count, 1);
});

After: the Playwright _electron equivalent

// Modern replacement (per electrontest)
const { _electron: electron } = require('playwright');

let electronApp;
beforeAll(async () => {
  electronApp = await electron.launch({ args: ['.'] });
});
afterAll(async () => {
  await electronApp.close();
});

test('opens a window', async () => {
  const windowCount = electronApp.windows().length;
  expect(windowCount).toBe(1);
});

Migration shopping list

Spectron conceptPlaywright _electron replacement
new Application({ path })electron.launch({ args: ['.'] }) (electrontest (opens in new window))
app.start() / app.stop()electronApp.launch() / electronApp.close()
app.client.<webdriver-method>page = await electronApp.firstWindow(); then standard page.<method> (electrontest (opens in new window))
app.browserWindow.<method> (sync RPC into main process)electronApp.evaluate(({ BrowserWindow }) => { … }) - typed handle (electrontest (opens in new window))
Window counting via app.client.getWindowCount()electronApp.windows().length
ChromeDriver binary lifecycleImplicit - Playwright bundles Chromium and exposes packaged-app launch directly

Plan migration test-file-by-test-file, not big-bang: tag each file as migrated, run both suites in CI until the Spectron set is empty, then delete spectron from package.json. There is no first-party codemod, and Spectron's main-process RPC (app.electron.<…>) has no 1:1 electronApp.evaluate() mapping for every case - some tests need a small refactor. Projects already on WDIO for browser tests may prefer wdio-electron-service (electrontest (opens in new window)) as the migration target instead.

Residual support contract (projects not migrating this sprint)

  • Pin spectron: 19.0.0 and electron: ^19.0.0 - newer Electron breaks Spectron's ChromeDriver bridge (spectronrepo (opens in new window)).
  • Pin Node.js to a version compatible with the bundled ChromeDriver - typically Node 16 for the Spectron 19 era.
  • Do not file issues upstream - the repository is archived; patches must live as local forks.
  • Schedule the migration. No security or Electron-version updates are coming; when stakeholders ask "but it still works," cite the Electron-version lock, missing security updates, and absent support - all consequences of the archived deprecation (spectronrepo (opens in new window)).

Related skills

desktop-test-strategy-reference

Reference catalog of desktop GUI test strategies across Windows, macOS, and Linux. Defines the three accessibility-tree backends (Microsoft UI Automation on Windows, Apple Accessibility / XCTest on macOS, AT-SPI on Linux), the wrapper-tools that drive each backend, the cross-toolkit Electron + Qt paths, the project-marker detection table plus one-driver-per-app decision table (FlaUI / WinAppDriver / electron-playwright / QtTest / XCUITest / AT-SPI), an accessibility-first locator strategy, and a desktop test-review hazard checklist (screen-object encapsulation, locator stability, explicit waits, STA / foreground-lock / elevation). Deep operational detail (per-OS async-wait hierarchies, parallel-test policy, UAC / TCC / AT-SPI elevation hazards, the high-DPI matrix) lives in references/. Use when choosing how to test or automate a desktop GUI application on Windows, macOS, or Linux, or when reviewing an existing desktop UI test suite - the strategic reference ahead of the per-tool implementation skills.

flaui-tests

Authors and runs FlaUI-based Windows UI tests - the .NET-native wrapper around Microsoft UI Automation (UIA2 + UIA3). Covers the `FlaUI.Core` / `FlaUI.UIA2` / `FlaUI.UIA3` NuGet packages, `Application.Launch` / `Application.Attach` lifecycles, `ConditionFactory` + `FindFirstDescendant` locator patterns, `Retry` waits, and xUnit / NUnit / MSTest harness integration. Use when the test stack is C# / .NET-first and the team wants idiomatic in-process UIA calls rather than the HTTP/JSON wire protocol of `winappdriver` (direct or Appium-wrapped).

qt-test-framework

Authors and runs Qt Test - the first-party C++ unit + GUI test framework that ships with Qt 6 (via the `QtTest` module header). Covers the `QTEST_MAIN` / `QTEST_APPLESS_MAIN` / `QTEST_GUILESS_MAIN` entry-point macros, the `QObject` private-slot test pattern, `QVERIFY` / `QCOMPARE` / `QFETCH` assertions, GUI event simulation (`QTest::mouseClick`, `QTest::keyClick`, `QTest::touchEvent`), `QSignalSpy` for signal introspection, `QBENCHMARK` for performance regression, and the `-o file,junitxml` CI output. Use for in-process testing of Qt widgets, QObject signal/slot chains, and Qt Quick / QML application logic; for out-of-process Qt-app driving, use an OS-native accessibility driver instead.

winappdriver

Authors and runs Windows UI tests against the WinAppDriver UIA surface via both invocation paths - the direct Microsoft W3C-WebDriver service (installing + launching `WinAppDriver.exe` on `127.0.0.1:4723`, `app` / `platformName` / `appArguments` / `appTopLevelWindow` capabilities) and the actively-maintained Appium 2.x wrapper (`appium driver install windows`, `appium:` prefixed capabilities, `windows:` gestures, PowerShell prerun/postrun hooks). Covers UWP / WPF / WinForms / Win32 apps, `AccessibilityId` / `Name` / `ClassName` locators, and Windows-runner CI. Use when driving a native Windows app from a Selenium-style client (C#, Java, Python, Ruby, JS) - directly when no Appium install is wanted, via Appium when the stack already runs Appium for iOS / Android / Mac2; for a C#-only FlaUI client use flaui-tests, and to choose among Windows desktop drivers first use desktop-test-strategy-reference.