src/skills/desktop-testing-electron/SKILL.md
E2E testing with Playwright, main process unit testing, IPC testing, dialog/menu mocking, CI headless setup
npx skillsauth add agents-inc/skills desktop-testing-electronInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Use Playwright's
_electron.launch()for E2E tests -- it controls the full app via CDP. Unit test main process code (IPC handlers, business logic) with your test runner by mocking theelectronmodule. Test preload scripts by mockingcontextBridgeandipcRenderer. Spectron is dead since Electron 24 -- Playwright and WebDriverIO are the replacements. Run Electron tests on headless Linux CI withxvfb-runor thexvfb-maybewrapper.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST await electronApp.close() in test teardown -- leaked Electron processes break CI and consume resources)
(You MUST mock the electron module in unit tests -- Electron APIs are only available inside the Electron runtime)
(You MUST use xvfb-run or xvfb-maybe for headless Linux CI -- Electron requires a display server)
(You MUST stub native dialogs in E2E tests -- showOpenDialog/showSaveDialog block the process and cannot be interacted with by Playwright)
</critical_requirements>
Auto-detection: Electron testing, _electron.launch, electronApp, electronApplication, firstWindow, Playwright Electron, electron-mock-ipc, electron-playwright-helpers, stubDialog, xvfb, xvfb-run, xvfb-maybe, Spectron migration, ipcMain.handle test, ipcRenderer mock, contextBridge mock, BrowserWindow mock, Electron E2E, Electron unit test
When to use:
dialog, BrowserWindow, ipcMain, ipcRenderer)contextBridge APIsWhen NOT to use:
Key patterns covered:
_electron.launch(), firstWindow(), evaluate(), assertionsipcMain.handle / ipcRenderer.invoke)contextBridge.exposeInMainWorld)Electron testing splits along the same boundaries as the Electron process model:
electron module and test IPC handlers, lifecycle logic, and business logic in isolation. These are fast and catch logic bugs early.Guiding principle: Test main process logic with unit tests, test integration through IPC with E2E, and test renderer UI with standard web tools. Don't try to unit test IPC communication itself -- the framework handles message passing. Test that your handlers produce the correct results given inputs.
When to use E2E (Playwright):
When to use unit tests:
Launch the Electron app, get the first window, and run assertions. Always close in teardown.
import { test, expect, _electron as electron } from "@playwright/test";
import type { ElectronApplication, Page } from "@playwright/test";
let electronApp: ElectronApplication;
let window: Page;
test.beforeEach(async () => {
electronApp = await electron.launch({ args: ["dist/main.js"] });
window = await electronApp.firstWindow();
});
test.afterEach(async () => {
await electronApp.close();
});
test("shows main window with title", async () => {
const title = await window.title();
expect(title).toBe("My App");
await expect(window.locator("h1")).toHaveText("Welcome");
});
Why good: afterEach guarantees cleanup, firstWindow() waits for the window to load, standard Playwright assertions work on the Page object
See examples/core.md for evaluate(), multi-window, and environment variable patterns.
Use electronApp.evaluate() to execute code in the main process context and access Electron APIs.
test("returns correct app version", async () => {
const version = await electronApp.evaluate(async ({ app }) => {
return app.getVersion();
});
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
});
test("app path is set correctly", async () => {
const appPath = await electronApp.evaluate(async ({ app }) => {
return app.getAppPath();
});
expect(appPath).toContain("dist");
});
Why good: evaluate() receives the Electron module object (containing app, BrowserWindow, etc.) as its first argument, runs in the real main process, returns serializable values
See examples/core.md for browserWindow handle access and process-level assertions.
Extract handler logic into pure functions, then unit test those functions. Mock the electron module so it doesn't fail outside the Electron runtime.
// main/handlers/file-handler.ts -- extracted pure logic
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const ALLOWED_EXTENSIONS = [".txt", ".md", ".json"];
export async function handleReadFile(
filePath: string,
): Promise<{ success: boolean; content?: string; error?: string }> {
const ext = path.extname(filePath);
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return { success: false, error: `Unsupported extension: ${ext}` };
}
const content = await readFile(filePath, "utf-8");
return { success: true, content };
}
// main/handlers/file-handler.test.ts
import { describe, it, expect } from "vitest";
import { handleReadFile } from "./file-handler.js";
describe("handleReadFile", () => {
it("rejects unsupported extensions", async () => {
const result = await handleReadFile("/tmp/file.exe");
expect(result).toStrictEqual({
success: false,
error: "Unsupported extension: .exe",
});
});
});
Why good: Handler logic is a pure function with no Electron dependency, testable with any test runner, no mocking required
See examples/core.md for the full IPC registration pattern and wiring handlers to ipcMain.handle.
When main process code imports directly from electron, mock the module in your test runner so tests don't fail outside the Electron runtime.
// test setup file -- mock the electron module globally
vi.mock("electron", () => ({
app: {
getPath: vi.fn().mockReturnValue("/tmp/mock-app-data"),
getVersion: vi.fn().mockReturnValue("1.0.0"),
whenReady: vi.fn().mockResolvedValue(undefined),
},
BrowserWindow: vi.fn().mockImplementation(() => ({
loadFile: vi.fn(),
webContents: { send: vi.fn() },
on: vi.fn(),
})),
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
removeHandler: vi.fn(),
},
dialog: {
showOpenDialog: vi.fn(),
showSaveDialog: vi.fn(),
showMessageBox: vi.fn(),
},
}));
Why good: Provides minimal stubs for common Electron APIs, tests run in Node.js without Electron runtime, each mock returns sensible defaults
See examples/mocking.md for per-test overrides and more granular mock patterns.
Native dialogs cannot be interacted with by Playwright. Stub them via evaluate() before triggering the dialog.
test("opens a file via dialog", async () => {
// Stub the dialog before the UI triggers it
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ["/tmp/test-file.txt"],
});
});
// Click the button that triggers showOpenDialog
await window.click('button[data-testid="open-file"]');
await expect(window.locator('[data-testid="file-name"]')).toHaveText(
"test-file.txt",
);
});
Why good: Stubs the dialog module in the running main process, returns controlled data, test can verify the downstream UI effect
See examples/e2e-patterns.md for save dialog, message box, and electron-playwright-helpers library patterns.
Electron requires a display server. On Linux CI, use xvfb-run or the cross-platform xvfb-maybe wrapper.
# GitHub Actions example
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: xvfb-run --auto-servernum -- npx playwright test
Key point: xvfb-run --auto-servernum creates a virtual display and sets $DISPLAY automatically. On macOS/Windows runners, xvfb-run is not needed -- Electron has native display access. xvfb-maybe wraps this cross-platform: it applies xvfb on Linux and does nothing elsewhere.
See examples/e2e-patterns.md for the full CI matrix and xvfb-maybe npm script pattern.
Playwright's toHaveScreenshot() works with Electron windows for visual regression testing.
test("main window matches screenshot", async () => {
// Wait for the UI to stabilize
await window.waitForLoadState("domcontentloaded");
await expect(window).toHaveScreenshot("main-window.png", {
maxDiffPixelRatio: 0.01,
});
});
test("dialog state matches screenshot", async () => {
await window.click('button[data-testid="open-settings"]');
await window.waitForSelector('[data-testid="settings-panel"]');
await expect(
window.locator('[data-testid="settings-panel"]'),
).toHaveScreenshot("settings-panel.png");
});
Key point: Run screenshot tests on a single OS in CI (Linux with xvfb) for consistent baselines. Cross-OS font rendering differences cause false positives. Use maxDiffPixelRatio or maxDiffPixels for tolerance.
<decision_framework>
What are you testing?
+-- Full user workflow (open, edit, save, multi-window)?
| +-- Playwright E2E (launch real app)
+-- Main process handler logic (validate input, transform data)?
| +-- Unit test with mocked electron module
+-- Preload script API shape?
| +-- Unit test with mocked contextBridge/ipcRenderer
+-- Renderer UI components?
| +-- Standard web testing tools (not Electron-specific)
+-- IPC round-trip (main <-> renderer)?
| +-- Playwright E2E (tests the real channel)
+-- Dialog/menu interactions?
| +-- Playwright E2E with stubbed dialogs
+-- Visual appearance?
| +-- Playwright screenshot comparison
+-- Auto-update flow?
+-- Mock event emission in unit tests + real staging server for integration
Does your code import from "electron"?
+-- YES: Is the logic separable from Electron APIs?
| +-- YES --> Extract pure function, test without mocking
| +-- NO --> Mock the electron module (use your test runner's module mocking)
+-- NO: Standard Node.js code
+-- Test normally, no special setup needed
</decision_framework>
Detailed resources:
<red_flags>
Critical Issues:
electronApp in test teardown -- leaked processes accumulate, break CI, and cause port conflictsArchitecture Issues:
ipcRenderer in E2E tests -- E2E tests use the real IPC channel; mock only native OS APIs (dialogs, menus)ipcMain.handle registration -- extract handlers to pure functions for testabilityCommon Mistakes:
await electronApp.firstWindow() returns a Page, not a BrowserWindow -- use Playwright page API, not Electron window APIevaluate() can return non-serializable values (functions, DOM nodes) -- it serializes via JSONpath.join(os.tmpdir(), ...) or test fixtureswaitForLoadState() or waitForSelector() before checking contentGotchas & Edge Cases:
_electron.launch() uses the electron binary from node_modules/.bin/ by default -- set executablePath if your app bundles a different Electron versionelectronApp.evaluate() receives the Electron module object (not require("electron")) as its first argument -- destructure { app }, { dialog }, etc.BrowserWindow handle from electronApp.browserWindow(page) returns a JSHandle, not a direct object -- call methods via evaluate on the handleipcMain.handle can only have one handler per channel -- calling handle twice on the same channel throws; use removeHandler first in tests</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST await electronApp.close() in test teardown -- leaked Electron processes break CI and consume resources)
(You MUST mock the electron module in unit tests -- Electron APIs are only available inside the Electron runtime)
(You MUST use xvfb-run or xvfb-maybe for headless Linux CI -- Electron requires a display server)
(You MUST stub native dialogs in E2E tests -- showOpenDialog/showSaveDialog block the process and cannot be interacted with by Playwright)
Failure to follow these rules will cause leaked processes, CI failures, and untestable dialog interactions.
</critical_reminders>
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Mapbox GL JS interactive maps - map initialization, markers, popups, sources, layers, expressions, clustering, 3D terrain, geocoding, directions
tools
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events