Desktop App CI/CD Testing Pipelines for Linux, macOS, and Windows

Desktop App CI/CD Testing Pipelines for Linux, macOS, and Windows

Desktop apps must work on Linux, macOS, and Windows — and each platform has different rendering engines, file system behaviors, system APIs, and distribution formats. A CI/CD pipeline that only tests on one platform misses platform-specific bugs.

This guide covers building cross-platform CI/CD testing pipelines for desktop applications built with Electron or Tauri.

The Cross-Platform Testing Challenge

Desktop apps face unique CI/CD challenges:

  • Linux: No display by default in CI — requires Xvfb (virtual framebuffer)
  • macOS: Code signing required for automated testing of packaged apps; Apple Silicon vs Intel
  • Windows: Different file path separators, CRLF line endings, UAC elevation dialogs
  • Native dependencies: Electron's native modules must be rebuilt for each platform
  • Build artifacts: .dmg, .exe, .deb, .AppImage — each needs separate packaging

GitHub Actions Cross-Platform Matrix

# .github/workflows/desktop-tests.yml
name: Desktop App Tests
on:
  push:
    branches: [main, 'release/**']
  pull_request:
    branches: [main]

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
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      # Linux-specific: install virtual display
      - name: Install Xvfb (Linux)
        if: runner.os == 'Linux'
        run: |
          sudo apt-get update
          sudo apt-get install -y xvfb libgtk-3-0 libgbm-dev
      
      # macOS-specific: increase file descriptor limit
      - name: Increase file descriptor limit (macOS)
        if: runner.os == 'macOS'
        run: ulimit -n 65536
      
      - name: Run unit tests
        run: npm test
      
      - name: Build application
        run: npm run build
        env:
          # Skip code signing in CI (unsigned app for testing)
          CSC_IDENTITY_AUTO_DISCOVERY: false
          APPLE_ID: ''
      
      - name: Run E2E tests (Linux with Xvfb)
        if: runner.os == 'Linux'
        run: xvfb-run --auto-servernum npx playwright test
        env:
          DISPLAY: ':99'
      
      - name: Run E2E tests (macOS and Windows)
        if: runner.os != 'Linux'
        run: npx playwright test
      
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.os }}
          path: |
            test-results/
            playwright-report/
          retention-days: 7
      
      - name: Upload screenshots on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: failure-screenshots-${{ matrix.os }}
          path: test-results/screenshots/

Platform-Specific Tests

Some tests should only run on specific platforms:

// e2e/platform-specific.spec.ts
import { test, expect } from '@playwright/test';
import os from 'os';

const PLATFORM = process.platform;

test.describe('Platform-specific behavior', () => {
  test('uses correct path separator for file operations', async ({ electronApp, window }) => {
    const result = await electronApp.evaluate(() => {
      const path = require('path');
      return path.sep;
    });
    
    if (PLATFORM === 'win32') {
      expect(result).toBe('\\');
    } else {
      expect(result).toBe('/');
    }
  });

  test.skip(PLATFORM !== 'darwin', 'macOS-only: menu bar is accessible');
  test('macOS menu bar renders correctly', async ({ electronApp }) => {
    const menuItems = await electronApp.evaluate(({ Menu }) => {
      const menu = Menu.getApplicationMenu();
      return menu?.items.map((item) => item.label) ?? [];
    });
    
    expect(menuItems).toContain('File');
    expect(menuItems).toContain('Edit');
    expect(menuItems).toContain('View');
  });

  test.skip(PLATFORM !== 'linux', 'Linux-only: system tray works');
  test('Linux system tray icon is created', async ({ electronApp }) => {
    const hasTray = await electronApp.evaluate(({ Tray }) => {
      return typeof Tray !== 'undefined';
    });
    expect(hasTray).toBe(true);
  });
});

Testing the Packaged App

Unit tests test the source; you also need to test the packaged distributable:

# .github/workflows/package-test.yml
name: Package and Test
on:
  push:
    tags: ['v*']

jobs:
  package-and-test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            artifact: '*.AppImage'
            test-cmd: 'chmod +x *.AppImage && ./*.AppImage --no-sandbox'
          - os: macos-latest
            artifact: '*.dmg'
            test-cmd: 'hdiutil attach *.dmg && /Volumes/MyApp/MyApp.app/Contents/MacOS/MyApp'
          - os: windows-latest
            artifact: '*.exe'
            test-cmd: 'Start-Process *.exe -Wait'
    
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      
      - name: Package application
        run: npm run package
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Test packaged app launches
        run: ${{ matrix.test-cmd }}
        working-directory: dist/
        timeout-minutes: 2
      
      - name: Upload packaged artifact
        uses: actions/upload-artifact@v4
        with:
          name: package-${{ matrix.os }}
          path: dist/${{ matrix.artifact }}

Native Module Rebuild Testing

Electron apps with native Node.js modules must rebuild them for the Electron runtime:

- name: Rebuild native modules for Electron
  run: npx electron-rebuild
  env:
    npm_config_target: ${{ steps.electron-version.outputs.version }}
    npm_config_arch: ${{ matrix.arch || 'x64' }}
    npm_config_target_arch: ${{ matrix.arch || 'x64' }}
    npm_config_disturl: https://electronjs.org/headers
    npm_config_runtime: electron
    npm_config_build_from_source: true

- name: Verify native modules loaded correctly
  run: |
    node -e "const addon = require('./native-module'); console.log('Native module OK:', typeof addon.doWork);"

Performance Testing for Desktop Apps

Monitor app startup time and memory usage in CI:

// e2e/performance.spec.ts
import { test, expect } from '@playwright/test';

test('app starts within 5 seconds', async () => {
  const startTime = Date.now();
  
  const { electronApp, window } = await launchApp();
  await window.waitForLoadState('domcontentloaded');
  
  const startupTime = Date.now() - startTime;
  
  console.log(`Startup time: ${startupTime}ms`);
  expect(startupTime).toBeLessThan(5_000);
  
  await electronApp.close();
});

test('memory usage is under 200MB after 60 seconds', async () => {
  const { electronApp, window } = await launchApp();
  
  // Simulate 60 seconds of usage
  await window.waitForLoadState('networkidle');
  await window.waitForTimeout(5_000); // Let the app settle
  
  const metrics = await electronApp.evaluate(({ app }) => {
    const metrics = process.memoryUsage();
    return {
      heapUsed: metrics.heapUsed / 1024 / 1024,
      rss: metrics.rss / 1024 / 1024,
    };
  });
  
  console.log(`Memory: heap=${metrics.heapUsed.toFixed(0)}MB rss=${metrics.rss.toFixed(0)}MB`);
  
  expect(metrics.rss).toBeLessThan(200);
  
  await electronApp.close();
});

Summary

Cross-platform desktop CI/CD testing:

  • Use matrix buildsubuntu-latest, macos-latest, windows-latest in parallel
  • Xvfb on Linuxxvfb-run --auto-servernum npx playwright test
  • fail-fast: false — run all platforms even if one fails
  • Skip code signing in CI — set CSC_IDENTITY_AUTO_DISCOVERY: false for test builds
  • Platform-specific tests — use test.skip(platform !== 'darwin', ...) for OS-specific features
  • Test the packaged app — launch the actual .AppImage, .dmg, or .exe in a separate job
  • Monitor startup time and memory — performance regressions are easy to miss without tracking

Cross-platform testing catches the platform-specific bugs that local development on a single OS misses. The investment in a proper matrix pipeline pays back on the first Windows-only crash you catch before release.

Read more