Examples

Electron App Testing

Complete guide to testing Electron applications with nut.js and @playwright/test

electronplaywrighte2edesktop

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:

LayerToolCapabilities
Web ContentPlaywrightDOM queries, clicks, form filling, network interception
Native Desktopnut.jsWindow management, system dialogs, native menus, screenshots

Using both tools together gives you complete test coverage.

Project Setup

Installation

bash
npm install --save-dev @playwright/test @nut-tree/nut-js

Playwright Configuration

Create playwright.config.ts:

typescript
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:

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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:

typescript
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

typescript
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

typescript
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

yaml
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:

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

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

Error Handling

Capture screenshots on failure for debugging:

typescript
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

typescript
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:

typescript
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");
  });
});

Was this page helpful?