[eric] test: drive the real packaged app on mac + windows to catch breakage early

This commit is contained in:
Eric
2026-05-27 10:19:53 -07:00
parent b7cb505561
commit 1fc4ab9163
8 changed files with 335 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
name: E2E (packaged app, mac + win)
# Builds the UNSIGNED packaged app on each OS, then drives it with Playwright
# (e2e/). This is the cross-platform "does the real artifact actually boot and
# serve" gate. Signing/notarization + release upload are handled separately by
# release-windows.yml / release-macos.yml.
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
e2e:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
env:
# Unsigned build is fine for E2E; skip codesign/notarize discovery.
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
# Placeholder OAuth values just satisfy the build script's required-env
# check; the Google MCP isn't exercised by these tests.
GOOGLE_OAUTH_CLIENT_ID: 'e2e-placeholder.apps.googleusercontent.com'
GOOGLE_OAUTH_CLIENT_SECRET: 'e2e-placeholder-secret'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.18.1'
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Build packaged app (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: pwsh -NoProfile -File scripts/build-app-win.ps1
- name: Build packaged app (macOS)
if: matrix.os == 'macos-latest'
shell: bash
run: bash scripts/build-app.sh
- name: Install e2e deps
shell: bash
working-directory: e2e
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
run: npm ci
- name: Run E2E
shell: bash
working-directory: e2e
run: npm test
- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results-${{ matrix.os }}
path: e2e/results.json
if-no-files-found: ignore
retention-days: 14
+4
View File
@@ -0,0 +1,4 @@
node_modules/
results.json
test-results/
playwright-report/
+37
View File
@@ -0,0 +1,37 @@
# End-to-end tests (packaged app, macOS + Windows)
Playwright tests that launch the **packaged** OpenSwarm desktop app (the real
built binary, asar + bundled python-env + real paths) and drive it the way a user
would. The same specs run unchanged on macOS and Windows; CI builds the artifact
per-OS, then runs these. No provider API key is needed (no agent turn), so the
suite is hermetic and deterministic on a clean machine.
## What it checks (per OS)
- Main window paints the React shell (first meaningful paint).
- The preload bridge (`window.openswarm`) is exposed.
- The real backend the app spawned reaches HTTP-ready (`/api/health/check` -> 200).
- Provenance: the running app's `getBuildInfo()` sha matches `electron/build-info.json`.
- App version is reported.
## Run locally
1. Build the app first (produces `electron/dist/...`):
- Windows: `pwsh scripts/build-app-win.ps1`
- macOS: `bash scripts/build-app.sh`
2. Then:
```
cd e2e
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci # Electron ships its own Chromium
npm test
```
Override the binary location with `E2E_APP_PATH=/path/to/app` if your build
output lives elsewhere. Auto-detection covers `win-unpacked/OpenSwarm.exe` and the
mac `OpenSwarm.app` variants.
## CI
`.github/workflows/e2e.yml` runs this on a `windows-latest` + `macos-latest`
matrix: it builds the unsigned app, then runs the suite. Tag-driven signed
releases are covered separately by `release-windows.yml` / `release-macos.yml`.
+59
View File
@@ -0,0 +1,59 @@
import { _electron as electron, ElectronApplication, Page } from '@playwright/test';
import fs from 'fs';
import path from 'path';
// Repo root is two levels up from this file (e2e/helpers/).
const REPO_ROOT = path.resolve(__dirname, '..', '..');
// Resolve the PACKAGED Electron binary for the current OS. Override with
// E2E_APP_PATH to point at any built artifact. We deliberately drive the packaged
// build (asar, bundled python-env, real paths) — not `electron .` on source —
// because that is what ships and what the plan requires us to verify.
export function packagedAppPath(): string {
if (process.env.E2E_APP_PATH) return process.env.E2E_APP_PATH;
const dist = path.join(REPO_ROOT, 'electron', 'dist');
const candidates =
process.platform === 'win32'
? [path.join(dist, 'win-unpacked', 'OpenSwarm.exe')]
: process.platform === 'darwin'
? [
path.join(dist, 'mac-arm64', 'OpenSwarm.app', 'Contents', 'MacOS', 'OpenSwarm'),
path.join(dist, 'mac', 'OpenSwarm.app', 'Contents', 'MacOS', 'OpenSwarm'),
path.join(dist, 'mac-universal', 'OpenSwarm.app', 'Contents', 'MacOS', 'OpenSwarm'),
]
: [path.join(dist, 'linux-unpacked', 'openswarm')];
const found = candidates.find((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } });
if (!found) throw new Error(`Packaged app not found. Build first or set E2E_APP_PATH. Looked in:\n ${candidates.join('\n ')}`);
return found;
}
export async function launchApp(): Promise<ElectronApplication> {
return electron.launch({ executablePath: packagedAppPath(), args: [] });
}
// The app opens a splash window first, then the main window that loads the React
// frontend and exposes window.openswarm. Poll all windows until one has the
// bridge AND the React root has mounted (first meaningful paint), then return it.
export async function waitForMainWindow(app: ElectronApplication, timeoutMs = 120_000): Promise<Page> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const w of app.windows()) {
try {
const ready = await w.evaluate(() => {
const hasBridge = typeof (window as any).openswarm?.getBackendPort === 'function';
const root = document.getElementById('root');
return hasBridge && !!root && root.childElementCount > 0;
});
if (ready) return w;
} catch { /* window navigating or not ready; keep polling */ }
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error('main window with mounted React root never appeared');
}
// Read the build-info.json the build stamped, so tests can assert the running
// app's provenance matches the artifact on disk.
export function readBuildInfo(): { sha: string; shortSha: string; channel: string; version: string } {
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'electron', 'build-info.json'), 'utf8'));
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "openswarm-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm-e2e",
"devDependencies": {
"@playwright/test": "1.49.1"
}
},
"node_modules/@playwright/test": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "openswarm-e2e",
"private": true,
"description": "Cross-platform end-to-end tests that drive the PACKAGED OpenSwarm desktop app (Electron) on macOS and Windows via Playwright.",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.49.1"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test';
// E2E config for driving the PACKAGED Electron app (not a dev server). We launch
// the real built binary via Playwright's _electron API, so there is no webServer
// and no browser project — Electron ships its own Chromium. Runs identically on
// macOS and Windows; CI builds the artifact first, then runs these.
export default defineConfig({
testDir: './tests',
// Boot of a cold packaged app (Defender scan + Python cold start) can take a
// while on first launch, so allow generous per-test time.
timeout: 180_000,
expect: { timeout: 30_000 },
fullyParallel: false, // one packaged app instance at a time (single-instance lock)
workers: 1,
reporter: [['list'], ['json', { outputFile: 'results.json' }]],
retries: 0,
});
+63
View File
@@ -0,0 +1,63 @@
import { test, expect, ElectronApplication, Page } from '@playwright/test';
import { launchApp, waitForMainWindow, readBuildInfo } from '../helpers/launch';
// End-to-end smoke of the PACKAGED app. Everything here runs unchanged on macOS
// and Windows; CI builds the artifact for the OS, then runs this. It deliberately
// avoids anything needing a provider API key (no agent turn) so it is hermetic
// and deterministic on a clean machine.
test.describe('packaged app boot', () => {
let app: ElectronApplication;
let win: Page;
test.beforeAll(async () => {
app = await launchApp();
win = await waitForMainWindow(app);
});
test.afterAll(async () => {
await app?.close().catch(() => {});
});
test('main window paints the React shell', async () => {
// waitForMainWindow already required a mounted #root; assert it explicitly.
const childCount = await win.evaluate(() => document.getElementById('root')!.childElementCount);
expect(childCount).toBeGreaterThan(0);
});
test('preload bridge is exposed', async () => {
const hasBridge = await win.evaluate(() => ({
port: typeof (window as any).openswarm?.getBackendPort === 'function',
buildInfo: typeof (window as any).openswarm?.getBuildInfo === 'function',
}));
expect(hasBridge.port).toBe(true);
expect(hasBridge.buildInfo).toBe(true);
});
test('backend reaches HTTP-ready (health 200)', async () => {
const port: number = await win.evaluate(() => (window as any).openswarm.getBackendPort());
expect(port).toBeGreaterThan(0);
// Poll the real backend the packaged app spawned, from inside the renderer
// (same origin/path the app itself uses), until it answers 200.
await expect.poll(
async () =>
win.evaluate(
(p) => fetch(`http://127.0.0.1:${p}/api/health/check`).then((r) => r.status).catch(() => 0),
port,
),
{ timeout: 150_000, intervals: [1000] },
).toBe(200);
});
test('provenance: running app reports the built commit', async () => {
const info = await win.evaluate(() => (window as any).openswarm.getBuildInfo());
const onDisk = readBuildInfo();
expect(info.sha).toBe(onDisk.sha);
expect(info.shortSha).toMatch(/^[0-9a-f]{12}$/);
expect(info.version).toBe(onDisk.version);
});
test('app version is reported', async () => {
const version = await win.evaluate(() => (window as any).openswarm.getAppVersion());
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
});