OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / bench / scenarios / _lib.js

180 lines · javascript ref:
/**
 * 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
}