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 builds —
ubuntu-latest,macos-latest,windows-latestin parallel - Xvfb on Linux —
xvfb-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: falsefor test builds - Platform-specific tests — use
test.skip(platform !== 'darwin', ...)for OS-specific features - Test the packaged app — launch the actual
.AppImage,.dmg, or.exein 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.