From 992d075cc0f0b6620d9c0473b7c8df58474713e9 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 27 May 2026 15:35:08 -0700 Subject: [PATCH] [eric] test: pull the app-launch plumbing into a shared lib so each check stays tiny --- scripts/ci/lib/app-harness.js | 144 ++++++++++++++++++++++++++++++ scripts/ci/verify-packaged-app.js | 107 ++++------------------ 2 files changed, 162 insertions(+), 89 deletions(-) create mode 100644 scripts/ci/lib/app-harness.js diff --git a/scripts/ci/lib/app-harness.js b/scripts/ci/lib/app-harness.js new file mode 100644 index 00000000..974af6ff --- /dev/null +++ b/scripts/ci/lib/app-harness.js @@ -0,0 +1,144 @@ +'use strict'; +// Shared plumbing for the packaged-app verifiers in scripts/ci/. Every verifier +// needs the same things: find the built artifact for this OS, launch it, read the +// backend.log it writes, and kill it cleanly. Keeping that here means each verifier +// stays small and single-purpose (boot, resilience, signature, network). +// +// These helpers THROW on misuse and return data on success; the calling script +// owns the pass/fail print + exit code so the harness has no opinion on policy. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const { spawn, execSync } = require('child_process'); + +// This file is scripts/ci/lib/ -> repo root is three up. +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); + +function packagedAppPath(explicit) { + if (explicit) return explicit; + const dist = path.join(REPO_ROOT, 'electron', 'dist'); + const candidates = process.platform === 'win32' + ? [path.join(dist, 'win-unpacked', 'OpenSwarm.exe')] + : process.platform === 'darwin' + ? ['mac-arm64', 'mac', 'mac-universal'].map((d) => path.join(dist, d, '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 pass --app. Looked in:\n ${candidates.join('\n ')}`); + return found; +} + +// The on-disk binary the OS actually signs/scans: the .exe on win, the .app +// bundle dir on mac (codesign/spctl assess the bundle, not the inner MachO). +function signableTarget(appExecutable) { + if (process.platform === 'darwin') { + // .../OpenSwarm.app/Contents/MacOS/OpenSwarm -> .../OpenSwarm.app + const i = appExecutable.indexOf('.app'); + return i === -1 ? appExecutable : appExecutable.slice(0, i + 4); + } + return appExecutable; +} + +function backendLogPath() { + if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'backend.log'); + if (process.platform === 'win32') return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'backend.log'); + const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(xdg, 'OpenSwarm', 'data', 'backend.log'); +} + +// The bearer token the shell writes before the HTTP bind; tests reuse it to call +// the same authenticated API the app itself uses. +function authTokenPath() { + const dir = path.dirname(backendLogPath()); + return path.join(dir, 'auth.token'); +} + +function gitHeadShort() { + try { return execSync('git rev-parse HEAD', { cwd: REPO_ROOT }).toString().trim().slice(0, 12); } catch { return null; } +} + +function readFileSafe(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } } +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +function spawnApp(appPath, extraArgs = []) { + // detached on posix so we can SIGKILL the whole process group (the app spawns + // python + 9router children); on win we reap by image name instead. + return spawn(appPath, extraArgs, { detached: process.platform !== 'win32', stdio: 'ignore', cwd: path.dirname(appPath) }); +} + +function killApp(child) { + try { + if (process.platform === 'win32') { + if (child && child.pid) { try { execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: 'ignore' }); } catch { /* gone */ } } + try { execSync('taskkill /IM OpenSwarm.exe /T /F', { stdio: 'ignore' }); } catch { /* none */ } + } else if (child && child.pid) { + try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } } + } + } catch { /* best effort */ } +} + +function healthCode(port, timeoutMs = 3000) { + return new Promise((resolve) => { + const req = http.get({ host: '127.0.0.1', port, path: '/api/health/check' }, (res) => { res.resume(); resolve(res.statusCode); }); + req.on('error', () => resolve(0)); + req.setTimeout(timeoutMs, () => { req.destroy(); resolve(0); }); + }); +} + +function parseProvenanceSha(log) { + const m = log.match(/\[provenance\] OpenSwarm \S+ sha=([0-9a-f]+)/); + return m ? m[1] : null; +} + +function parsePerfMarks(log) { + const marks = {}; + for (const key of ['app-launch', 'first-paint', 'backend-http-ready']) { + const m = log.match(new RegExp(`\\[perf\\] ${key} t=(\\d+)`)); + if (m) marks[key] = Number(m[1]); + } + return marks; +} + +// Launch the app and poll its backend.log until it reports HTTP-ready (or time out). +// Returns { child, log, port }. Caller is responsible for killApp(child). +async function launchAndWait({ appPath, timeoutMs = 180000, freshLog = true } = {}) { + const logPath = backendLogPath(); + if (freshLog) { + try { fs.mkdirSync(path.dirname(logPath), { recursive: true }); } catch { /* exists */ } + try { fs.unlinkSync(logPath); } catch { /* none */ } + } + const child = spawnApp(appPath); + let launchError = null; + child.on('error', (e) => { launchError = e; }); + + const deadline = Date.now() + timeoutMs; + let log = ''; + let port = 0; + while (Date.now() < deadline) { + if (launchError) throw new Error(`could not launch app: ${launchError.message}`); + log = readFileSafe(logPath); + const m = log.match(/Backend ready on port (\d+)/); + if (m) port = Number(m[1]); + if (/\[perf\] backend-http-ready/.test(log)) break; + await sleep(1000); + } + return { child, log, port, logPath }; +} + +module.exports = { + REPO_ROOT, + packagedAppPath, + signableTarget, + backendLogPath, + authTokenPath, + gitHeadShort, + readFileSafe, + sleep, + spawnApp, + killApp, + healthCode, + parseProvenanceSha, + parsePerfMarks, + launchAndWait, +}; diff --git a/scripts/ci/verify-packaged-app.js b/scripts/ci/verify-packaged-app.js index cbb97baf..7cab0523 100644 --- a/scripts/ci/verify-packaged-app.js +++ b/scripts/ci/verify-packaged-app.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -// Deterministic "does the packaged app actually work" check — a plain script, no -// browser/Electron automation (which is flaky: single-instance locks, target- -// closed races). It launches the REAL built exe/app, waits for the backend, and -// reads the same backend.log the shipped app writes to confirm the whole boot: +// Deterministic "does the packaged app actually boot and serve" check — a plain +// script, no browser/Electron automation (which is flaky: single-instance locks, +// target-closed races). It launches the REAL built exe/app, waits for the backend, +// and reads the same backend.log the shipped app writes to confirm the boot: // // - [provenance] line present and its sha == git rev-parse HEAD (right build) // - [perf] app-launch < first-paint < backend-http-ready (UI painted, ordered) @@ -17,13 +17,7 @@ // Exit 0 = all good. Exit 1 = something didn't boot/serve/match (prints why). 'use strict'; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const http = require('http'); -const { spawn, execSync } = require('child_process'); - -const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const h = require('./lib/app-harness'); function parseArgs(argv) { const out = { app: null, timeoutMs: 180000 }; @@ -34,90 +28,25 @@ function parseArgs(argv) { return out; } -function packagedAppPath(explicit) { - if (explicit) return explicit; - const dist = path.join(REPO_ROOT, 'electron', 'dist'); - const candidates = process.platform === 'win32' - ? [path.join(dist, 'win-unpacked', 'OpenSwarm.exe')] - : process.platform === 'darwin' - ? ['mac-arm64', 'mac', 'mac-universal'].map((d) => path.join(dist, d, '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) { fail(`packaged app not found; build first or pass --app. Looked in:\n ${candidates.join('\n ')}`); } - return found; -} - -function backendLogPath() { - if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'backend.log'); - if (process.platform === 'win32') return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'backend.log'); - const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(xdg, 'OpenSwarm', 'data', 'backend.log'); -} - -function gitHeadShort() { - try { return execSync('git rev-parse HEAD', { cwd: REPO_ROOT }).toString().trim().slice(0, 12); } catch { return null; } -} - -function readFileSafe(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } } -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } -function fail(msg) { process.stderr.write(`\nVERIFY FAIL: ${msg}\n`); killApp(); process.exit(1); } - let child = null; -function killApp() { - try { - if (process.platform === 'win32') { - if (child && child.pid) { try { execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: 'ignore' }); } catch { /* gone */ } } - // Also reap a stray bundled python the app spawned, scoped to our app dir. - try { execSync('taskkill /IM OpenSwarm.exe /T /F', { stdio: 'ignore' }); } catch { /* none */ } - } else if (child && child.pid) { - try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } } - } - } catch { /* best effort */ } -} - -function healthCode(port) { - return new Promise((resolve) => { - const req = http.get({ host: '127.0.0.1', port, path: '/api/health/check' }, (res) => { res.resume(); resolve(res.statusCode); }); - req.on('error', () => resolve(0)); - req.setTimeout(3000, () => { req.destroy(); resolve(0); }); - }); -} +function fail(msg) { process.stderr.write(`\nVERIFY FAIL: ${msg}\n`); h.killApp(child); process.exit(1); } async function main() { const args = parseArgs(process.argv.slice(2)); - const appPath = packagedAppPath(args.app); - const logPath = backendLogPath(); - const headShort = gitHeadShort(); - - // Start from a clean log so we read THIS launch, not a stale one. - try { fs.mkdirSync(path.dirname(logPath), { recursive: true }); } catch { /* exists */ } - try { fs.unlinkSync(logPath); } catch { /* none */ } + const appPath = h.packagedAppPath(args.app); + const headShort = h.gitHeadShort(); process.stdout.write(`Launching: ${appPath}\n`); - child = spawn(appPath, [], { detached: process.platform !== 'win32', stdio: 'ignore', cwd: path.dirname(appPath) }); - child.on('error', (e) => fail(`could not launch app: ${e.message}`)); - - // Wait until backend.log shows backend-http-ready (or time out). - const deadline = Date.now() + args.timeoutMs; - let log = ''; - let port = 0; - while (Date.now() < deadline) { - log = readFileSafe(logPath); - const m = log.match(/Backend ready on port (\d+)/); - if (m) port = Number(m[1]); - if (/\[perf\] backend-http-ready/.test(log)) break; - await sleep(1000); - } + const res = await h.launchAndWait({ appPath, timeoutMs: args.timeoutMs }); + child = res.child; + const { log, port } = res; // --- assertions --- - const prov = log.match(/\[provenance\] OpenSwarm \S+ sha=([0-9a-f]+)/); - if (!prov) fail('no [provenance] line in backend.log (app may not have booted)'); - if (headShort && prov[1] !== headShort) fail(`provenance sha ${prov[1]} != git HEAD ${headShort}`); + const provSha = h.parseProvenanceSha(log); + if (!provSha) fail('no [provenance] line in backend.log (app may not have booted)'); + if (headShort && provSha !== headShort) fail(`provenance sha ${provSha} != git HEAD ${headShort}`); - const marks = {}; - for (const re of [/\[perf\] app-launch t=(\d+)/, /\[perf\] first-paint t=(\d+)/, /\[perf\] backend-http-ready t=(\d+)/]) { - const mm = log.match(re); if (mm) marks[re.source.match(/(app-launch|first-paint|backend-http-ready)/)[0]] = Number(mm[1]); - } + const marks = h.parsePerfMarks(log); for (const k of ['app-launch', 'first-paint', 'backend-http-ready']) if (!(k in marks)) fail(`missing [perf] ${k} in backend.log`); if (!(marks['app-launch'] <= marks['first-paint'] && marks['first-paint'] <= marks['backend-http-ready'])) { fail(`[perf] marks out of order: ${JSON.stringify(marks)}`); @@ -125,15 +54,15 @@ async function main() { if (port) { let code = 0; - for (let i = 0; i < 10 && code !== 200; i++) { code = await healthCode(port); if (code !== 200) await sleep(1000); } + for (let i = 0; i < 10 && code !== 200; i++) { code = await h.healthCode(port); if (code !== 200) await h.sleep(1000); } if (code !== 200) fail(`backend health on :${port} returned ${code}, expected 200`); } else { process.stdout.write(' (note: could not parse backend port from log; relied on perf marks + provenance)\n'); } - killApp(); + h.killApp(child); process.stdout.write('\nVERIFY PASS: packaged app booted, painted, and served.\n'); - process.stdout.write(` provenance sha = ${prov[1]} (== HEAD)\n`); + process.stdout.write(` provenance sha = ${provSha} (== HEAD)\n`); process.stdout.write(` app-launch = ${marks['app-launch']} ms\n`); process.stdout.write(` first-paint = ${marks['first-paint']} ms\n`); process.stdout.write(` backend-ready = ${marks['backend-http-ready']} ms${port ? ` (health 200 on :${port})` : ''}\n\n`);