feat: add e2e test cursor skill

This commit is contained in:
Anish Sarkar 2026-05-04 13:54:13 +05:30
parent 2e1b9b5582
commit 4ac994792b
45 changed files with 39848 additions and 0 deletions

View file

@ -0,0 +1,622 @@
# 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