diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..ca4482dc --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..88066470 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +results.json +test-results/ +playwright-report/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..0cc10daa --- /dev/null +++ b/e2e/README.md @@ -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`. diff --git a/e2e/helpers/launch.ts b/e2e/helpers/launch.ts new file mode 100644 index 00000000..6c3bf369 --- /dev/null +++ b/e2e/helpers/launch.ts @@ -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 { + 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 { + 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')); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..92dce9c7 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..bc2a9e94 --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..f08da5ec --- /dev/null +++ b/e2e/playwright.config.ts @@ -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, +}); diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts new file mode 100644 index 00000000..038b395e --- /dev/null +++ b/e2e/tests/smoke.spec.ts @@ -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+/); + }); +});