Electron apps combine web technologies with native desktop capabilities, making them uniquely challenging to test. This guide shows how to leverage both Playwright's web automation and nut.js's desktop automation for comprehensive Electron testing.
Why Combine Playwright and nut.js?
Electron apps have two distinct layers:
| Layer | Tool | Capabilities |
|---|---|---|
| Web Content | Playwright | DOM queries, clicks, form filling, network interception |
| Native Desktop | nut.js | Window management, system dialogs, native menus, screenshots |
Using both tools together gives you complete test coverage.
Project Setup
Installation
npm install --save-dev @playwright/test @nut-tree/nut-jsPlaywright Configuration
Create playwright.config.ts:
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30000,
workers: 1,
})Important: Set
workers: 1— desktop automation requires sequential execution since tests interact with the same screen and window manager.
Basic Test Structure
Here's the foundational pattern for Electron + nut.js testing:
import { _electron as electron, ElectronApplication, Page, JSHandle } from "playwright";
import { test, expect } from "@playwright/test";
import { sleep, getActiveWindow, screen, getWindows } from "@nut-tree/nut-js";
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let app: ElectronApplication;
let page: Page;
let windowHandle: JSHandle;
const APP_TIMEOUT = 10000;
test.beforeEach(async () => {
// Launch the Electron app
app = await electron.launch({
args: ["main.cjs"],
cwd: __dirname,
env: {
...process.env,
NODE_ENV: "development",
},
});
// Get the first window
page = await app.firstWindow({ timeout: APP_TIMEOUT });
windowHandle = await app.browserWindow(page);
// Wait for the app to be ready
await page.waitForLoadState("domcontentloaded");
// Ensure window is focused (critical for nut.js)
await windowHandle.evaluate((win: any) => {
win.minimize();
win.restore();
win.focus();
});
});
test.afterEach(async () => {
if (app) {
await app.close();
}
});Window Management Tests
Listing Application Windows
test.describe("getWindows", () => {
test("should list our started application window", async () => {
const openWindows = await getWindows();
const windowNames = await Promise.all(openWindows.map((wnd) => wnd.title));
expect(windowNames).toContain("My Electron App");
});
});Getting the Active Window
test.describe("getActiveWindow", () => {
test("should return our started application window", async () => {
const foregroundWindow = await getActiveWindow();
const windowTitle = await foregroundWindow.title;
expect(windowTitle).toBe("My Electron App");
});
test("should determine correct window position", async () => {
const foregroundWindow = await getActiveWindow();
const region = await foregroundWindow.region;
expect(region.left).toBeGreaterThanOrEqual(0);
expect(region.top).toBeGreaterThanOrEqual(0);
expect(region.width).toBeGreaterThan(0);
expect(region.height).toBeGreaterThan(0);
});
});Moving and Resizing Windows
test("should move the window to a new position", async () => {
const newX = 142;
const newY = 425;
const foregroundWindow = await getActiveWindow();
await foregroundWindow.move({ x: newX, y: newY });
await sleep(1000); // Wait for window manager
const region = await foregroundWindow.region;
expect(region.left).toBe(newX);
expect(region.top).toBe(newY);
});
test("should resize the window", async () => {
const newWidth = 800;
const newHeight = 600;
const foregroundWindow = await getActiveWindow();
await foregroundWindow.resize({ width: newWidth, height: newHeight });
await sleep(1000);
const region = await foregroundWindow.region;
expect(region.width).toBe(newWidth);
expect(region.height).toBe(newHeight);
});Handling Screen Boundaries
test.describe("window regions", () => {
test("should handle window positioned beyond left edge", async () => {
const newLeft = -40;
const originalWidth = 400;
const foregroundWindow = await getActiveWindow();
await foregroundWindow.move({ x: newLeft, y: 100 });
await sleep(1000);
const region = await foregroundWindow.region;
// Window is cropped to screen boundary
expect(region.left).toBe(0);
expect(region.width).toBe(originalWidth + newLeft);
});
test("should handle window positioned beyond right edge", async () => {
const screenWidth = await screen.width();
const delta = 40;
const newLeft = screenWidth - delta;
const foregroundWindow = await getActiveWindow();
await foregroundWindow.move({ x: newLeft, y: 100 });
await sleep(1000);
const region = await foregroundWindow.region;
expect(region.left).toBe(newLeft);
expect(region.width).toBe(delta);
});
});Hybrid Web + Desktop Testing
The real power comes from combining Playwright's DOM access with nut.js's desktop capabilities.
Testing Web Content + Native Dialogs
import { keyboard, Key, mouse, centerOf, imageResource } from "@nut-tree/nut-js";
test("should save file using native dialog", async () => {
// Use Playwright to trigger the save action
await page.click('[data-testid="save-button"]');
// Wait for native dialog to appear
await sleep(1000);
// Handle native file dialog with nut.js
if (process.platform === 'darwin') {
// macOS: Type filename and press Enter
await keyboard.type("test-document.txt");
await sleep(300);
await keyboard.pressKey(Key.Enter);
await keyboard.releaseKey(Key.Enter);
} else if (process.platform === 'win32') {
// Windows: Similar approach
await keyboard.type("test-document.txt");
await sleep(300);
await keyboard.pressKey(Key.Enter);
await keyboard.releaseKey(Key.Enter);
}
// Verify save completed via web content
await expect(page.locator('[data-testid="save-status"]')).toHaveText("Saved");
});Testing Native Menus
test("should open preferences via native menu", async () => {
if (process.platform === 'darwin') {
// macOS: App Menu > Preferences
await keyboard.pressKey(Key.LeftSuper, Key.Comma);
await keyboard.releaseKey(Key.LeftSuper, Key.Comma);
} else {
// Windows/Linux: File > Preferences or Edit > Preferences
await keyboard.pressKey(Key.LeftAlt);
await keyboard.releaseKey(Key.LeftAlt);
await sleep(200);
await keyboard.type("f"); // File menu
await sleep(200);
await keyboard.type("p"); // Preferences
}
await sleep(500);
// Verify preferences window opened (via Playwright or nut.js)
const windows = await getWindows();
const titles = await Promise.all(windows.map((w) => w.title));
expect(titles.some(t => t.includes("Preferences"))).toBe(true);
});Testing Drag and Drop
import { mouse, centerOf, straightTo, Button } from "@nut-tree/nut-js";
test("should drag item from sidebar to main area", async () => {
// Get element positions via Playwright
const sourceElement = await page.locator('[data-testid="sidebar-item"]').boundingBox();
const targetElement = await page.locator('[data-testid="drop-zone"]').boundingBox();
if (!sourceElement || !targetElement) {
throw new Error("Elements not found");
}
// Perform native drag with nut.js
const sourceCenter = {
x: sourceElement.x + sourceElement.width / 2,
y: sourceElement.y + sourceElement.height / 2,
};
const targetCenter = {
x: targetElement.x + targetElement.width / 2,
y: targetElement.y + targetElement.height / 2,
};
await mouse.move(straightTo(sourceCenter));
await mouse.pressButton(Button.LEFT);
await sleep(100);
await mouse.move(straightTo(targetCenter));
await sleep(100);
await mouse.releaseButton(Button.LEFT);
// Verify drop completed
await expect(page.locator('[data-testid="drop-zone"]')).toContainText("Item dropped");
});Image-Based Verification
Use nut.js screen capture for visual verification:
import { screen, Region, imageResource } from "@nut-tree/nut-js";
test("should display the correct toolbar icons", async () => {
// Get window region
const foregroundWindow = await getActiveWindow();
const windowRegion = await foregroundWindow.region;
// Define toolbar region
const toolbarRegion = new Region(
windowRegion.left,
windowRegion.top,
windowRegion.width,
60
);
// Find expected icon in toolbar
const saveIcon = await screen.find(imageResource("save-icon.png"), {
searchRegion: toolbarRegion,
});
expect(saveIcon).toBeDefined();
});
test("should take screenshot on failure", async () => {
try {
await expect(page.locator('[data-testid="missing-element"]')).toBeVisible();
} catch (error) {
// Capture desktop screenshot for debugging
await screen.capture(`./screenshots/failure-${Date.now()}.png`);
throw error;
}
});Testing Multi-Window Scenarios
test("should open and manage multiple windows", async () => {
// Open new window via web content
await page.click('[data-testid="new-window-button"]');
await sleep(1000);
// Verify new window exists
const windows = await getWindows();
const appWindows = [];
for (const win of windows) {
const title = await win.title;
if (title.includes("My Electron App")) {
appWindows.push(win);
}
}
expect(appWindows.length).toBe(2);
// Focus the second window
if (appWindows[1]) {
await appWindows[1].focus();
await sleep(500);
const activeWindow = await getActiveWindow();
const activeTitle = await activeWindow.title;
expect(activeTitle).toBe(await appWindows[1].title);
}
});System Tray Testing
test("should show tray menu when clicking system tray icon", async () => {
// Minimize app to tray
await windowHandle.evaluate((win: any) => win.hide());
await sleep(500);
// Platform-specific tray interaction
if (process.platform === 'darwin') {
// macOS: Tray is in menu bar - find and click the icon
const trayIcon = await screen.find(imageResource("tray-icon-mac.png"));
await mouse.move(straightTo(centerOf(trayIcon)));
await mouse.click(Button.LEFT);
} else if (process.platform === 'win32') {
// Windows: Tray is in system tray area
const trayIcon = await screen.find(imageResource("tray-icon-win.png"));
await mouse.move(straightTo(centerOf(trayIcon)));
await mouse.click(Button.RIGHT); // Right-click for context menu
}
await sleep(300);
// Verify tray menu appeared
const menuItem = await screen.find(imageResource("tray-menu-open.png"));
expect(menuItem).toBeDefined();
});CI/CD Considerations
GitHub Actions
name: E2E Tests
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
# Linux requires virtual display
- name: Setup virtual display (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get install -y xvfb
Xvfb :99 -screen 0 1920x1080x24 &
echo "DISPLAY=:99" >> $GITHUB_ENV
- name: Run E2E tests
run: npx playwright test
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshots-${{ matrix.os }}
path: screenshots/Best Practices
Window Focus
Always ensure the window is focused before nut.js operations:
// Force focus before each test
test.beforeEach(async () => {
await windowHandle.evaluate((win: any) => {
win.minimize();
win.restore();
win.focus();
});
await sleep(300);
});Timing
Desktop operations need time to complete:
// Wait for window manager operations
await foregroundWindow.move({ x: 100, y: 100 });
await sleep(1000); // Wait for animation
// Wait for native dialogs
await page.click('[data-testid="open-dialog"]');
await sleep(1000); // Dialog animationError Handling
Capture screenshots on failure for debugging:
test.afterEach(async ({}, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
const name = testInfo.title.replace(/\s+/g, '-');
await screen.capture(`./screenshots/${name}-${Date.now()}.png`);
}
});Platform-Specific Logic
function getPlatformShortcut(key: string): Key[] {
const modifier = process.platform === 'darwin' ? Key.LeftSuper : Key.LeftControl;
const keyMap: Record<string, Key> = {
's': Key.S,
'c': Key.C,
'v': Key.V,
};
return [modifier, keyMap[key] || Key.A];
}Complete Example
Here's a full test file combining all concepts:
import { _electron as electron, ElectronApplication, Page, JSHandle } from "playwright";
import { test, expect } from "@playwright/test";
import {
sleep,
getActiveWindow,
screen,
getWindows,
keyboard,
mouse,
Key,
Button,
centerOf,
straightTo,
imageResource,
} from "@nut-tree/nut-js";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let app: ElectronApplication;
let page: Page;
let windowHandle: JSHandle;
test.beforeEach(async () => {
app = await electron.launch({
args: [join(__dirname, "main.cjs")],
env: { ...process.env, NODE_ENV: "test" },
});
page = await app.firstWindow({ timeout: 10000 });
windowHandle = await app.browserWindow(page);
await page.waitForLoadState("domcontentloaded");
await windowHandle.evaluate((win: any) => {
win.minimize();
win.restore();
win.focus();
});
await sleep(500);
});
test.afterEach(async ({}, testInfo) => {
// Screenshot on failure
if (testInfo.status !== testInfo.expectedStatus) {
await screen.capture(`./screenshots/fail-${Date.now()}.png`);
}
if (app) {
await app.close();
}
});
test.describe("Document Editor", () => {
test("should create, edit, and save a document", async () => {
// 1. Create new document (web content)
await page.click('[data-testid="new-doc-button"]');
await expect(page.locator('[data-testid="editor"]')).toBeVisible();
// 2. Type content using nut.js keyboard
await page.click('[data-testid="editor"]');
await keyboard.type("Hello, this is a test document.");
// 3. Save using keyboard shortcut
const modifier = process.platform === 'darwin' ? Key.LeftSuper : Key.LeftControl;
await keyboard.pressKey(modifier, Key.S);
await keyboard.releaseKey(modifier, Key.S);
// 4. Handle native save dialog
await sleep(1000);
await keyboard.type("test-document");
await keyboard.pressKey(Key.Enter);
await keyboard.releaseKey(Key.Enter);
// 5. Verify save completed
await sleep(500);
await expect(page.locator('[data-testid="save-status"]')).toHaveText("Saved");
});
test("should verify window appears in nut.js window list", async () => {
const windows = await getWindows();
const titles = await Promise.all(windows.map(w => w.title));
expect(titles).toContain("Document Editor");
});
});