SurfSense/.cursor/skills/playwright-testing/testing-patterns/electron.md
2026-05-10 04:19:55 +05:30

13 KiB

Electron Testing

Table of Contents

  1. Setup & Configuration
  2. Launching Electron Apps
  3. Main Process Testing
  4. Renderer Process Testing
  5. IPC Communication
  6. Native Features
  7. Packaging & Distribution

Setup & Configuration

Installation

npm install -D @playwright/test electron

Basic Configuration

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  timeout: 30000,
  use: {
    trace: "on-first-retry",
  },
});

Electron Test Fixture

// fixtures/electron.ts
import {
  test as base,
  _electron as electron,
  ElectronApplication,
  Page,
} from "@playwright/test";

type ElectronFixtures = {
  electronApp: ElectronApplication;
  window: Page;
};

export const test = base.extend<ElectronFixtures>({
  electronApp: async ({}, use) => {
    // Launch Electron app
    const electronApp = await electron.launch({
      args: [".", "--no-sandbox"],
      env: {
        ...process.env,
        NODE_ENV: "test",
      },
    });

    await use(electronApp);

    // Cleanup
    await electronApp.close();
  },

  window: async ({ electronApp }, use) => {
    // Wait for first window
    const window = await electronApp.firstWindow();

    // Wait for app to be ready
    await window.waitForLoadState("domcontentloaded");

    await use(window);
  },
});

export { expect } from "@playwright/test";

Launch Options

// Advanced launch configuration
const electronApp = await electron.launch({
  args: ["main.js", "--custom-flag"],
  cwd: "/path/to/app",
  env: {
    ...process.env,
    ELECTRON_ENABLE_LOGGING: "1",
    NODE_ENV: "test",
  },
  timeout: 30000,
  // For packaged apps
  executablePath: "/path/to/MyApp.app/Contents/MacOS/MyApp",
});

Launching Electron Apps

Development Mode

test("launch in dev mode", async () => {
  const electronApp = await electron.launch({
    args: ["."], // Points to package.json main
  });

  const window = await electronApp.firstWindow();
  await expect(window.locator("h1")).toContainText("My App");

  await electronApp.close();
});

Packaged Application

test("launch packaged app", async () => {
  const appPath =
    process.platform === "darwin"
      ? "/Applications/MyApp.app/Contents/MacOS/MyApp"
      : process.platform === "win32"
        ? "C:\\Program Files\\MyApp\\MyApp.exe"
        : "/usr/bin/myapp";

  const electronApp = await electron.launch({
    executablePath: appPath,
  });

  const window = await electronApp.firstWindow();
  await expect(window).toHaveTitle(/MyApp/);

  await electronApp.close();
});

Multiple Windows

test("handle multiple windows", async ({ electronApp }) => {
  const mainWindow = await electronApp.firstWindow();

  // Trigger new window
  await mainWindow.getByRole("button", { name: "Open Settings" }).click();

  // Wait for new window
  const settingsWindow = await electronApp.waitForEvent("window");

  // Both windows are now accessible
  await expect(settingsWindow.locator("h1")).toHaveText("Settings");
  await expect(mainWindow.locator("h1")).toHaveText("Main");

  // Get all windows
  const windows = electronApp.windows();
  expect(windows.length).toBe(2);
});

Main Process Testing

Evaluate in Main Process

test("access main process", async ({ electronApp }) => {
  // Evaluate in main process context
  const appPath = await electronApp.evaluate(async ({ app }) => {
    return app.getAppPath();
  });

  expect(appPath).toContain("my-electron-app");
});

Access Electron APIs

test("electron API access", async ({ electronApp }) => {
  // Get app version
  const version = await electronApp.evaluate(async ({ app }) => {
    return app.getVersion();
  });
  expect(version).toMatch(/^\d+\.\d+\.\d+$/);

  // Get platform info
  const platform = await electronApp.evaluate(async ({ app }) => {
    return process.platform;
  });
  expect(["darwin", "win32", "linux"]).toContain(platform);

  // Check if app is ready
  const isReady = await electronApp.evaluate(async ({ app }) => {
    return app.isReady();
  });
  expect(isReady).toBe(true);
});

BrowserWindow Properties

test("check window properties", async ({ electronApp, window }) => {
  // Get BrowserWindow from main process
  const windowBounds = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.getBounds();
  });

  expect(windowBounds.width).toBeGreaterThan(0);
  expect(windowBounds.height).toBeGreaterThan(0);

  // Check window state
  const isMaximized = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.isMaximized();
  });

  // Check window title
  const title = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.getTitle();
  });
  expect(title).toBeTruthy();
});

Renderer Process Testing

Standard Page Testing

test("renderer interactions", async ({ window }) => {
  // Standard Playwright page interactions
  await window.getByRole("button", { name: "Click Me" }).click();
  await expect(window.getByText("Clicked!")).toBeVisible();

  // Fill forms
  await window.getByLabel("Username").fill("testuser");
  await window.getByLabel("Password").fill("password123");
  await window.getByRole("button", { name: "Login" }).click();

  // Verify navigation
  await expect(window).toHaveURL(/dashboard/);
});

Access Node.js in Renderer

test("node integration", async ({ window }) => {
  // If nodeIntegration is enabled
  const nodeVersion = await window.evaluate(() => {
    return (window as any).process?.version;
  });

  // Check if Node APIs are available
  const hasFs = await window.evaluate(() => {
    return typeof (window as any).require === "function";
  });
});

Context Isolation Testing

test("context isolation", async ({ window }) => {
  // Test preload script exposed APIs
  const apiAvailable = await window.evaluate(() => {
    return typeof (window as any).electronAPI !== "undefined";
  });
  expect(apiAvailable).toBe(true);

  // Call exposed API
  const result = await window.evaluate(async () => {
    return await (window as any).electronAPI.getAppVersion();
  });
  expect(result).toMatch(/^\d+\.\d+\.\d+$/);
});

IPC Communication

Testing IPC from Renderer

test("IPC invoke", async ({ window }) => {
  // Test preload-exposed IPC call
  const result = await window.evaluate(async () => {
    return await (window as any).electronAPI.getData("user-settings");
  });

  expect(result).toHaveProperty("theme");
});

Testing IPC from Main Process

test("main to renderer IPC", async ({ electronApp, window }) => {
  // Set up listener in renderer
  await window.evaluate(() => {
    (window as any).receivedMessage = null;
    (window as any).electronAPI.onMessage((msg: string) => {
      (window as any).receivedMessage = msg;
    });
  });

  // Send from main process
  await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    win.webContents.send("message", "Hello from main!");
  });

  // Verify receipt
  await window.waitForFunction(() => (window as any).receivedMessage !== null);
  const message = await window.evaluate(() => (window as any).receivedMessage);
  expect(message).toBe("Hello from main!");
});

Mock IPC Handlers

// In test setup or fixture
test("mock IPC handler", async ({ electronApp, window }) => {
  // Override IPC handler in main process
  await electronApp.evaluate(async ({ ipcMain }) => {
    // Remove existing handler
    ipcMain.removeHandler("fetch-data");

    // Add mock handler
    ipcMain.handle("fetch-data", async () => {
      return { mocked: true, data: "test-data" };
    });
  });

  // Test with mocked handler
  const result = await window.evaluate(async () => {
    return await (window as any).electronAPI.fetchData();
  });

  expect(result.mocked).toBe(true);
});

Native Features

File System Dialogs

test("file dialog", async ({ electronApp, window }) => {
  // Mock dialog response
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: false,
      filePaths: ["/mock/path/file.txt"],
    });
  });

  // Trigger file open
  await window.getByRole("button", { name: "Open File" }).click();

  // Verify file was "opened"
  await expect(window.getByText("file.txt")).toBeVisible();
});

test("save dialog", async ({ electronApp, window }) => {
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showSaveDialog = async () => ({
      canceled: false,
      filePath: "/mock/path/saved-file.txt",
    });
  });

  await window.getByRole("button", { name: "Save" }).click();
  await expect(window.getByText("Saved successfully")).toBeVisible();
});

Menu Testing

test("application menu", async ({ electronApp }) => {
  // Get menu structure
  const menuLabels = await electronApp.evaluate(async ({ Menu }) => {
    const menu = Menu.getApplicationMenu();
    return menu?.items.map((item) => item.label) || [];
  });

  expect(menuLabels).toContain("File");
  expect(menuLabels).toContain("Edit");

  // Trigger menu action
  await electronApp.evaluate(async ({ Menu }) => {
    const menu = Menu.getApplicationMenu();
    const fileMenu = menu?.items.find((item) => item.label === "File");
    const newItem = fileMenu?.submenu?.items.find(
      (item) => item.label === "New",
    );
    newItem?.click();
  });
});

Native Notifications

test("notifications", async ({ electronApp, window }) => {
  // Mock Notification
  let notificationShown = false;
  await electronApp.evaluate(async ({ Notification }) => {
    const OriginalNotification = Notification;
    (global as any).Notification = class extends OriginalNotification {
      constructor(options: any) {
        super(options);
        (global as any).lastNotification = options;
      }
    };
  });

  // Trigger notification
  await window.getByRole("button", { name: "Notify" }).click();

  // Verify notification was created
  const notification = await electronApp.evaluate(async () => {
    return (global as any).lastNotification;
  });

  expect(notification.title).toBe("New Message");
});

Clipboard

test("clipboard operations", async ({ electronApp, window }) => {
  // Write to clipboard
  await electronApp.evaluate(async ({ clipboard }) => {
    clipboard.writeText("Test clipboard content");
  });

  // Paste in app
  await window.getByRole("textbox").focus();
  await window.keyboard.press("ControlOrMeta+v");

  // Read clipboard
  const clipboardContent = await electronApp.evaluate(async ({ clipboard }) => {
    return clipboard.readText();
  });

  expect(clipboardContent).toBe("Test clipboard content");
});

Packaging & Distribution

Testing Packaged Apps

// fixtures/packaged-electron.ts
import { test as base, _electron as electron } from "@playwright/test";
import path from "path";
import { execSync } from "child_process";

export const test = base.extend({
  electronApp: async ({}, use) => {
    // Build the app first (or use pre-built)
    const distPath = path.join(__dirname, "../dist");

    let executablePath: string;
    if (process.platform === "darwin") {
      executablePath = path.join(
        distPath,
        "mac",
        "MyApp.app",
        "Contents",
        "MacOS",
        "MyApp",
      );
    } else if (process.platform === "win32") {
      executablePath = path.join(distPath, "win-unpacked", "MyApp.exe");
    } else {
      executablePath = path.join(distPath, "linux-unpacked", "myapp");
    }

    const electronApp = await electron.launch({ executablePath });
    await use(electronApp);
    await electronApp.close();
  },
});

Anti-Patterns to Avoid

Anti-Pattern Problem Solution
Not closing ElectronApplication Resource leaks Always call electronApp.close() in cleanup
Hardcoded executable paths Breaks cross-platform Use platform detection
Testing packaged app without building Outdated code Build before testing or test dev mode
Ignoring IPC in tests Missing coverage Test IPC communication explicitly
Not mocking native dialogs Tests hang waiting for input Mock dialog responses