mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 01:32:40 +02:00
13 KiB
13 KiB
Electron Testing
Table of Contents
- Setup & Configuration
- Launching Electron Apps
- Main Process Testing
- Renderer Process Testing
- IPC Communication
- Native Features
- 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 |
Related References
- Fixtures: See fixtures-hooks.md for custom fixture patterns
- Component Testing: See component-testing.md for renderer testing patterns
- Debugging: See debugging.md for troubleshooting