PSA/scripts/build-perf-harness.mjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

534 lines
19 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Build-perf harness for the optimization loop.
*
* Pipeline (each stage is independently timed + reported):
* 1. clear — remove server/.next + server/tsconfig.tsbuildinfo
* 2. build — `npm run build` (or configured cmd) from repo root, captured + timed
* 3. start — `next start -p <port>` from server/, wait for "Ready"
* 4. password — tail server stdout/stderr for the "Password is -> [ ... ]" line
* 5. smoke — drive puppeteer through:
* a. signin (POST /auth/msp/signin with glinda creds)
* b. /msp/dashboard (wait for it to render)
* c. /msp/clients (wait for client-link-* row)
* d. click first row → /msp/clients/<id>
*
* Output is intentionally machine-parseable. Every stage emits:
* [HARNESS] stage=<name> status=start|done|fail [duration_ms=N] [extra=...]
* Plus a single final line:
* [HARNESS RESULT] {<json summary>}
*
* Exit code is 0 only if every stage succeeds. Non-zero on any failure so the
* loop driver can detect regressions cleanly.
*
* Required: a running dev infrastructure (postgres, redis) reachable via the
* env in server/.env. Use the alga-local-wirein skill if you haven't wired the
* worktree to a running stack.
*
* Usage:
* node scripts/build-perf-harness.mjs # full pipeline, port 3010
* node scripts/build-perf-harness.mjs --port 4001 # custom port
* node scripts/build-perf-harness.mjs --skip-build # reuse existing .next
* node scripts/build-perf-harness.mjs --headed # show puppeteer browser
* node scripts/build-perf-harness.mjs --keep-running # leave server up on success
*/
import { spawn } from 'node:child_process';
import { rm, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createServer } from 'node:net';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, '..');
const SERVER_DIR = resolve(REPO_ROOT, 'server');
const NEXT_CACHE = resolve(SERVER_DIR, '.next');
const TSBUILDINFO = resolve(SERVER_DIR, 'tsconfig.tsbuildinfo');
const ARTIFACT_DIR = resolve(REPO_ROOT, '.build-perf');
const ARGS = parseArgs(process.argv.slice(2));
const PORT = ARGS.port ?? 4011;
const HEADED = !!ARGS.headed;
const SKIP_BUILD = !!ARGS['skip-build'];
const SKIP_CLEAR = !!ARGS['skip-clear'];
const KEEP_RUNNING = !!ARGS['keep-running'];
const BUILD_CMD = ARGS['build-cmd'] ?? 'npm run build';
const START_TIMEOUT_MS = Number(ARGS['start-timeout-ms'] ?? 180_000);
const PASSWORD_TIMEOUT_MS = Number(ARGS['password-timeout-ms'] ?? 120_000);
const SMOKE_TIMEOUT_MS = Number(ARGS['smoke-timeout-ms'] ?? 90_000);
const ADMIN_EMAIL = ARGS.email ?? 'glinda@emeraldcity.oz';
const stages = {};
let serverProc = null;
let serverLog = '';
let serverLogPath = null;
let buildLogPath = null;
process.on('SIGINT', () => shutdown('sigint').then(() => process.exit(130)));
process.on('SIGTERM', () => shutdown('sigterm').then(() => process.exit(143)));
main().then(
async (summary) => {
emitResult(summary);
if (!KEEP_RUNNING) await shutdown('done');
process.exit(summary.ok ? 0 : 1);
},
async (err) => {
const summary = finalizeSummary({ ok: false, error: errToObj(err) });
emitResult(summary);
await shutdown('error');
process.exit(1);
}
);
async function main() {
await mkdir(ARTIFACT_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
serverLogPath = resolve(ARTIFACT_DIR, `server-${ts}.log`);
buildLogPath = resolve(ARTIFACT_DIR, `build-${ts}.log`);
log(`harness starting | port=${PORT} headed=${HEADED} skip_build=${SKIP_BUILD}`);
log(`artifacts: ${ARTIFACT_DIR}`);
if (!SKIP_CLEAR) {
await runStage('clear', clearCache);
} else {
log('clear skipped (--skip-clear)');
}
if (!SKIP_BUILD) {
await runStage('build', () => runBuild(BUILD_CMD));
} else {
log('build skipped (--skip-build)');
if (!existsSync(NEXT_CACHE)) {
throw new Error('--skip-build set but server/.next does not exist; nothing to start');
}
}
await runStage('start', () => startServer(PORT));
await runStage('password', () => waitForPassword(PASSWORD_TIMEOUT_MS));
await runStage('smoke', () => runSmoke(PORT, ADMIN_EMAIL, stages.password.password));
return finalizeSummary({ ok: true });
}
// ─── stages ────────────────────────────────────────────────────────────────
async function clearCache() {
await rm(NEXT_CACHE, { recursive: true, force: true });
await rm(TSBUILDINFO, { force: true });
return { cleared: ['server/.next', 'server/tsconfig.tsbuildinfo'] };
}
async function runBuild(cmd) {
log(`build cmd: ${cmd}`);
// Next's webpack build worker forks a TS type-check process that inherits
// NODE_OPTIONS. The repo's 8 GB default OOMs on this monorepo. Always
// override unless caller passed --node-options explicitly. Inheriting the
// shell's NODE_OPTIONS is unsafe — that's how we OOM'd the first two runs.
const nodeOptions = ARGS['node-options'] ?? '--max-old-space-size=16384';
const buildEnv = { ...process.env, NODE_OPTIONS: nodeOptions };
log(`NODE_OPTIONS=${buildEnv.NODE_OPTIONS}`);
// Use shell mode so `--build-cmd` accepts chained commands (`a && b`).
const child = spawn(cmd, {
cwd: REPO_ROOT,
env: buildEnv,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
});
let out = '';
child.stdout.on('data', (d) => {
const s = d.toString();
out += s;
process.stdout.write(s);
});
child.stderr.on('data', (d) => {
const s = d.toString();
out += s;
process.stderr.write(s);
});
const exitCode = await new Promise((res, rej) => {
child.on('error', rej);
child.on('close', res);
});
await writeFile(buildLogPath, out, 'utf8');
if (exitCode !== 0) {
const tail = out.split('\n').slice(-40).join('\n');
throw new Error(`build failed (exit ${exitCode})\n${tail}`);
}
const sizeBytes = await dirSize(NEXT_CACHE).catch(() => null);
return { exit_code: exitCode, log_path: buildLogPath, next_size_bytes: sizeBytes };
}
async function startServer(port) {
if (!(await portFree(port))) {
throw new Error(`port ${port} already in use — pick another via --port`);
}
const env = {
...process.env,
PORT: String(port),
NODE_ENV: 'production',
NEXTAUTH_URL: `http://localhost:${port}`,
};
serverProc = spawn('npx', ['next', 'start', '-p', String(port)], {
cwd: SERVER_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
serverProc.stdout.on('data', (d) => onServerData(d));
serverProc.stderr.on('data', (d) => onServerData(d));
let serverExited = null;
serverProc.on('exit', (code, sig) => {
serverExited = { code, sig };
log(`next start exited code=${code} sig=${sig}`);
failPendingWaiters(new Error(`next start exited prematurely code=${code} sig=${sig}`));
});
// Only match "Ready in" — Next prints the "- Local: http://…" banner BEFORE
// it actually binds, so matching that races EADDRINUSE.
const readyRe = /Ready in /;
await waitForLog(readyRe, START_TIMEOUT_MS, 'next-start-ready');
if (serverExited) {
throw new Error(`next start exited code=${serverExited.code} sig=${serverExited.sig}`);
}
// Touch / to ensure instrumentation runs and initializeApp fires (which
// generates + logs the glinda password). On modern Next the register hook
// runs at boot, but a request guarantees it across versions.
try {
await fetch(`http://localhost:${port}/api/auth/csrf`, {
method: 'GET',
signal: AbortSignal.timeout(10_000),
});
} catch (e) {
log(`warmup fetch failed (continuing): ${e.message}`);
}
return { port, pid: serverProc.pid };
}
async function waitForPassword(timeoutMs) {
const re = /Password is -> \[ (.+?) \]/;
const line = await waitForLog(re, timeoutMs, 'password-line');
const match = line.match(re);
if (!match) throw new Error('password regex matched waitForLog but capture failed');
// Returned data is merged into stages.password by runStage; emitResult redacts.
return { found: true, length: match[1].length, password: match[1] };
}
async function runSmoke(port, email, password) {
if (!password) throw new Error('no password captured; cannot run smoke');
let puppeteer;
try {
puppeteer = (await import('puppeteer')).default;
} catch {
// try server-local install
const serverPup = resolve(SERVER_DIR, 'node_modules/puppeteer/lib/esm/puppeteer/puppeteer.js');
puppeteer = (await import(serverPup)).default;
}
const browser = await puppeteer.launch({
headless: HEADED ? false : 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const result = { steps: {} };
let page;
const pageErrors = [];
const runStep = async (name, fn) => {
const t = Date.now();
try {
const data = (await fn()) ?? {};
result.steps[name] = { ok: true, duration_ms: Date.now() - t, ...data };
} catch (err) {
result.steps[name] = {
ok: false,
duration_ms: Date.now() - t,
error: err.message,
url: page ? page.url() : null,
title: page ? await page.title().catch(() => null) : null,
};
// snapshot failing page so we can see what rendered
if (page) {
const shot = resolve(ARTIFACT_DIR, `smoke-fail-${name}-${Date.now()}.png`);
await page.screenshot({ path: shot, fullPage: true }).catch(() => {});
const html = await page.content().catch(() => null);
if (html) {
const htmlPath = resolve(ARTIFACT_DIR, `smoke-fail-${name}-${Date.now()}.html`);
await writeFile(htmlPath, html, 'utf8').catch(() => {});
result.steps[name].html_path = htmlPath;
}
result.steps[name].screenshot = shot;
}
throw err;
}
};
let topErr;
try {
page = await browser.newPage();
// Default viewport (800x600) hides table columns on /msp/clients; use a
// realistic desktop size so the client-name column (and its links) renders.
await page.setViewport({ width: 1600, height: 1000, deviceScaleFactor: 1 });
page.setDefaultTimeout(SMOKE_TIMEOUT_MS);
page.setDefaultNavigationTimeout(SMOKE_TIMEOUT_MS);
page.on('pageerror', (e) => pageErrors.push(String(e)));
page.on('console', (msg) => {
if (msg.type() === 'error') pageErrors.push(`console.error: ${msg.text()}`);
});
await runStep('signin', async () => {
await page.goto(`http://localhost:${port}/auth/msp/signin`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#msp-email-field');
await page.type('#msp-email-field', email);
await page.type('#msp-password-field', password);
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: SMOKE_TIMEOUT_MS }),
page.click('#msp-sign-in-button'),
]);
const url = page.url();
if (!url.includes('/msp/')) {
// sign-in failed → still on /auth/msp/signin or error page
throw new Error(`signin did not redirect to /msp/* (now at ${url})`);
}
return { url };
});
await runStep('dashboard', async () => {
if (!page.url().includes('/msp/dashboard')) {
await page.goto(`http://localhost:${port}/msp/dashboard`, { waitUntil: 'domcontentloaded' });
}
// Wait for an element that's specifically dashboard (not just any layout)
await page.waitForSelector('[data-automation-id*="dashboard"], h1, [data-testid*="dashboard"]', {
timeout: SMOKE_TIMEOUT_MS,
});
return { url: page.url() };
});
await runStep('clients_list', async () => {
await page.goto(`http://localhost:${port}/msp/clients`, { waitUntil: 'domcontentloaded' });
// Wait for either client rows OR a clear empty state, then assert there are rows
await page.waitForFunction(
() =>
document.querySelector('a[data-automation-id^="client-link-"]') ||
document.body.innerText.toLowerCase().includes('no clients') ||
document.body.innerText.toLowerCase().includes('add your first'),
{ timeout: SMOKE_TIMEOUT_MS },
);
const clientCount = await page.$$eval('a[data-automation-id^="client-link-"]', (els) => els.length);
if (clientCount === 0) {
const sample = await page.evaluate(() => document.body.innerText.slice(0, 500));
throw new Error(`no client rows visible on /msp/clients (page text sample: ${sample.replace(/\s+/g, ' ')})`);
}
return { url: page.url(), client_count: clientCount };
});
await runStep('client_detail', async () => {
// Read href first (cheap), then navigate directly. Clicking the anchor
// races React table re-renders ("Node is detached from document").
const targetHref = await page.$eval(
'a[data-automation-id^="client-link-"]',
(el) => el.getAttribute('href'),
);
if (!targetHref) throw new Error('first client row link has no href');
await page.goto(`http://localhost:${port}${targetHref}`, { waitUntil: 'domcontentloaded' });
if (!/\/msp\/clients\/[^/?#]+/.test(page.url())) {
throw new Error(`expected to land on a client detail page, got ${page.url()}`);
}
// Confirm the detail view actually rendered (not just navigated)
await page.waitForSelector('h1, [data-automation-id*="client"], [role="tablist"]', {
timeout: SMOKE_TIMEOUT_MS,
});
return { target_href: targetHref, url: page.url() };
});
if (pageErrors.length) result.page_errors = pageErrors.slice(0, 20);
} catch (err) {
topErr = err;
} finally {
if (pageErrors.length && !result.page_errors) result.page_errors = pageErrors.slice(0, 20);
await browser.close().catch(() => {});
}
if (topErr) {
// Surface partial step results alongside the top-level smoke failure.
topErr.partial = result;
throw topErr;
}
return result;
}
// ─── plumbing ─────────────────────────────────────────────────────────────
function onServerData(buf) {
const s = buf.toString();
serverLog += s;
process.stdout.write(s.replace(/^/gm, '[server] '));
// best-effort log persistence (fire-and-forget)
if (serverLogPath) {
import('node:fs').then((fs) => fs.appendFile(serverLogPath, s, () => {}));
}
flushWaiters();
}
const logWaiters = [];
function waitForLog(regex, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const idx = logWaiters.findIndex((w) => w.timer === timer);
if (idx >= 0) logWaiters.splice(idx, 1);
const tail = serverLog.split('\n').slice(-30).join('\n');
reject(new Error(`timeout waiting for ${label} (${timeoutMs}ms)\nlast server output:\n${tail}`));
}, timeoutMs);
const check = () => {
const lines = serverLog.split('\n');
for (const line of lines) {
if (regex.test(line)) {
clearTimeout(timer);
return resolve(line);
}
}
return null;
};
if (check()) return;
logWaiters.push({ regex, resolve, reject, timer, check });
});
}
function flushWaiters() {
for (let i = logWaiters.length - 1; i >= 0; i--) {
const w = logWaiters[i];
const lines = serverLog.split('\n');
for (const line of lines) {
if (w.regex.test(line)) {
clearTimeout(w.timer);
logWaiters.splice(i, 1);
w.resolve(line);
break;
}
}
}
}
function failPendingWaiters(err) {
while (logWaiters.length) {
const w = logWaiters.pop();
clearTimeout(w.timer);
w.reject(err);
}
}
async function runStage(name, fn) {
emit(`stage=${name} status=start`);
const t = Date.now();
try {
const data = (await fn()) ?? {};
const duration_ms = Date.now() - t;
stages[name] = { ok: true, duration_ms, ...data };
emit(`stage=${name} status=done duration_ms=${duration_ms}`);
} catch (err) {
const duration_ms = Date.now() - t;
const partial = err.partial && typeof err.partial === 'object' ? err.partial : {};
stages[name] = { ok: false, duration_ms, error: errToObj(err), ...partial };
emit(`stage=${name} status=fail duration_ms=${duration_ms} error=${JSON.stringify(err.message)}`);
throw err;
}
}
function finalizeSummary(extra) {
const total_ms = Object.values(stages).reduce((s, st) => s + (st.duration_ms ?? 0), 0);
return {
ok: extra.ok ?? Object.values(stages).every((s) => s.ok),
timestamp: new Date().toISOString(),
port: PORT,
total_ms,
stages,
build_log_path: buildLogPath,
server_log_path: serverLogPath,
...extra,
};
}
function emit(line) {
process.stdout.write(`[HARNESS] ${line}\n`);
}
function emitResult(summary) {
// Drop password before printing summary
const safe = JSON.parse(JSON.stringify(summary));
if (safe.stages?.password?.password) safe.stages.password.password = '<redacted>';
process.stdout.write(`[HARNESS RESULT] ${JSON.stringify(safe)}\n`);
}
function log(msg) {
process.stdout.write(`[HARNESS] ${msg}\n`);
}
function errToObj(err) {
return { message: err?.message ?? String(err), stack: err?.stack };
}
async function shutdown(reason) {
if (!serverProc) return;
if (serverProc.exitCode !== null) return;
log(`shutting down server (reason=${reason})`);
serverProc.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 1500));
if (serverProc.exitCode === null) serverProc.kill('SIGKILL');
}
function portFree(port) {
// Next binds to `::` (IPv6 ANY) by default. Test that exact bind to catch
// existing IPv6 listeners that an IPv4-loopback check would miss.
return new Promise((res) => {
const srv = createServer();
srv.once('error', () => res(false));
srv.once('listening', () => srv.close(() => res(true)));
srv.listen(port, '::');
});
}
async function dirSize(dir) {
const { readdir, stat } = await import('node:fs/promises');
const { join } = await import('node:path');
let total = 0;
async function walk(p) {
const entries = await readdir(p, { withFileTypes: true });
for (const e of entries) {
const full = join(p, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile()) total += (await stat(full)).size;
}
}
await walk(dir);
return total;
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
out[key] = true;
} else {
out[key] = isNaN(Number(next)) ? next : Number(next);
i++;
}
}
return out;
}