desktop-testing-electron
E2E testing with Playwright, main process unit testing, IPC testing, dialog/menu mocking, CI headless setup
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-testing-electron/skills/desktop-testing-electron
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Electron Testing Patterns
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>
CRITICAL: Before Using This Skill
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:
- Writing E2E tests for an Electron application with Playwright
- Unit testing main process code (IPC handlers, lifecycle logic)
- Mocking Electron modules (
dialog,BrowserWindow,ipcMain,ipcRenderer) - Testing preload scripts and
contextBridgeAPIs - Setting up headless CI for Electron tests (Linux xvfb)
- Migrating from Spectron to Playwright
- Screenshot/visual regression testing of Electron windows
- Testing auto-update flows
When NOT to use:
- Testing renderer UI in isolation (use your web testing skill -- renderer is standard web)
- Writing tests unrelated to Electron-specific APIs
- Performance profiling or benchmarking Electron apps
- Packaging or distributing Electron apps (use the Electron framework skill)
Key patterns covered:
- Playwright E2E:
_electron.launch(),firstWindow(),evaluate(), assertions - Main process unit testing with mocked Electron modules
- IPC handler testing (
ipcMain.handle/ipcRenderer.invoke) - Preload script testing (mock
contextBridge.exposeInMainWorld) - Dialog and menu stubbing in E2E tests
- Auto-updater test strategies
- Headless CI configuration (xvfb, GitHub Actions)
- Screenshot and visual regression testing
- Spectron migration path
<decision_framework>
Decision Framework
What to Test Where
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
Mocking Decision
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:
- examples/core.md - Playwright launch, evaluate, firstWindow, IPC handler unit testing, preload testing
- examples/e2e-patterns.md - Dialog stubbing, CI setup, screenshot testing, auto-update testing, multi-window
- examples/mocking.md - Mocking electron module, ipcMain/ipcRenderer, BrowserWindow, dialog, contextBridge
- reference.md - Playwright Electron API quick reference, Spectron migration, test runner comparison
<red_flags>
RED FLAGS
Critical Issues:
- Not closing
electronAppin test teardown -- leaked processes accumulate, break CI, and cause port conflicts - Running Electron E2E tests on Linux CI without xvfb -- tests fail immediately with "no display" errors
- Testing IPC communication logic itself rather than handler outcomes -- the framework handles message passing, test your business logic
- Using Spectron for Electron 24+ -- Spectron is unmaintained and incompatible with modern Electron
Architecture Issues:
- Putting all test logic in E2E tests when unit tests would suffice -- E2E is slow, unit test handler logic separately
- Mocking
ipcRendererin E2E tests -- E2E tests use the real IPC channel; mock only native OS APIs (dialogs, menus) - Testing renderer components through Electron launch -- renderer is standard Chromium, test with web tools for speed
- Coupling handler logic directly to
ipcMain.handleregistration -- extract handlers to pure functions for testability
Common Mistakes:
- Forgetting
await electronApp.firstWindow()returns aPage, not aBrowserWindow-- use Playwright page API, not Electron window API - Assuming
evaluate()can return non-serializable values (functions, DOM nodes) -- it serializes via JSON - Hardcoding file paths in E2E dialog stubs -- use
path.join(os.tmpdir(), ...)or test fixtures - Not waiting for window load before assertions -- use
waitForLoadState()orwaitForSelector()before checking content
Gotchas & Edge Cases:
_electron.launch()uses theelectronbinary fromnode_modules/.bin/by default -- setexecutablePathif your app bundles a different Electron version- Playwright Electron support is marked "experimental" -- API may change between major Playwright versions
electronApp.evaluate()receives the Electron module object (notrequire("electron")) as its first argument -- destructure{ app },{ dialog }, etc.- Screenshot baselines differ across OSes due to font rendering -- pin to one OS in CI or use per-OS baselines
BrowserWindowhandle fromelectronApp.browserWindow(page)returns aJSHandle, not a direct object -- call methods viaevaluateon the handleipcMain.handlecan only have one handler per channel -- callinghandletwice on the same channel throws; useremoveHandlerfirst in tests
</red_flags>
<critical_reminders>
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>
Files (skills)
-
examples
-
core.md 10.3 KB
# Electron Testing - Core Patterns > Playwright E2E fundamentals, main process evaluation, IPC handler unit testing, preload script testing. See [SKILL.md](../SKILL.md) for decision frameworks and red flags. See [e2e-patterns.md](e2e-patterns.md) for dialog stubbing, CI, and screenshots. See [mocking.md](mocking.md) for Electron module mocking patterns. > **Note:** Unit test examples use Vitest syntax for concreteness. The Electron-specific patterns (what to extract, what to mock) apply to any test runner. --- ## Playwright E2E: Launch and First Window ```typescript 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"], env: { ...process.env, NODE_ENV: "test" }, }); window = await electronApp.firstWindow(); // Wait for the renderer to be ready await window.waitForLoadState("domcontentloaded"); }); test.afterEach(async () => { await electronApp.close(); }); test("app opens with main window", async () => { const title = await window.title(); expect(title).toBe("My Application"); }); test("main window has expected dimensions", async () => { const windowSize = await electronApp.evaluate(async ({ BrowserWindow }) => { const [mainWindow] = BrowserWindow.getAllWindows(); const [width, height] = mainWindow.getSize(); return { width, height }; }); expect(windowSize.width).toBeGreaterThanOrEqual(800); expect(windowSize.height).toBeGreaterThanOrEqual(600); }); ``` **Why good:** Launches with test environment, waits for DOM before assertions, cleans up in afterEach, uses evaluate for main process checks ```typescript // BAD: no cleanup, no wait, hardcoded path test("opens app", async () => { const app = await electron.launch({ args: ["/Users/me/dev/app/main.js"] }); const win = await app.firstWindow(); expect(await win.title()).toBe("My App"); // No close! Process leaks on every test run }); ``` **Why bad:** Hardcoded absolute path, no waitForLoadState, no close in teardown -- leaked process accumulates --- ## Main Process Evaluation with evaluate() `evaluate()` runs a function inside the main Electron process. It receives the Electron module object as its argument. ```typescript test("app version matches package.json", async () => { const version = await electronApp.evaluate(async ({ app }) => { return app.getVersion(); }); expect(version).toMatch(/^\d+\.\d+\.\d+$/); }); test("user data path is set", async () => { const userDataPath = await electronApp.evaluate(async ({ app }) => { return app.getPath("userData"); }); expect(userDataPath).toBeTruthy(); }); test("main window is not minimized on startup", async () => { const isMinimized = await electronApp.evaluate(async ({ BrowserWindow }) => { const [mainWindow] = BrowserWindow.getAllWindows(); return mainWindow.isMinimized(); }); expect(isMinimized).toBe(false); }); ``` **Key points:** - The callback receives the full Electron module -- destructure what you need (`{ app }`, `{ BrowserWindow }`, `{ dialog }`) - Return values must be JSON-serializable (no functions, DOM nodes, or circular references) - The function runs in the real main process, so you can access actual app state --- ## BrowserWindow Handle via browserWindow() Access the underlying BrowserWindow for a given Page. Returns a `JSHandle` -- use `evaluate` on the handle. ```typescript test("window has correct title bar configuration", async () => { const bwHandle = await electronApp.browserWindow(window); const isResizable = await bwHandle.evaluate((bw) => bw.isResizable()); expect(isResizable).toBe(true); const bounds = await bwHandle.evaluate((bw) => bw.getBounds()); expect(bounds.width).toBeGreaterThanOrEqual(800); }); ``` **Key point:** `browserWindow(page)` returns a JSHandle, not a direct BrowserWindow. You must call `.evaluate()` on the handle to access properties and methods. --- ## Multi-Window Testing Use `waitForEvent("window")` to capture new windows spawned during a test. ```typescript test("preferences opens in a new window", async () => { // Set up listener before triggering the action const windowPromise = electronApp.waitForEvent("window"); // Trigger the action that opens a new window await window.click('[data-testid="open-preferences"]'); // Wait for the new window const prefsWindow = await windowPromise; await prefsWindow.waitForLoadState("domcontentloaded"); await expect(prefsWindow.locator("h1")).toHaveText("Preferences"); // Verify window count const allWindows = electronApp.windows(); expect(allWindows).toHaveLength(2); }); ``` **Why good:** Sets up event listener before the action, waits for load state on the new window, verifies window count --- ## Unit Testing IPC Handlers (Extract + Test) The most testable pattern: extract handler logic into pure functions, register them separately. ### Step 1: Extract handler logic ```typescript // main/handlers/file-handler.ts import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; const ALLOWED_EXTENSIONS = new Set([".txt", ".md", ".json"]); const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB export interface FileResult { success: boolean; content?: string; error?: string; } export async function handleReadFile(filePath: string): Promise<FileResult> { const ext = path.extname(filePath).toLowerCase(); if (!ALLOWED_EXTENSIONS.has(ext)) { return { success: false, error: `Unsupported extension: ${ext}` }; } try { const content = await readFile(filePath, "utf-8"); if (Buffer.byteLength(content) > MAX_FILE_SIZE_BYTES) { return { success: false, error: "File exceeds maximum size" }; } return { success: true, content }; } catch { return { success: false, error: `Failed to read file: ${filePath}` }; } } export async function handleSaveFile( filePath: string, content: string, ): Promise<FileResult> { try { await writeFile(filePath, content, "utf-8"); return { success: true }; } catch { return { success: false, error: `Failed to write file: ${filePath}` }; } } ``` ### Step 2: Register handlers (thin wiring layer) ```typescript // main/register-handlers.ts import { ipcMain } from "electron"; import { handleReadFile, handleSaveFile } from "./handlers/file-handler.js"; export function registerFileHandlers(): void { ipcMain.handle("read-file", (_event, filePath: string) => handleReadFile(filePath), ); ipcMain.handle("save-file", (_event, filePath: string, content: string) => handleSaveFile(filePath, content), ); } ``` ### Step 3: Unit test the handler logic (no mocking needed) ```typescript // main/handlers/file-handler.test.ts import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { writeFile, mkdir, rm } from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { handleReadFile, handleSaveFile } from "./file-handler.js"; let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(path.join(os.tmpdir(), "electron-test-")); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); describe("handleReadFile", () => { it("reads a valid text file", async () => { const filePath = path.join(tempDir, "test.txt"); await writeFile(filePath, "hello world"); const result = await handleReadFile(filePath); expect(result).toStrictEqual({ success: true, content: "hello world" }); }); it("rejects unsupported file extensions", async () => { const result = await handleReadFile("/tmp/malicious.exe"); expect(result).toStrictEqual({ success: false, error: "Unsupported extension: .exe", }); }); it("returns error for non-existent files", async () => { const result = await handleReadFile("/tmp/does-not-exist.txt"); expect(result.success).toBe(false); expect(result.error).toContain("Failed to read file"); }); }); describe("handleSaveFile", () => { it("writes content to a file", async () => { const filePath = path.join(tempDir, "output.txt"); const result = await handleSaveFile(filePath, "saved content"); expect(result).toStrictEqual({ success: true }); }); }); ``` **Why good:** Handler logic is a pure async function, no Electron imports, no mocking needed, uses temp directories for filesystem tests --- ## Preload Script Testing Test that your preload script exposes the correct API shape by mocking `contextBridge` and `ipcRenderer`. ```typescript // preload.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock electron/renderer before importing preload vi.mock("electron", () => { const exposedApis: Record<string, unknown> = {}; return { contextBridge: { exposeInMainWorld: vi.fn((key: string, api: unknown) => { exposedApis[key] = api; }), }, ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeAllListeners: vi.fn(), }, __exposedApis: exposedApis, }; }); describe("preload script", () => { let exposedApis: Record<string, unknown>; beforeEach(async () => { vi.resetModules(); const electronMock = await import("electron"); // Import preload -- it calls exposeInMainWorld as a side effect await import("./preload.js"); exposedApis = ( electronMock as unknown as { __exposedApis: Record<string, unknown> } ).__exposedApis; }); it("exposes electronAPI to the renderer", () => { expect(exposedApis).toHaveProperty("electronAPI"); }); it("exposes expected methods", () => { const api = exposedApis.electronAPI as Record<string, unknown>; expect(typeof api.readFile).toBe("function"); expect(typeof api.saveFile).toBe("function"); expect(typeof api.getAppVersion).toBe("function"); }); it("readFile calls ipcRenderer.invoke with correct channel", async () => { const { ipcRenderer } = await import("electron"); const api = exposedApis.electronAPI as Record< string, (arg: string) => Promise<unknown> >; await api.readFile("/tmp/test.txt"); expect(ipcRenderer.invoke).toHaveBeenCalledWith( "read-file", "/tmp/test.txt", ); }); }); ``` **Why good:** Verifies the preload API shape without running Electron, confirms correct IPC channels are wired, catches typos in channel names early -
e2e-patterns.md 9.2 KB
# Electron Testing - E2E Patterns > Dialog stubbing, menu testing, CI headless setup, screenshot testing, auto-update testing, multi-window flows. See [SKILL.md](../SKILL.md) for decision frameworks. See [core.md](core.md) for launch/evaluate fundamentals. See [mocking.md](mocking.md) for unit test mocking. --- ## Dialog Stubbing Native dialogs (`showOpenDialog`, `showSaveDialog`, `showMessageBox`) block the process and cannot be interacted with by Playwright. Stub them via `evaluate()` before triggering. ### Open File Dialog ```typescript test("opens and displays a file", async () => { // Stub showOpenDialog to return a test file await electronApp.evaluate(async ({ dialog }) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: ["/tmp/test-data/sample.txt"], }); }); await window.click('button[data-testid="open-file"]'); await expect(window.locator('[data-testid="file-content"]')).toContainText( "sample content", ); }); ``` ### Save File Dialog ```typescript test("saves file to selected path", async () => { const savePath = path.join(os.tmpdir(), "electron-test-save.txt"); await electronApp.evaluate(async ({ dialog }, targetPath) => { dialog.showSaveDialog = async () => ({ canceled: false, filePath: targetPath, }); }, savePath); await window.click('button[data-testid="save-file"]'); // Verify the file was actually written const content = await readFile(savePath, "utf-8"); expect(content).toContain("expected content"); }); ``` **Key point:** The second argument to `evaluate()` is passed into the callback. Use this to inject test-specific paths. ### Canceled Dialog ```typescript test("handles canceled dialog gracefully", async () => { await electronApp.evaluate(async ({ dialog }) => { dialog.showOpenDialog = async () => ({ canceled: true, filePaths: [], }); }); await window.click('button[data-testid="open-file"]'); // Verify the app does not crash and shows no file await expect( window.locator('[data-testid="file-content"]'), ).not.toBeVisible(); }); ``` ### Message Box Dialog ```typescript test("confirms dangerous action via message box", async () => { // Button index 0 = "Yes" in a Yes/No dialog const CONFIRM_BUTTON_INDEX = 0; await electronApp.evaluate(async ({ dialog }, buttonIndex) => { dialog.showMessageBox = async () => ({ response: buttonIndex, checkboxChecked: false, }); }, CONFIRM_BUTTON_INDEX); await window.click('button[data-testid="delete-all"]'); await expect(window.locator('[data-testid="empty-state"]')).toBeVisible(); }); ``` --- ## electron-playwright-helpers Library For projects with many dialog-heavy tests, `electron-playwright-helpers` provides convenience wrappers. ```typescript import { stubDialog, stubMultipleDialogs } from "electron-playwright-helpers"; test("opens file using helper library", async () => { await stubDialog(electronApp, "showOpenDialog", { filePaths: ["/tmp/test.txt"], canceled: false, }); await window.click('button[data-testid="open-file"]'); await expect(window.locator('[data-testid="file-name"]')).toHaveText( "test.txt", ); }); test("multi-step file workflow", async () => { await stubMultipleDialogs(electronApp, [ { method: "showOpenDialog", value: { filePaths: ["/tmp/source.txt"], canceled: false }, }, { method: "showSaveDialog", value: { filePath: "/tmp/output.txt", canceled: false }, }, ]); await window.click('button[data-testid="convert-file"]'); await expect(window.locator('[data-testid="status"]')).toHaveText( "Conversion complete", ); }); ``` **Key point:** Each dialog method can only be stubbed with one value at a time. Call `stubDialog` again before each dialog trigger if the same method is called multiple times. --- ## Menu Testing Application menus and context menus cannot be directly clicked via Playwright. Test via `evaluate()` to trigger menu actions programmatically. ```typescript test("File > New creates a new document", async () => { // Simulate menu click by triggering the menu item's click handler await electronApp.evaluate(async ({ Menu }) => { const appMenu = Menu.getApplicationMenu(); const fileMenu = appMenu?.getMenuItemById("file-new"); fileMenu?.click(); }); await expect(window.locator('[data-testid="document-title"]')).toHaveText( "Untitled", ); }); ``` **Alternative:** If your menu items trigger IPC messages, test the IPC handler directly rather than going through the menu. --- ## Headless CI Configuration ### GitHub Actions -- Linux with xvfb ```yaml name: Electron E2E Tests on: [push, pull_request] jobs: test-linux: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build # Build the Electron app first - run: npx playwright install --with-deps chromium - name: Run E2E tests run: xvfb-run --auto-servernum -- npx playwright test test-macos: runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build - run: npx playwright install --with-deps chromium - run: npx playwright test # No xvfb needed on macOS test-windows: runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build - run: npx playwright install --with-deps chromium - run: npx playwright test # No xvfb needed on Windows ``` ### Cross-Platform npm Script with xvfb-maybe ```json { "scripts": { "test:e2e": "xvfb-maybe npx playwright test" }, "devDependencies": { "xvfb-maybe": "^0.2.1" } } ``` **Key point:** `xvfb-maybe` auto-detects the platform. On Linux it wraps with xvfb, on macOS/Windows it's a no-op. --- ## Screenshot and Visual Regression Testing ### Full Window Screenshot ```typescript test("main window visual regression", async () => { await window.waitForLoadState("networkidle"); await expect(window).toHaveScreenshot("main-window.png", { maxDiffPixelRatio: 0.01, // Allow 1% pixel difference }); }); ``` ### Component Screenshot ```typescript test("sidebar matches baseline", async () => { const sidebar = window.locator('[data-testid="sidebar"]'); await expect(sidebar).toHaveScreenshot("sidebar.png"); }); ``` ### Masking Dynamic Content ```typescript test("dashboard without dynamic elements", async () => { await expect(window).toHaveScreenshot("dashboard.png", { mask: [ window.locator('[data-testid="timestamp"]'), window.locator('[data-testid="user-avatar"]'), ], }); }); ``` **Key points:** - Generate baselines with `npx playwright test --update-snapshots` - Run screenshot tests on a single OS in CI for consistent baselines - Use `maxDiffPixelRatio` for tolerance against minor rendering differences - Mask timestamps, avatars, and other dynamic content to avoid false positives - Store baseline screenshots in version control --- ## Auto-Update Testing Auto-update is difficult to fully E2E test because it requires a real update server. Use a layered strategy. ### Unit Test: Mock Event Emission ```typescript // auto-updater.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("electron-updater", () => ({ autoUpdater: { checkForUpdatesAndNotify: vi.fn(), on: vi.fn(), quitAndInstall: vi.fn(), }, })); import { autoUpdater } from "electron-updater"; import { setupAutoUpdater } from "./auto-updater.js"; describe("auto-updater setup", () => { beforeEach(() => { vi.clearAllMocks(); }); it("registers update-available handler", () => { setupAutoUpdater(); expect(autoUpdater.on).toHaveBeenCalledWith( "update-available", expect.any(Function), ); }); it("registers update-downloaded handler", () => { setupAutoUpdater(); expect(autoUpdater.on).toHaveBeenCalledWith( "update-downloaded", expect.any(Function), ); }); }); ``` ### E2E Test: Verify Update UI (with stubbed updater) ```typescript test("shows update notification when update is available", async () => { // Simulate the main process emitting an update event await electronApp.evaluate(async ({ BrowserWindow }) => { const [mainWindow] = BrowserWindow.getAllWindows(); mainWindow.webContents.send("update-available", { version: "2.0.0", releaseDate: "2025-01-01", }); }); await expect(window.locator('[data-testid="update-banner"]')).toBeVisible(); await expect(window.locator('[data-testid="update-version"]')).toHaveText( "2.0.0", ); }); ``` ### Staging Integration Test For full integration testing, use a local update server (e.g., Minio for S3-compatible hosting) with a version bump in `package.json`: 1. Build a "current" version (e.g., 1.0.0) 2. Build an "update" version (e.g., 1.1.0) 3. Serve the update via local server 4. Launch the 1.0.0 build and verify it detects and downloads the update This is typically run manually or in a dedicated CI stage, not on every commit. -
mocking.md 9.2 KB
# Electron Testing - Mocking Patterns > Mocking Electron modules for unit tests: electron module, ipcMain/ipcRenderer, BrowserWindow, dialog, contextBridge. See [SKILL.md](../SKILL.md) for decision frameworks. See [core.md](core.md) for E2E and handler testing. See [e2e-patterns.md](e2e-patterns.md) for dialog stubbing in E2E. > **Note:** Examples use Vitest mock syntax (`vi.mock`, `vi.fn`) for concreteness. The mocking patterns (what to mock and why) apply to any test runner that supports module mocking. --- ## Full Electron Module Mock When main process code imports from `electron`, mock the entire module. Place this in a setup file or at the top of each test file. ```typescript // test/mocks/electron.ts -- reusable mock import { vi } from "vitest"; export function createElectronMock() { return { app: { getPath: vi.fn().mockReturnValue("/tmp/mock-app-data"), getVersion: vi.fn().mockReturnValue("1.0.0"), getName: vi.fn().mockReturnValue("TestApp"), whenReady: vi.fn().mockResolvedValue(undefined), on: vi.fn(), quit: vi.fn(), }, BrowserWindow: vi.fn().mockImplementation(() => ({ loadFile: vi.fn().mockResolvedValue(undefined), loadURL: vi.fn().mockResolvedValue(undefined), webContents: { send: vi.fn(), on: vi.fn(), openDevTools: vi.fn(), }, on: vi.fn(), show: vi.fn(), close: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), getBounds: vi .fn() .mockReturnValue({ x: 0, y: 0, width: 1200, height: 800 }), })), ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn(), removeAllListeners: vi.fn(), }, dialog: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn(), showMessageBox: vi.fn(), showErrorBox: vi.fn(), }, Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn(), getApplicationMenu: vi.fn(), }, Tray: vi.fn().mockImplementation(() => ({ setContextMenu: vi.fn(), setToolTip: vi.fn(), on: vi.fn(), destroy: vi.fn(), })), Notification: vi.fn().mockImplementation(() => ({ show: vi.fn(), on: vi.fn(), })), nativeTheme: { shouldUseDarkColors: false, themeSource: "system", on: vi.fn(), }, shell: { openExternal: vi.fn().mockResolvedValue(undefined), openPath: vi.fn().mockResolvedValue(""), }, safeStorage: { isEncryptionAvailable: vi.fn().mockReturnValue(true), encryptString: vi.fn().mockReturnValue(Buffer.from("encrypted")), decryptString: vi.fn().mockReturnValue("decrypted"), }, }; } ``` ### Using the Mock in Tests ```typescript // vitest setup or individual test file import { vi } from "vitest"; import { createElectronMock } from "./mocks/electron.js"; vi.mock("electron", () => createElectronMock()); ``` Or inline for a single test file: ```typescript vi.mock("electron", () => ({ app: { getPath: vi.fn().mockReturnValue("/tmp/test"), getVersion: vi.fn().mockReturnValue("1.0.0"), whenReady: vi.fn().mockResolvedValue(undefined), }, ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() }, })); ``` --- ## Per-Test Mock Overrides Override specific mock behavior for individual tests without resetting the entire module. ```typescript import { vi, describe, it, expect, beforeEach } from "vitest"; import { dialog } from "electron"; vi.mock("electron", () => ({ dialog: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn(), showMessageBox: vi.fn(), }, })); describe("file-picker module", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns selected file path", async () => { vi.mocked(dialog.showOpenDialog).mockResolvedValue({ canceled: false, filePaths: ["/home/user/document.txt"], }); const result = await pickFile(); expect(result).toBe("/home/user/document.txt"); }); it("returns null when dialog is canceled", async () => { vi.mocked(dialog.showOpenDialog).mockResolvedValue({ canceled: true, filePaths: [], }); const result = await pickFile(); expect(result).toBeNull(); }); }); ``` **Why good:** Each test controls the mock return value independently, `clearAllMocks` in beforeEach prevents leak between tests --- ## ipcMain Handler Registration Testing Test that your handler registration function wires up the correct channels. ```typescript import { vi, describe, it, expect, beforeEach } from "vitest"; import { ipcMain } from "electron"; vi.mock("electron", () => ({ ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn(), }, })); import { registerFileHandlers } from "./register-handlers.js"; describe("registerFileHandlers", () => { beforeEach(() => { vi.clearAllMocks(); }); it("registers read-file and save-file handlers", () => { registerFileHandlers(); expect(ipcMain.handle).toHaveBeenCalledWith( "read-file", expect.any(Function), ); expect(ipcMain.handle).toHaveBeenCalledWith( "save-file", expect.any(Function), ); }); it("read-file handler calls through to handleReadFile", async () => { registerFileHandlers(); // Extract the registered handler const handleCall = vi .mocked(ipcMain.handle) .mock.calls.find(([channel]) => channel === "read-file"); const handler = handleCall?.[1]; // Call the handler with a mock event and test args const mockEvent = {} as Electron.IpcMainInvokeEvent; const result = await handler?.(mockEvent, "/tmp/test.txt"); expect(result).toBeDefined(); }); }); ``` --- ## BrowserWindow Mock with webContents Test code that creates and manages BrowserWindows. ```typescript import { vi, describe, it, expect } from "vitest"; import { BrowserWindow } from "electron"; vi.mock("electron", () => { const mockWebContents = { send: vi.fn(), on: vi.fn(), openDevTools: vi.fn(), }; const mockWindow = { loadFile: vi.fn().mockResolvedValue(undefined), loadURL: vi.fn().mockResolvedValue(undefined), webContents: mockWebContents, on: vi.fn(), show: vi.fn(), close: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), }; return { BrowserWindow: vi.fn().mockImplementation(() => mockWindow), }; }); import { createMainWindow } from "./window-manager.js"; describe("createMainWindow", () => { it("creates window with correct options", () => { createMainWindow(); expect(BrowserWindow).toHaveBeenCalledWith( expect.objectContaining({ width: expect.any(Number), height: expect.any(Number), webPreferences: expect.objectContaining({ preload: expect.stringContaining("preload"), }), }), ); }); it("loads the correct entry file", () => { const win = createMainWindow(); expect(win.loadFile).toHaveBeenCalledWith( expect.stringContaining("index.html"), ); }); }); ``` --- ## contextBridge and ipcRenderer Mock (Preload Testing) Test preload scripts by capturing what `exposeInMainWorld` receives. ```typescript import { vi, describe, it, expect, beforeEach } from "vitest"; const exposedApis: Record<string, Record<string, Function>> = {}; vi.mock("electron", () => ({ contextBridge: { exposeInMainWorld: vi.fn((key: string, api: Record<string, Function>) => { exposedApis[key] = api; }), }, ipcRenderer: { invoke: vi.fn(), on: vi.fn(), send: vi.fn(), removeAllListeners: vi.fn(), }, })); describe("preload script", () => { beforeEach(async () => { // Clear captured APIs and re-import preload Object.keys(exposedApis).forEach((key) => delete exposedApis[key]); vi.resetModules(); await import("./preload.js"); }); it("exposes all expected API methods", () => { const api = exposedApis.electronAPI; expect(api).toBeDefined(); const expectedMethods = [ "readFile", "saveFile", "getAppVersion", "onUpdateAvailable", ]; for (const method of expectedMethods) { expect(typeof api[method]).toBe("function"); } }); it("wires readFile to the correct IPC channel", async () => { const { ipcRenderer } = await import("electron"); const api = exposedApis.electronAPI; await api.readFile("/test/path.txt"); expect(ipcRenderer.invoke).toHaveBeenCalledWith( "read-file", "/test/path.txt", ); }); it("wires event listener to correct channel", async () => { const { ipcRenderer } = await import("electron"); const api = exposedApis.electronAPI; const callback = vi.fn(); api.onUpdateAvailable(callback); expect(ipcRenderer.on).toHaveBeenCalledWith( "update-available", expect.any(Function), ); }); }); ``` **Why good:** Captures the exact API surface exposed to the renderer, verifies correct IPC channel names, catches wiring bugs without running Electron --- ## Test Runner Configuration Tip Main process tests should run in a **Node environment** (not jsdom/happy-dom). If your project has both renderer tests (browser environment) and main process tests (Node environment), use your test runner's project/workspace feature to split them. Apply the Electron module mock globally in a setup file so every main process test gets it automatically.
-
-
reference.md 10.3 KB
# Electron Testing Reference > Quick-lookup tables, Playwright Electron API reference, Spectron migration, test runner comparison. See [SKILL.md](SKILL.md) for decision frameworks and red flags. See [examples/](examples/) for full code examples. --- ## Playwright Electron API Quick Reference ### Electron Class | Method | Signature | Returns | Purpose | | -------- | --------------------------- | ------------------------------ | ------------------- | | `launch` | `electron.launch(options?)` | `Promise<ElectronApplication>` | Launch Electron app | #### Launch Options | Option | Type | Default | Purpose | | ---------------- | -------------------------------------- | ---------------------------- | ------------------------------------------------------ | | `args` | `string[]` | -- | Arguments passed to Electron (typically `["main.js"]`) | | `executablePath` | `string` | `node_modules/.bin/electron` | Path to Electron binary | | `cwd` | `string` | -- | Working directory | | `env` | `Record<string, string>` | `process.env` | Environment variables | | `timeout` | `number` | `30000` | Max wait for launch (ms) | | `colorScheme` | `"dark" \| "light" \| "no-preference"` | -- | Emulate color scheme | | `locale` | `string` | -- | Emulate locale | | `recordVideo` | `{ dir: string }` | -- | Record video to directory | | `tracesDir` | `string` | -- | Trace files output directory | ### ElectronApplication Class | Method | Signature | Returns | Purpose | | ---------------- | ------------------------------- | ----------------------- | --------------------------------- | | `firstWindow` | `firstWindow(options?)` | `Promise<Page>` | Wait for first window | | `windows` | `windows()` | `Page[]` | All open windows | | `close` | `close()` | `Promise<void>` | Quit the application | | `evaluate` | `evaluate(fn, arg?)` | `Promise<Serializable>` | Run function in main process | | `evaluateHandle` | `evaluateHandle(fn, arg?)` | `Promise<JSHandle>` | Get handle to main process object | | `browserWindow` | `browserWindow(page)` | `Promise<JSHandle>` | Get BrowserWindow for a Page | | `context` | `context()` | `BrowserContext` | Access browser context | | `process` | `process()` | `ChildProcess` | Main process child_process | | `waitForEvent` | `waitForEvent(event, options?)` | `Promise<Object>` | Wait for event (e.g., `"window"`) | ### ElectronApplication Events | Event | Payload | When | | --------- | ---------------- | ---------------------------------- | | `close` | -- | Application process terminated | | `console` | `ConsoleMessage` | Main process console method called | | `window` | `Page` | New window created and loaded | --- ## Test Strategy Matrix | What to Test | Approach | Speed | Confidence | | -------------------- | ------------------------------ | ----- | ---------- | | IPC handler logic | Unit test (pure functions) | Fast | Medium | | Handler registration | Unit test (mock ipcMain) | Fast | Low | | Preload API shape | Unit test (mock contextBridge) | Fast | Medium | | IPC round-trip | E2E (Playwright) | Slow | High | | Dialog workflows | E2E + stubbed dialogs | Slow | High | | Window management | E2E (Playwright) | Slow | High | | Visual appearance | E2E + screenshot | Slow | High | | Renderer UI | Standard web tests | Fast | High | | Auto-update | Mock events + staging server | Mixed | Medium | --- ## Spectron Migration Guide Spectron was deprecated February 2022 and does not work with Electron 24+. Migrate to Playwright or WebDriverIO. ### Spectron to Playwright Mapping | Spectron | Playwright Equivalent | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `new Application({ path: electronPath, args: [...] })` | `electron.launch({ args: [...] })` | | `app.start()` | Happens at `launch()` | | `app.stop()` | `electronApp.close()` | | `app.client` (WebDriver) | `electronApp.firstWindow()` (Page) | | `app.client.$("selector")` | `window.locator("selector")` | | `app.client.getText("selector")` | `window.locator("selector").textContent()` | | `app.client.click("selector")` | `window.click("selector")` | | `app.client.waitForVisible("selector")` | `window.waitForSelector("selector")` | | `app.electron.remote.app.getVersion()` | `electronApp.evaluate(({ app }) => app.getVersion())` | | `app.electron.remote.dialog` | `electronApp.evaluate(({ dialog }) => ...)` | | `app.browserWindow.getBounds()` | `electronApp.browserWindow(page).evaluate(bw => bw.getBounds())` | | `app.webContents.send(channel, data)` | `electronApp.evaluate(({ BrowserWindow }, data) => BrowserWindow.getAllWindows()[0].webContents.send(channel, data), data)` | ### Key Differences - **No `remote` module** -- Spectron used Electron's deprecated `remote` module. Playwright uses `evaluate()` to run code in the main process. - **Page API, not WebDriver** -- Playwright returns `Page` objects with its own locator/assertion API, not WebDriver protocol elements. - **Built-in assertions** -- Use `expect(locator).toHaveText()` instead of manual `getText()` + assert. - **Auto-waiting** -- Playwright auto-waits for elements; no need for explicit `waitForVisible` in most cases. --- ## CI Platform Reference | Platform | Display Server | Configuration | | ------------------------ | -------------- | -------------------------------------------------- | | GitHub Actions (Ubuntu) | xvfb-run | `xvfb-run --auto-servernum -- npx playwright test` | | GitHub Actions (macOS) | Native | `npx playwright test` (no extra config) | | GitHub Actions (Windows) | Native | `npx playwright test` (no extra config) | | CircleCI | Pre-configured | `$DISPLAY` already set | | Docker (Linux) | xvfb | Install `xvfb` in Dockerfile, run with `xvfb-run` | ### xvfb Commands | Command | Purpose | | ------------------------------------ | ---------------------------------------------------------- | | `xvfb-run --auto-servernum -- <cmd>` | Run command with auto-assigned display | | `xvfb-maybe <cmd>` | Cross-platform wrapper (Linux: xvfb, macOS/Windows: no-op) | | `Xvfb :99 &` + `export DISPLAY=:99` | Manual setup (less common) | --- ## Common Assertion Patterns | Assertion | Playwright Code | | ------------------ | --------------------------------------------------------------------------------- | | Window title | `expect(await window.title()).toBe("My App")` | | Element text | `await expect(window.locator("h1")).toHaveText("Welcome")` | | Element visible | `await expect(window.locator(".modal")).toBeVisible()` | | Element hidden | `await expect(window.locator(".modal")).not.toBeVisible()` | | Element count | `await expect(window.locator(".item")).toHaveCount(3)` | | Screenshot match | `await expect(window).toHaveScreenshot("baseline.png")` | | Window count | `expect(electronApp.windows()).toHaveLength(2)` | | Main process value | `expect(await electronApp.evaluate(({ app }) => app.getVersion())).toBe("1.0.0")` | -
SKILL.md 16.1 KB
--- name: desktop-testing-electron description: E2E testing with Playwright, main process unit testing, IPC testing, dialog/menu mocking, CI headless setup --- # Electron Testing Patterns > **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 the `electron` module. Test preload scripts by mocking `contextBridge` and `ipcRenderer`. Spectron is dead since Electron 24 -- Playwright and WebDriverIO are the replacements. Run Electron tests on headless Linux CI with `xvfb-run` or the `xvfb-maybe` wrapper. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **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:** - Writing E2E tests for an Electron application with Playwright - Unit testing main process code (IPC handlers, lifecycle logic) - Mocking Electron modules (`dialog`, `BrowserWindow`, `ipcMain`, `ipcRenderer`) - Testing preload scripts and `contextBridge` APIs - Setting up headless CI for Electron tests (Linux xvfb) - Migrating from Spectron to Playwright - Screenshot/visual regression testing of Electron windows - Testing auto-update flows **When NOT to use:** - Testing renderer UI in isolation (use your web testing skill -- renderer is standard web) - Writing tests unrelated to Electron-specific APIs - Performance profiling or benchmarking Electron apps - Packaging or distributing Electron apps (use the Electron framework skill) **Key patterns covered:** - Playwright E2E: `_electron.launch()`, `firstWindow()`, `evaluate()`, assertions - Main process unit testing with mocked Electron modules - IPC handler testing (`ipcMain.handle` / `ipcRenderer.invoke`) - Preload script testing (mock `contextBridge.exposeInMainWorld`) - Dialog and menu stubbing in E2E tests - Auto-updater test strategies - Headless CI configuration (xvfb, GitHub Actions) - Screenshot and visual regression testing - Spectron migration path --- <philosophy> ## Philosophy Electron testing splits along the same boundaries as the Electron process model: 1. **E2E tests** launch the full application with Playwright and exercise the complete flow -- main process, preload, renderer, and IPC together. These are slow but high-confidence. 2. **Main process unit tests** mock the `electron` module and test IPC handlers, lifecycle logic, and business logic in isolation. These are fast and catch logic bugs early. 3. **Renderer tests** are standard web tests -- the renderer is Chromium. Use your existing web testing approach. **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):** - Full user workflows (open file, edit, save) - IPC round-trips that span main and renderer - Window management (multi-window, modals, frameless) - Visual regression / screenshot comparison - Auto-update UI flow **When to use unit tests:** - IPC handler logic (validate input, produce output) - Main process business logic (file operations, data processing) - Preload API shape (correct channels exposed) - Configuration and startup logic </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Playwright E2E -- Launch and Basic Assertions Launch the Electron app, get the first window, and run assertions. Always close in teardown. ```typescript 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](examples/core.md) for evaluate(), multi-window, and environment variable patterns. --- ### Pattern 2: Main Process Evaluation Use `electronApp.evaluate()` to execute code in the main process context and access Electron APIs. ```typescript 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](examples/core.md) for browserWindow handle access and process-level assertions. --- ### Pattern 3: Unit Testing IPC Handlers Extract handler logic into pure functions, then unit test those functions. Mock the `electron` module so it doesn't fail outside the Electron runtime. ```typescript // 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 }; } ``` ```typescript // 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](examples/core.md) for the full IPC registration pattern and wiring handlers to `ipcMain.handle`. --- ### Pattern 4: Mocking the Electron Module When main process code imports directly from `electron`, mock the module in your test runner so tests don't fail outside the Electron runtime. ```typescript // 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](examples/mocking.md) for per-test overrides and more granular mock patterns. --- ### Pattern 5: Dialog Stubbing in E2E Tests Native dialogs cannot be interacted with by Playwright. Stub them via `evaluate()` before triggering the dialog. ```typescript 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](examples/e2e-patterns.md) for save dialog, message box, and `electron-playwright-helpers` library patterns. --- ### Pattern 6: Headless CI Configuration Electron requires a display server. On Linux CI, use `xvfb-run` or the cross-platform `xvfb-maybe` wrapper. ```yaml # 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](examples/e2e-patterns.md) for the full CI matrix and `xvfb-maybe` npm script pattern. --- ### Pattern 7: Screenshot and Visual Regression Testing Playwright's `toHaveScreenshot()` works with Electron windows for visual regression testing. ```typescript 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. </patterns> --- <decision_framework> ## Decision Framework ### What to Test Where ``` 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 ``` ### Mocking Decision ``` 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:** - [examples/core.md](examples/core.md) - Playwright launch, evaluate, firstWindow, IPC handler unit testing, preload testing - [examples/e2e-patterns.md](examples/e2e-patterns.md) - Dialog stubbing, CI setup, screenshot testing, auto-update testing, multi-window - [examples/mocking.md](examples/mocking.md) - Mocking electron module, ipcMain/ipcRenderer, BrowserWindow, dialog, contextBridge - [reference.md](reference.md) - Playwright Electron API quick reference, Spectron migration, test runner comparison --- <red_flags> ## RED FLAGS **Critical Issues:** - Not closing `electronApp` in test teardown -- leaked processes accumulate, break CI, and cause port conflicts - Running Electron E2E tests on Linux CI without xvfb -- tests fail immediately with "no display" errors - Testing IPC communication logic itself rather than handler outcomes -- the framework handles message passing, test your business logic - Using Spectron for Electron 24+ -- Spectron is unmaintained and incompatible with modern Electron **Architecture Issues:** - Putting all test logic in E2E tests when unit tests would suffice -- E2E is slow, unit test handler logic separately - Mocking `ipcRenderer` in E2E tests -- E2E tests use the real IPC channel; mock only native OS APIs (dialogs, menus) - Testing renderer components through Electron launch -- renderer is standard Chromium, test with web tools for speed - Coupling handler logic directly to `ipcMain.handle` registration -- extract handlers to pure functions for testability **Common Mistakes:** - Forgetting `await electronApp.firstWindow()` returns a `Page`, not a `BrowserWindow` -- use Playwright page API, not Electron window API - Assuming `evaluate()` can return non-serializable values (functions, DOM nodes) -- it serializes via JSON - Hardcoding file paths in E2E dialog stubs -- use `path.join(os.tmpdir(), ...)` or test fixtures - Not waiting for window load before assertions -- use `waitForLoadState()` or `waitForSelector()` before checking content **Gotchas & Edge Cases:** - `_electron.launch()` uses the `electron` binary from `node_modules/.bin/` by default -- set `executablePath` if your app bundles a different Electron version - Playwright Electron support is marked "experimental" -- API may change between major Playwright versions - `electronApp.evaluate()` receives the Electron module object (not `require("electron")`) as its first argument -- destructure `{ app }`, `{ dialog }`, etc. - Screenshot baselines differ across OSes due to font rendering -- pin to one OS in CI or use per-OS baselines - `BrowserWindow` handle from `electronApp.browserWindow(page)` returns a `JSHandle`, not a direct object -- call methods via `evaluate` on the handle - `ipcMain.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> ## 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>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.