/**
* pgh-bench runner — execute a named scenario and emit one JSONL row.
*
* Scenarios are JS modules exporting:
* {
* name, // unique scenario id
* description, // one-line human summary
* setup : async (ctx) => {...}, // prepares the world; returns metadata
* run : async (ctx) => {...}, // the timed operation; returns metrics
* teardown : async (ctx) => {...} // cleans up; optional
* }
*
* The runner provides a `ctx` object containing:
* {
* storeRoot : <isolated $PGH_STORE_ROOT for this run>,
* appsDir : <PGH_STORE_ROOT/apps>,
* binDir : <a sandboxed bin dir>,
* sidecarSock : <UDS path for an isolated sidecar; null when not started>,
* env : <env vars to pass to spawned pgh CLIs>,
* emit : <(label, fields) => void — adds a row to the metrics buffer>,
* metrics : <accumulator written into the JSONL row at the end>,
* workspace : <scratch tmpdir for the scenario>
* }
*
* Each scenario is run in a freshly-mkdtemp'd workspace with its own
* isolated tree-store so chaos in one run never bleeds into another or
* into the developer's real install.
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import { execSync, spawn } from 'child_process'
import { performance } from 'perf_hooks'
export function createCtx (opts = {}) {
const workspace = opts.workspace || fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-bench-'))
const storeRoot = path.join(workspace, 'store')
const appsDir = path.join(storeRoot, 'apps')
const binDir = path.join(workspace, 'bin')
const sidecarSock = path.join(workspace, 'svc.sock')
fs.mkdirSync(storeRoot, { recursive: true })
fs.mkdirSync(appsDir, { recursive: true })
fs.mkdirSync(binDir, { recursive: true })
const env = {
...process.env,
PGH_STORE_ROOT: storeRoot,
PGH_BIN_DIR: binDir,
PGH_SIDECAR_SOCK: sidecarSock,
PGH_LINKED_DEVICE_FILE: path.join(workspace, 'no-linked.json'),
PGH_PROFILE: '1'
}
const metrics = {}
const phaseTimings = {}
return {
workspace,
storeRoot,
appsDir,
binDir,
sidecarSock,
env,
metrics,
phaseTimings,
emit (key, value) {
metrics[key] = value
},
markPhase (label, ms) {
phaseTimings[label] = ms
}
}
}
export async function timed (fn) {
const t0 = performance.now()
let result, error
try {
result = await fn()
} catch (err) {
error = err
}
const elapsed = Math.round(performance.now() - t0)
return { result, error, elapsed }
}
/**
* Spawn a child pgh CLI with the scenario's isolated env. Captures
* stdout + stderr. Parses PGH_PROFILE timing lines into ctx.phaseTimings.
*
* @returns {{ code, stdout, stderr, durationMs }}
*/
export function runPgh (ctx, args, opts = {}) {
const cliPath = opts.cliPath || path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'..', '..', 'bin', 'pgh'
)
const t0 = performance.now()
let stdout = ''
let stderr = ''
let code = null
return new Promise((resolve) => {
const child = spawn('node', [cliPath, ...args], {
env: ctx.env,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.on('data', d => { stdout += d.toString('utf8') })
child.stderr.on('data', d => {
const s = d.toString('utf8')
stderr += s
// Parse `[profile] +<ms>ms <label>` lines
for (const line of s.split('\n')) {
const m = line.match(/^\[profile\]\s+\+(\d+)ms\s+(.+)$/)
if (m) ctx.markPhase(m[2].trim(), parseInt(m[1], 10))
}
})
child.on('close', (c) => {
code = c
resolve({ code, stdout, stderr, durationMs: Math.round(performance.now() - t0) })
})
child.on('error', (err) => {
resolve({ code: -1, stdout, stderr, durationMs: Math.round(performance.now() - t0), error: err.message })
})
if (opts.killAfterMs) {
setTimeout(() => {
try { child.kill(opts.killSignal || 'SIGTERM') } catch {}
}, opts.killAfterMs)
}
})
}
/**
* Recursively measure the size of a directory tree on disk. Symlinks
* are followed only for the "logical" size in opts.follow; default is
* to count what's actually allocated (no follow).
*/
export function dirSize (dir, opts = {}) {
let total = 0
let files = 0
function walk (p) {
let stat
try { stat = opts.follow ? fs.statSync(p) : fs.lstatSync(p) } catch { return }
if (stat.isSymbolicLink()) return
if (stat.isFile()) {
total += stat.size
files++
return
}
if (stat.isDirectory()) {
let entries
try { entries = fs.readdirSync(p) } catch { return }
for (const e of entries) walk(path.join(p, e))
}
}
walk(dir)
return { bytes: total, files }
}
/**
* Execute a scenario module against a fresh ctx.
*/
export async function executeScenario (scenarioModule, opts = {}) {
const ctx = createCtx(opts)
const startedAt = new Date().toISOString()
const t0 = performance.now()
let outcome = 'success'
let error = null
try {
if (scenarioModule.setup) await scenarioModule.setup(ctx)
await scenarioModule.run(ctx)
} catch (err) {
outcome = 'failed'
error = { message: err.message, stack: err.stack }
} finally {
try {
if (scenarioModule.teardown) await scenarioModule.teardown(ctx)
} catch {}
}
const totalMs = Math.round(performance.now() - t0)
// Pull system info for the row
let pghSha = 'unknown'
try {
pghSha = execSync('git -C ' + path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'..', '..'
) + ' rev-parse --short HEAD', { encoding: 'utf8' }).trim()
} catch {}
const row = {
scenario: scenarioModule.name,
description: scenarioModule.description,
machine: os.hostname(),
pghSha,
nodeVersion: process.version,
startedAt,
totalMs,
outcome,
error,
phaseTimings: ctx.phaseTimings,
metrics: ctx.metrics
}
// Workspace cleanup unless caller wants it preserved (e.g. failure inspection)
if (!opts.keepWorkspace && outcome === 'success') {
try { fs.rmSync(ctx.workspace, { recursive: true, force: true }) } catch {}
} else {
row.workspace = ctx.workspace
}
return row
}
/**
* CLI: node test/bench/runner.js <scenario-file> [<scenario-file> ...] [--output <file>]
*/
async function main () {
const args = process.argv.slice(2)
const outIdx = args.indexOf('--output')
const outputFile = outIdx !== -1 ? args[outIdx + 1] : null
const scenarios = args.filter((a, i) => !a.startsWith('--') && (outIdx === -1 || i !== outIdx + 1))
if (scenarios.length === 0) {
console.error('usage: node runner.js <scenario.js> [<scenario.js> ...] [--output results.jsonl]')
process.exit(2)
}
const results = []
for (const file of scenarios) {
const absPath = path.resolve(file)
process.stderr.write(`▶ ${path.basename(file)}\n`)
const mod = await import(absPath)
const row = await executeScenario(mod, { keepWorkspace: false })
results.push(row)
process.stderr.write(` ${row.outcome} in ${row.totalMs}ms\n`)
}
const lines = results.map(r => JSON.stringify(r)).join('\n') + '\n'
if (outputFile) {
fs.appendFileSync(outputFile, lines)
process.stderr.write(`wrote ${results.length} rows → ${outputFile}\n`)
} else {
process.stdout.write(lines)
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(err => {
console.error(err.stack || err)
process.exit(1)
})
}