# Electron Testing

> **When to use**: When your application is an Electron desktop app and you need end-to-end tests covering the renderer process, main process, IPC communication, native dialogs, system tray, and multi-window workflows.
> **Prerequisites**: [core/configuration.md](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)

## Quick Reference

```typescript
import { _electron as electron } from 'playwright';

// Launch the Electron app
const app = await electron.launch({ args: ['./main.js'] });

// Get the first window (renderer process)
const window = await app.firstWindow();

// Access the main process for evaluation
const appPath = await app.evaluate(async ({ app }) => {
  return app.getPath('userData');
});

// Close the app
await app.close();
```

## Patterns

### Basic Electron App Setup

**Use when**: Starting to test an Electron app with Playwright for the first time.
**Avoid when**: Your app is a web app, not an Electron app.

**TypeScript**
```typescript
import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';

let app: ElectronApplication;
let window: Page;

test.beforeAll(async () => {
  // Launch the Electron app from your project directory
  app = await electron.launch({
    args: ['./dist/main.js'],
    env: {
      ...process.env,
      NODE_ENV: 'test',
    },
  });

  // Wait for the first BrowserWindow to open
  window = await app.firstWindow();

  // Optional: wait for the app to be fully loaded
  await window.waitForLoadState('domcontentloaded');
});

test.afterAll(async () => {
  await app.close();
});

test('app window has correct title', async () => {
  const title = await window.title();
  expect(title).toBe('My Electron App');
});

test('main page renders', async () => {
  await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect, _electron: electron } = require('@playwright/test');

let app;
let window;

test.beforeAll(async () => {
  app = await electron.launch({
    args: ['./dist/main.js'],
    env: {
      ...process.env,
      NODE_ENV: 'test',
    },
  });
  window = await app.firstWindow();
  await window.waitForLoadState('domcontentloaded');
});

test.afterAll(async () => {
  await app.close();
});

test('app window has correct title', async () => {
  const title = await window.title();
  expect(title).toBe('My Electron App');
});

test('main page renders', async () => {
  await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
```

### Electron App Fixture (Recommended)

**Use when**: You want isolated, reusable Electron app instances across test files.
**Avoid when**: All tests can share a single app instance (rare in practice).

**TypeScript**
```typescript
// fixtures.ts
import { test as base, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';

type ElectronFixtures = {
  electronApp: ElectronApplication;
  window: Page;
};

export const test = base.extend<ElectronFixtures>({
  electronApp: async ({}, use) => {
    const app = await electron.launch({
      args: ['./dist/main.js'],
      env: { ...process.env, NODE_ENV: 'test' },
    });
    await use(app);
    await app.close();
  },

  window: async ({ electronApp }, use) => {
    const window = await electronApp.firstWindow();
    await window.waitForLoadState('domcontentloaded');
    await use(window);
  },
});

export { expect };
```

```typescript
// app.spec.ts
import { test, expect } from './fixtures';

test('navigate to settings', async ({ window }) => {
  await window.getByRole('link', { name: 'Settings' }).click();
  await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
```

**JavaScript**
```javascript
// fixtures.js
const { test: base, expect, _electron: electron } = require('@playwright/test');

const test = base.extend({
  electronApp: async ({}, use) => {
    const app = await electron.launch({
      args: ['./dist/main.js'],
      env: { ...process.env, NODE_ENV: 'test' },
    });
    await use(app);
    await app.close();
  },

  window: async ({ electronApp }, use) => {
    const window = await electronApp.firstWindow();
    await window.waitForLoadState('domcontentloaded');
    await use(window);
  },
});

module.exports = { test, expect };
```

### Accessing the Main Process

**Use when**: You need to read Electron app state, check paths, get app version, or verify main process behavior.
**Avoid when**: Everything you need is in the renderer (UI). Prefer testing through the UI.

`app.evaluate()` runs code in the main process with access to all Electron APIs.

**TypeScript**
```typescript
import { test, expect } from './fixtures';

test('verify app version and paths', async ({ electronApp }) => {
  // Evaluate in the main process — receives the Electron module
  const appInfo = await electronApp.evaluate(async ({ app }) => {
    return {
      version: app.getVersion(),
      name: app.getName(),
      userData: app.getPath('userData'),
      locale: app.getLocale(),
      isPackaged: app.isPackaged,
    };
  });

  expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/);
  expect(appInfo.name).toBe('my-electron-app');
  expect(appInfo.userData).toBeTruthy();
  expect(appInfo.isPackaged).toBe(false); // false during development
});

test('main process environment variables are set', async ({ electronApp }) => {
  const nodeEnv = await electronApp.evaluate(async () => {
    return process.env.NODE_ENV;
  });

  expect(nodeEnv).toBe('test');
});
```

**JavaScript**
```javascript
const { test, expect } = require('./fixtures');

test('verify app version and paths', async ({ electronApp }) => {
  const appInfo = await electronApp.evaluate(async ({ app }) => {
    return {
      version: app.getVersion(),
      name: app.getName(),
      userData: app.getPath('userData'),
      isPackaged: app.isPackaged,
    };
  });

  expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/);
  expect(appInfo.name).toBe('my-electron-app');
});
```

### Testing IPC Communication

**Use when**: Your app uses `ipcMain` / `ipcRenderer` for communication between the main and renderer processes.
**Avoid when**: IPC is an implementation detail and the behavior is fully testable through the UI.

**TypeScript**
```typescript
import { test, expect } from './fixtures';

test('renderer sends IPC message and gets response', async ({ electronApp, window }) => {
  // Trigger an IPC call from the renderer
  const result = await window.evaluate(async () => {
    // Assumes your preload script exposes ipcRenderer via contextBridge
    return await (window as any).electronAPI.getSystemInfo();
  });

  expect(result).toHaveProperty('platform');
  expect(result).toHaveProperty('arch');
  expect(result.platform).toBeTruthy();
});

test('main process handles IPC file-read request', async ({ electronApp, window }) => {
  // Set up a listener in the main process first
  await electronApp.evaluate(async ({ ipcMain }) => {
    ipcMain.handle('test-ping', async () => {
      return { pong: true, timestamp: Date.now() };
    });
  });

  // Send from renderer
  const response = await window.evaluate(async () => {
    return await (window as any).electronAPI.invoke('test-ping');
  });

  expect(response.pong).toBe(true);
  expect(response.timestamp).toBeGreaterThan(0);
});

test('IPC event triggers UI update', async ({ window }) => {
  // Simulate the main process sending an event to the renderer
  await window.evaluate(() => {
    // Trigger a custom event that the app listens for
    window.dispatchEvent(new CustomEvent('app:notification', {
      detail: { message: 'Update available', version: '2.0.0' },
    }));
  });

  await expect(window.getByText('Update available')).toBeVisible();
  await expect(window.getByText('Version 2.0.0')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('./fixtures');

test('renderer sends IPC message and gets response', async ({ electronApp, window }) => {
  const result = await window.evaluate(async () => {
    return await window.electronAPI.getSystemInfo();
  });

  expect(result).toHaveProperty('platform');
  expect(result).toHaveProperty('arch');
});

test('IPC event triggers UI update', async ({ window }) => {
  await window.evaluate(() => {
    window.dispatchEvent(new CustomEvent('app:notification', {
      detail: { message: 'Update available', version: '2.0.0' },
    }));
  });

  await expect(window.getByText('Update available')).toBeVisible();
});
```

### File System Dialogs

**Use when**: Your app uses Electron's `dialog.showOpenDialog`, `dialog.showSaveDialog`, or similar native file dialogs.
**Avoid when**: File selection is handled by a web input (`<input type="file">`). Use standard Playwright file chooser for that.

Native dialogs cannot be interacted with directly. Mock them in the main process.

**TypeScript**
```typescript
import { test, expect } from './fixtures';

test('open file dialog and load a document', async ({ electronApp, window }) => {
  // Mock the dialog to return a specific file path
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: false,
      filePaths: ['/tmp/test-document.txt'],
    });
  });

  // Click the "Open File" button in the renderer
  await window.getByRole('button', { name: 'Open File' }).click();

  // Verify the app loaded the file
  await expect(window.getByTestId('file-name')).toHaveText('test-document.txt');
});

test('save file dialog returns selected path', async ({ electronApp, window }) => {
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showSaveDialog = async () => ({
      canceled: false,
      filePath: '/tmp/exported-report.pdf',
    });
  });

  await window.getByRole('button', { name: 'Export PDF' }).click();
  await expect(window.getByText('Saved to /tmp/exported-report.pdf')).toBeVisible();
});

test('handle canceled file dialog', async ({ electronApp, window }) => {
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: true,
      filePaths: [],
    });
  });

  await window.getByRole('button', { name: 'Open File' }).click();

  // App should not crash or change state
  await expect(window.getByTestId('file-name')).toHaveText('No file selected');
});
```

**JavaScript**
```javascript
const { test, expect } = require('./fixtures');

test('open file dialog and load a document', async ({ electronApp, window }) => {
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: false,
      filePaths: ['/tmp/test-document.txt'],
    });
  });

  await window.getByRole('button', { name: 'Open File' }).click();
  await expect(window.getByTestId('file-name')).toHaveText('test-document.txt');
});

test('handle canceled file dialog', async ({ electronApp, window }) => {
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: true,
      filePaths: [],
    });
  });

  await window.getByRole('button', { name: 'Open File' }).click();
  await expect(window.getByTestId('file-name')).toHaveText('No file selected');
});
```

### System Tray Testing

**Use when**: Your app has a system tray icon with context menus or status indicators.
**Avoid when**: Your app has no tray functionality.

Playwright cannot directly click system tray icons. Test the tray logic by evaluating in the main process.

**TypeScript**
```typescript
import { test, expect } from './fixtures';

test('tray icon is created on app launch', async ({ electronApp }) => {
  const hasTray = await electronApp.evaluate(async ({ BrowserWindow }) => {
    // Access the tray via a reference your app stores
    const { tray } = require('./tray-manager');
    return tray !== null && !tray.isDestroyed();
  });

  expect(hasTray).toBe(true);
});

test('tray tooltip shows unread count', async ({ electronApp }) => {
  const tooltip = await electronApp.evaluate(async () => {
    const { tray } = require('./tray-manager');
    return tray.getToolTip();
  });

  expect(tooltip).toMatch(/\d+ unread messages?/);
});

test('clicking tray "Show" menu item opens the window', async ({ electronApp }) => {
  // Simulate clicking a tray menu item by invoking its callback
  await electronApp.evaluate(async ({ BrowserWindow }) => {
    const { trayMenu } = require('./tray-manager');
    // Find the "Show" menu item and invoke its click handler
    const showItem = trayMenu.items.find((item: any) => item.label === 'Show');
    if (showItem && showItem.click) {
      showItem.click();
    }
  });

  // The main window should now be visible
  const window = await electronApp.firstWindow();
  const isVisible = await window.evaluate(() => {
    return document.visibilityState === 'visible';
  });
  expect(isVisible).toBe(true);
});
```

**JavaScript**
```javascript
const { test, expect } = require('./fixtures');

test('tray icon is created on app launch', async ({ electronApp }) => {
  const hasTray = await electronApp.evaluate(async () => {
    const { tray } = require('./tray-manager');
    return tray !== null && !tray.isDestroyed();
  });

  expect(hasTray).toBe(true);
});
```

### Multiple Windows

**Use when**: Your Electron app opens multiple windows (preferences, about, detached panels).
**Avoid when**: Your app uses a single window.

**TypeScript**
```typescript
import { test, expect } from './fixtures';

test('open and interact with preferences window', async ({ electronApp, window }) => {
  // Click the menu item or button that opens the preferences window
  await window.getByRole('menuitem', { name: 'Preferences' }).click();

  // Wait for the new window to appear
  const prefsWindow = await electronApp.waitForEvent('window');
  await prefsWindow.waitForLoadState('domcontentloaded');

  // Interact with the preferences window
  await prefsWindow.getByLabel('Theme').selectOption('dark');
  await prefsWindow.getByRole('button', { name: 'Save' }).click();

  // Verify the main window reflects the change
  await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark');

  // Close the preferences window
  await prefsWindow.close();
});

test('get all open windows', async ({ electronApp, window }) => {
  // Open a second window
  await window.getByRole('button', { name: 'New Window' }).click();

  // Get all windows
  const allWindows = electronApp.windows();
  expect(allWindows.length).toBe(2);

  // Find the new window (not the main one)
  const newWindow = allWindows.find((w) => w !== window)!;
  await expect(newWindow.getByRole('heading')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('./fixtures');

test('open and interact with preferences window', async ({ electronApp, window }) => {
  await window.getByRole('menuitem', { name: 'Preferences' }).click();

  const prefsWindow = await electronApp.waitForEvent('window');
  await prefsWindow.waitForLoadState('domcontentloaded');

  await prefsWindow.getByLabel('Theme').selectOption('dark');
  await prefsWindow.getByRole('button', { name: 'Save' }).click();

  await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark');
  await prefsWindow.close();
});
```

### Testing Packaged/Built Apps

**Use when**: You want to test the production build of your Electron app (after `electron-builder`, `electron-forge`, etc.).
**Avoid when**: Development mode testing is sufficient for your CI pipeline.

**TypeScript**
```typescript
import { test, expect, _electron as electron } from '@playwright/test';
import path from 'path';

test('packaged app launches and works', async () => {
  // Path to the packaged app executable
  const appPath = process.platform === 'darwin'
    ? path.join(__dirname, '../dist/mac/MyApp.app/Contents/MacOS/MyApp')
    : process.platform === 'win32'
      ? path.join(__dirname, '../dist/win-unpacked/MyApp.exe')
      : path.join(__dirname, '../dist/linux-unpacked/my-app');

  const app = await electron.launch({
    executablePath: appPath,
  });

  const window = await app.firstWindow();
  await window.waitForLoadState('domcontentloaded');

  // Verify the packaged app works correctly
  const title = await window.title();
  expect(title).toBe('My Electron App');

  await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();

  // Verify it reports as packaged
  const isPackaged = await app.evaluate(async ({ app }) => app.isPackaged);
  expect(isPackaged).toBe(true);

  await app.close();
});
```

**JavaScript**
```javascript
const { test, expect, _electron: electron } = require('@playwright/test');
const path = require('path');

test('packaged app launches and works', async () => {
  const appPath = process.platform === 'darwin'
    ? path.join(__dirname, '../dist/mac/MyApp.app/Contents/MacOS/MyApp')
    : process.platform === 'win32'
      ? path.join(__dirname, '../dist/win-unpacked/MyApp.exe')
      : path.join(__dirname, '../dist/linux-unpacked/my-app');

  const app = await electron.launch({
    executablePath: appPath,
  });

  const window = await app.firstWindow();
  await window.waitForLoadState('domcontentloaded');

  const title = await window.title();
  expect(title).toBe('My Electron App');

  await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
  await app.close();
});
```

## Decision Guide

| Scenario | Approach | Why |
|---|---|---|
| Launch Electron app for testing | `_electron.launch({ args: ['./main.js'] })` | Playwright's built-in Electron support |
| Get the main window | `app.firstWindow()` | Returns the first `BrowserWindow` as a Playwright `Page` |
| Read main process state | `app.evaluate(({ app }) => ...)` | Runs code in the main process with Electron APIs |
| Test IPC round-trips | `window.evaluate` (renderer) + `app.evaluate` (main) | Cover both sides of the IPC bridge |
| Mock native file dialogs | Override `dialog.showOpenDialog` via `app.evaluate` | Native dialogs cannot be automated directly |
| Test system tray | `app.evaluate` to invoke tray callbacks | Tray icons are OS-native; not clickable via Playwright |
| Multiple windows | `app.waitForEvent('window')` | Captures new `BrowserWindow` instances as they open |
| Test packaged builds | `electron.launch({ executablePath })` | Points to the built binary instead of source |

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
|---|---|---|
| `const { app } = require('electron')` in test files | Electron APIs are not available in Playwright's Node process | Use `electronApp.evaluate(({ app }) => ...)` |
| Directly import renderer code into tests | Bypasses the actual app lifecycle and IPC | Test through the UI via the `window` (Page) object |
| Skip `waitForLoadState` after `firstWindow()` | Window may not be fully rendered | Always `await window.waitForLoadState('domcontentloaded')` |
| Test tray by clicking system-level UI | Playwright cannot interact with native OS chrome | Mock tray menu callbacks via `app.evaluate` |
| Share a single `ElectronApplication` across all tests without cleanup | State leaks between tests | Use fixtures with `app.close()` in teardown |
| Forget to close the app in `afterAll` | Leaves Electron processes running, eating CI resources | Always `await app.close()` in teardown |

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `electron.launch()` throws "cannot find module" | `args` path does not point to your main entry file | Verify the path: `args: ['./dist/main.js']` relative to the working directory |
| `firstWindow()` times out | App does not open a `BrowserWindow` in time | Check that the app creates a window on startup; increase timeout |
| `app.evaluate` cannot access Electron modules | Destructuring the wrong parameter | Destructure correctly: `evaluate(async ({ app, dialog, BrowserWindow }) => ...)` |
| Dialog mock does not take effect | Mock applied after the dialog was already called | Set up mocks before triggering the UI action that opens the dialog |
| Second window not captured | `waitForEvent('window')` registered after the window opened | Register the event listener before triggering the action that opens the window |
| Tests hang after `app.close()` | Child processes spawned by the app are still running | Ensure your Electron app cleans up child processes on quit |
| Packaged app test fails with path error | Executable path varies by OS and build tool | Use `process.platform` to compute the correct path |
| `window.evaluate` throws context destroyed | Window was closed or navigated during evaluation | Ensure the window is stable before evaluating |

## Related

- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrapping Electron app launch in fixtures
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- all standard assertions work on Electron windows
- [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) -- Electron apps often embed web components or iframes
- [core/browser-apis.md](browser-apis.md) -- localStorage, IndexedDB, and other APIs work the same in Electron
