mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 18:14:53 +02:00
[eric] test: pull the app-launch plumbing into a shared lib so each check stays tiny
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user