/**
* Shared helpers for bench scenarios.
*
* Each scenario picks a fixture, publishes it (capturing the resulting
* registry/serverKey/treeHash + dep tree hashes), and then exercises a
* particular install path. The "publish once, install many times"
* pattern is what lets us measure warm-cache and partial-cache behavior
* without paying the publish cost in each scenario.
*
* Fixtures must be pre-built via test/bench/fixtures/build-fixtures.js.
*/
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
import { runPgh, dirSize } from '../runner.js'
const __filename = new URL(import.meta.url).pathname
const __dirname = path.dirname(__filename)
export const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', '.bench-fixtures')
export const CLI_PATH = path.resolve(__dirname, '..', '..', '..', 'bin', 'pgh')
let _cachedRegistryKey = null
/**
* Get the local publisher's registry key. Cached for the process so
* the suite doesn't re-query for every scenario. Returns null if the
* sidecar isn't running.
*/
export function getLocalRegistryKey () {
if (_cachedRegistryKey) return _cachedRegistryKey
try {
const out = execSync('node ' + JSON.stringify(CLI_PATH) + ' releases key 2>/dev/null', {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
}).trim()
// `pgh releases key` outputs the hex key on a line by itself.
const m = out.match(/[0-9a-f]{64}/)
if (m) {
_cachedRegistryKey = m[0]
return _cachedRegistryKey
}
} catch {}
return null
}
export function fixturePath (name) {
const p = path.join(FIXTURES_DIR, name)
if (!fs.existsSync(p)) {
throw new Error(
'Fixture ' + name + ' not built. Run: node test/bench/fixtures/build-fixtures.js --only ' + name
)
}
return p
}
/**
* Publish a fixture from a workspace-relative copy so the source
* tree is identical across runs. Returns the publish result row
* + the lockfile path.
*
* NOTE — isolation seam:
* Publish runs WITHOUT the sandbox PGH_STORE_ROOT so the tree
* lands in the user's real ~/.local/share/store/. The install
* subprocess uses the sandbox PGH_STORE_ROOT so it fetches the
* tree over the network (deploy topic) — measuring the real
* install path. Side effect: the user's published-index gets a
* bench-* entry per run, capped to 3 versions by retention policy.
* Genuine hermetic publish needs sidecar-per-test (see
* docs/2026-05-13-architecture-critique.md #7).
*/
export async function publishFixture (ctx, fixtureName) {
const src = fixturePath(fixtureName)
const stagingDir = path.join(ctx.workspace, 'publish-' + fixtureName)
// Hardlink-copy the fixture so our publish doesn't mutate the canonical one.
execSync('cp -al ' + JSON.stringify(src) + ' ' + JSON.stringify(stagingDir))
// Strip sandbox env vars for publish — we WANT this in the user's
// real store + sidecar so the network install path can find it.
const publishEnv = { ...ctx.env }
delete publishEnv.PGH_STORE_ROOT
delete publishEnv.PGH_SIDECAR_SOCK
const publishOut = await runPgh(
{ ...ctx, env: publishEnv },
['publish', stagingDir],
{ cliPath: CLI_PATH }
)
if (publishOut.code !== 0) {
throw new Error('pgh publish failed for ' + fixtureName + ':\n' + publishOut.stderr)
}
// Parse the lockfile to get tree hashes for downstream scenarios.
const lockfile = JSON.parse(fs.readFileSync(path.join(stagingDir, '.pgh-lock.json'), 'utf8'))
return { stagingDir, lockfile, publishOut }
}
/**
* Construct a pgh:// URL pointing at a freshly-published fixture
* using the local sidecar's registry key.
*/
export function pghUrl (lockfile) {
const key = getLocalRegistryKey()
if (!key) {
throw new Error(
'cannot resolve local registry key — is pgh-sidecar running? ' +
'try `pgh app status pgh-sidecar`'
)
}
return 'pgh://' + key + '/' + lockfile.name
}
/**
* Install a fixture by name + measure phases. Returns the parsed
* profile timeline plus disk sizes.
*/
export async function installByName (ctx, lockfile, installName) {
installName = installName || lockfile.name
const result = await runPgh(ctx, [
'app', 'install', pghUrl(lockfile),
'--name', installName
], { cliPath: CLI_PATH })
if (result.code !== 0) {
throw new Error('install failed: ' + result.stderr.slice(-2000))
}
// Disk size of the install dir + the store.
const appPath = path.join(ctx.appsDir, installName)
const store = path.join(ctx.storeRoot, 'dependencies')
const appSize = dirSize(appPath, { follow: false })
const storeSize = dirSize(store, { follow: false })
ctx.emit('install_app_bytes', appSize.bytes)
ctx.emit('install_app_files', appSize.files)
ctx.emit('install_store_bytes', storeSize.bytes)
ctx.emit('install_store_files', storeSize.files)
ctx.emit('install_duration_ms', result.durationMs)
ctx.emit('install_exit_code', result.code)
return result
}
/**
* Clear the tree-store entirely (forces a cold-cache install on next op).
*/
export function clearStore (ctx) {
const deps = path.join(ctx.storeRoot, 'dependencies')
if (fs.existsSync(deps)) fs.rmSync(deps, { recursive: true, force: true })
fs.mkdirSync(deps, { recursive: true })
}
/**
* Remove just the installed app dir (forces re-materialize without
* re-fetching dep trees).
*/
export function clearApp (ctx, installName) {
const p = path.join(ctx.appsDir, installName)
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true })
}
/**
* Remove a random ~half of the dep trees from the store (forces
* partial-cache install).
*/
export function evictHalfStore (ctx) {
const depsDir = path.join(ctx.storeRoot, 'dependencies')
if (!fs.existsSync(depsDir)) return 0
const entries = fs.readdirSync(depsDir)
let evicted = 0
for (let i = 0; i < entries.length; i++) {
if (i % 2 === 0) {
fs.rmSync(path.join(depsDir, entries[i]), { recursive: true, force: true })
evicted++
}
}
return evicted
}