Back to Sanity

Electron Testing

.agents/skills/playwright-best-practices/testing-patterns/electron.md

5.24.012.9 KB
Original Source

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

bash
npm install -D @playwright/test electron

Basic Configuration

typescript
// playwright.config.ts
import {defineConfig} from '@playwright/test'

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

Electron Test Fixture

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

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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

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

typescript
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()
})
typescript
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

typescript
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

typescript
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

typescript
// 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-PatternProblemSolution
Not closing ElectronApplicationResource leaksAlways call electronApp.close() in cleanup
Hardcoded executable pathsBreaks cross-platformUse platform detection
Testing packaged app without buildingOutdated codeBuild before testing or test dev mode
Ignoring IPC in testsMissing coverageTest IPC communication explicitly
Not mocking native dialogsTests hang waiting for inputMock dialog responses