Cross-Platform Electron App Testing: Windows, macOS, and Linux
One of Electron's selling points is write-once-run-everywhere. In practice, cross-platform bugs are one of the top sources of Electron app issues: keyboard shortcuts that work on macOS but not Windows, path handling that breaks on Linux, UI that looks fine on Retina displays but wrong at 100% DPI, and file system behavior that differs across operating systems.
Cross-platform testing requires running your tests on all three platforms. This guide covers platform-specific bugs to test for, CI pipeline setup, and testing patterns that catch cross-platform issues early.
Platform-Specific Bug Categories
Path Handling
The most common cross-platform bug in Electron apps:
// WRONG - breaks on Windows
const configPath = `${homeDir}/.myapp/config.json`;
// CORRECT - use path.join
const configPath = path.join(homeDir, '.myapp', 'config.json');Windows-specific issues:
\vs/separator (usepath.join()orpath.posix/path.win32as needed)- Drive letters in paths (
C:\Users\...) - UNC paths for network shares
- Reserved filenames: CON, PRN, AUX, NUL, COM1-9, LPT1-9 — don't create files with these names
macOS-specific issues:
- Case-insensitive filesystem by default (APFS can be case-sensitive but usually isn't)
.DS_Storefiles that may affect file listing tests- App Sandbox and Gatekeeper affecting file access
Linux-specific issues:
- Case-sensitive filesystem
- Different home directory structure
- No concept of "Documents" folder in the same way as Windows/macOS
Test path handling:
// tests/utils/paths.test.js
const path = require('path');
const { getConfigPath, getLogPath } = require('../../main/utils/paths');
describe('path utilities', () => {
it('config path uses platform path separator', () => {
const configPath = getConfigPath();
// Verify path is valid for current platform
expect(path.isAbsolute(configPath)).toBe(true);
expect(configPath).toContain(path.sep); // Uses correct separator
expect(configPath).toMatch(/config\.json$/);
});
it('paths do not contain platform-specific hardcoded separators', () => {
const paths = [getConfigPath(), getLogPath()];
// If running on Windows, paths should not contain forward slashes (unless URL)
if (process.platform === 'win32') {
paths.forEach(p => {
// Forward slashes in Windows paths are often bugs
expect(p.includes('//')).toBe(false); // No double forward slashes
});
}
});
it('handles paths with spaces', () => {
// Users on all platforms can have spaces in their home directory
// "/Users/John Smith/..." or "C:\Users\John Smith\..."
const pathWithSpaces = path.join('/Users/John Smith', '.myapp', 'config.json');
// Your path utilities should handle this without breaking
expect(() => validatePath(pathWithSpaces)).not.toThrow();
});
});Keyboard Shortcuts
macOS uses Cmd where Windows/Linux use Ctrl. Electron's globalShortcut and menu accelerators handle this automatically if you use the right syntax:
// WRONG - only works on macOS
{ accelerator: 'Command+S', ... }
// CORRECT - works cross-platform
{ accelerator: 'CmdOrCtrl+S', ... }Test keyboard shortcut registrations:
// tests/main/menu.test.js
const { buildMenu } = require('../../main/menu');
describe('application menu', () => {
let menu;
beforeAll(() => {
menu = buildMenu();
});
it('uses CmdOrCtrl not Command for cross-platform shortcuts', () => {
const allAccelerators = extractAllAccelerators(menu);
for (const accelerator of allAccelerators) {
// 'Command' alone only works on macOS — should use 'CmdOrCtrl'
if (accelerator?.includes('Command')) {
expect(accelerator).toContain('CmdOrCtrl');
}
}
});
it('all shortcuts are valid Electron accelerator strings', () => {
const allAccelerators = extractAllAccelerators(menu);
for (const accelerator of allAccelerators) {
if (accelerator) {
// Basic validation — valid accelerators follow specific format
expect(accelerator).toMatch(/^(CmdOrCtrl|Ctrl|Alt|Shift|Meta|Command|F[0-9]+)/);
}
}
});
});DPI and Display Scaling
Windows users commonly use 125%, 150%, or 200% display scaling. macOS Retina displays run at 2x. Linux varies widely.
// Tests for DPI-aware sizing
test('window minimum size is reasonable at all scales', async () => {
const app = await electron.launch({ args: ['main.js'] });
const window = await app.firstWindow();
const bounds = await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win.getBounds();
});
// Minimum window size should be practical at 200% scaling too
// At 200% scale, a 800px window looks like 400 "logical" pixels — still usable?
expect(bounds.width).toBeGreaterThan(600);
expect(bounds.height).toBeGreaterThan(400);
await app.close();
});File System Permissions
// tests/main/utils/file-utils.test.js
const fs = require('fs/promises');
const { safeWriteFile, safeReadFile } = require('../../main/utils/file-utils');
describe('file operations', () => {
it('handles EACCES permission error gracefully', async () => {
// Mock fs to simulate permission error
jest.spyOn(fs, 'writeFile').mockRejectedValue(
Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
);
await expect(safeWriteFile('/protected/path', 'content'))
.rejects.toThrow('Permission denied');
// Should not expose raw error codes to users
try {
await safeWriteFile('/protected/path', 'content');
} catch (error) {
expect(error.message).not.toContain('EACCES');
}
});
it('handles ENOENT missing file gracefully', async () => {
jest.spyOn(fs, 'readFile').mockRejectedValue(
Object.assign(new Error('ENOENT: no such file'), { code: 'ENOENT' })
);
const result = await safeReadFile('/nonexistent/file', { default: null });
expect(result).toBeNull();
});
});Setting Up Cross-Platform CI
GitHub Actions Matrix
# .github/workflows/test-cross-platform.yml
name: Cross-Platform Tests
on: [push, pull_request]
jobs:
test:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Run all platforms even if one fails
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: ['20']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Build app
run: npm run build
# E2E tests need display on Linux
- name: Install virtual display (Linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get install -y xvfb
- name: Run E2E tests (Linux)
if: matrix.os == 'ubuntu-latest'
run: xvfb-run --auto-servernum npx playwright test
env:
DISPLAY: ':99'
- name: Run E2E tests (macOS/Windows)
if: matrix.os != 'ubuntu-latest'
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-${{ matrix.os }}
path: |
playwright-report/
test-results/Platform-Specific Test Configuration
// playwright.config.js
const os = require('os');
export default defineConfig({
projects: [
{
name: 'electron-current-platform',
use: {
launchOptions: {
args: ['.'],
// Platform-specific settings
env: {
NODE_ENV: 'test',
// Disable Squirrel startup behavior on Windows during tests
...(process.platform === 'win32' && { SQUIRREL_SKIP_INSTALLER_CHECK: '1' }),
},
},
},
},
],
// Skip certain tests on platforms where they don't apply
grep: process.platform === 'darwin'
? undefined
: /^(?!.*macOS-only)/,
});Platform-Specific Tests
Use test.skip for tests that only apply to certain platforms:
const { test, expect } = require('@playwright/test');
test('macOS-only: Touch Bar support', async ({ app }) => {
test.skip(process.platform !== 'darwin', 'Touch Bar only on macOS');
// Test Touch Bar functionality
const touchBar = await app.evaluate(() => {
const win = require('electron').BrowserWindow.getAllWindows()[0];
return win.touchBar !== null;
});
expect(touchBar).toBe(true);
});
test('Windows-only: jump list integration', async ({ app }) => {
test.skip(process.platform !== 'win32', 'Jump list only on Windows');
const result = await app.evaluate(() => {
const { app } = require('electron');
return app.getJumpListSettings();
});
expect(result).toBeDefined();
});
test('Linux: file manager integration', async ({ app }) => {
test.skip(process.platform !== 'linux', 'Linux file manager test');
// Test that reveal in file manager uses xdg-open
// ...
});Testing Platform Detection Logic
Your app likely branches on process.platform. Test those branches:
// tests/main/platform.test.js
const { getDefaultDownloadPath, getConfigDirectory } = require('../../main/utils/platform');
describe('platform-specific paths', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('uses ~/Downloads on macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const downloadPath = getDefaultDownloadPath();
expect(downloadPath).toMatch(/\/Downloads$/);
});
it('uses %USERPROFILE%\\Downloads on Windows', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
process.env.USERPROFILE = 'C:\\Users\\TestUser';
const downloadPath = getDefaultDownloadPath();
expect(downloadPath).toContain('Downloads');
});
it('uses ~/Downloads on Linux', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const downloadPath = getDefaultDownloadPath();
expect(downloadPath).toMatch(/Downloads$/);
});
it('uses ~/Library/Application Support on macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const configDir = getConfigDirectory('MyApp');
expect(configDir).toContain('Library/Application Support');
expect(configDir).toContain('MyApp');
});
});Visual Regression Across Platforms
Platform differences in fonts, scrollbar styling, and UI chrome can cause visual regressions:
// tests/visual/cross-platform.spec.js
test('main window screenshot', async ({ page, app }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Platform-specific snapshot
await expect(page).toHaveScreenshot(
`main-window-${process.platform}.png`,
{
maxDiffPixels: 200, // Allow some difference for platform rendering
}
);
});Store separate baseline screenshots for each platform. Commit these to your repo so CI compares against the correct baseline.
Common Cross-Platform Failures in CI
Missing native dependencies: Some npm packages have platform-native binaries (.node files). If npm ci on macOS doesn't rebuild these for Linux, you'll get "invalid ELF header" errors.
Solution: Use platform-specific binary caching or ensure native modules rebuild in CI:
- run: npm ci
- run: npx electron-rebuild # Rebuild native modules for Electron's versionFont differences: Default fonts differ by platform. If tests assert text metrics (width, height), they'll fail cross-platform.
Timing differences: Windows CI runners are often slower than macOS/Linux. Playwright tests with aggressive timeouts fail on Windows.
Solution: Use generous timeouts in tests and avoid hardcoded setTimeout waits.
Line ending issues: Files committed with Windows line endings (\r\n) can cause test failures on Linux. Configure .gitattributes:
*.js text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.md text eol=lfSummary
Cross-platform Electron testing requires:
- CI on all three platforms: GitHub Actions matrix with ubuntu, macos, and windows runners
- Platform-specific test cases: Use
test.skipfor OS-specific features - Path handling tests: Verify path utilities work correctly on all platforms
- Keyboard shortcut tests: Verify
CmdOrCtrlusage, not platform-specific keys - Platform detection tests: Mock
process.platformto test each branch - Visual regression: Store per-platform baselines to handle rendering differences
Most cross-platform bugs are caught in CI once you have all three platforms in the matrix. The key is running them on every PR, not just before release — by then, finding the commit that introduced the bug is much harder.