mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[eric] test: drive the real packaged app on mac + windows to catch breakage early
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
results.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -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`.
|
||||
@@ -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'));
|
||||
}
|
||||
Generated
+76
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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+/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user