Cross-Platform Electron App Testing: Windows, macOS, and Linux

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 (use path.join() or path.posix/path.win32 as 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_Store files 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 version

Font 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=lf

Summary

Cross-platform Electron testing requires:

  1. CI on all three platforms: GitHub Actions matrix with ubuntu, macos, and windows runners
  2. Platform-specific test cases: Use test.skip for OS-specific features
  3. Path handling tests: Verify path utilities work correctly on all platforms
  4. Keyboard shortcut tests: Verify CmdOrCtrl usage, not platform-specific keys
  5. Platform detection tests: Mock process.platform to test each branch
  6. 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.

Read more

Start now free