/**
* Build fixture apps for benchmarks.
*
* Each fixture is a directory we can publish, install, and time. The
* shapes are picked to bisect cost contributors:
*
* bench-tiny — 5 deps, all empty stubs. Measures fixed per-app overhead.
* bench-medium — 50 deps, real JS content. Measures dep-loop cost at scale.
* bench-large — 400 deps, varied size. Hyper-tps shape; bottleneck stress.
* bench-blob-2g — 1 dep with a single 2 GB blob. Measures bulk-transfer perf
* (raw throughput; what hyper-deploy / NetworkFetcher actually
* give us on one giant file).
* bench-many-2g — 2000 deps, ~1 MB each. Measures metadata throughput at
* the same total size (compare against blob-2g — divergence
* exposes per-tree overhead).
*
* Generated content is deterministic per fixture: same seed → same bytes →
* identical tree hashes across machines and runs. This is what makes
* cross-machine comparisons meaningful.
*
* Usage:
* node test/bench/fixtures/build-fixtures.js [--out <dir>] [--only <fixture>]
*
* --out <dir> base output dir (default: ./.bench-fixtures)
* --only <name> build just one fixture (e.g. bench-tiny)
* --skip-blobs skip the 2GB fixtures (saves a lot of disk + time)
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const __filename = new URL(import.meta.url).pathname
const __dirname = path.dirname(__filename)
function flag (name) {
const i = process.argv.indexOf(name)
return i === -1 ? undefined : process.argv[i + 1]
}
function bool (name) { return process.argv.includes(name) }
const OUT = flag('--out') || path.join(__dirname, '..', '..', '..', '.bench-fixtures')
const ONLY = flag('--only')
const SKIP_BLOBS = bool('--skip-blobs')
fs.mkdirSync(OUT, { recursive: true })
/**
* Deterministic byte generator. Seeded chacha20-ish via repeated SHA-256
* is overkill but the content has to look random enough that lazy
* dedup heuristics (e.g. compression) don't compress it to zero. Plain
* 'a' × N would otherwise let gzip dominate.
*/
function* deterministicBytes (seed, totalBytes) {
let block = crypto.createHash('sha256').update(seed).digest()
let written = 0
while (written < totalBytes) {
const remaining = totalBytes - written
const chunk = block.slice(0, Math.min(block.length, remaining))
written += chunk.length
yield chunk
block = crypto.createHash('sha256').update(block).digest()
}
}
function writeBytes (filePath, seed, totalBytes) {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const fd = fs.openSync(filePath, 'w')
try {
for (const chunk of deterministicBytes(seed, totalBytes)) {
fs.writeSync(fd, chunk)
}
} finally {
fs.closeSync(fd)
}
}
function makeFixtureRoot (name) {
const root = path.join(OUT, name)
if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true })
fs.mkdirSync(root, { recursive: true })
return root
}
function writePkg (root, pkg) {
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
}
function writeIndex (root, body) {
fs.writeFileSync(path.join(root, 'index.js'), body)
}
/**
* Build a single dep package inside the parent's node_modules.
*/
function makeDep (parentRoot, depName, opts = {}) {
const depRoot = path.join(parentRoot, 'node_modules', depName)
fs.mkdirSync(depRoot, { recursive: true })
fs.writeFileSync(path.join(depRoot, 'package.json'), JSON.stringify({
name: depName,
version: '1.0.0',
main: 'index.js',
...(opts.pkg || {})
}, null, 2) + '\n')
fs.writeFileSync(path.join(depRoot, 'index.js'),
opts.body || `module.exports = ${JSON.stringify(depName)}\n`)
if (opts.payloadBytes) {
writeBytes(path.join(depRoot, 'payload.bin'), depName + '-' + opts.seed,
opts.payloadBytes)
}
return depRoot
}
// ── Builders ─────────────────────────────────────────────────────
function buildTiny () {
const root = makeFixtureRoot('bench-tiny')
const deps = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
writePkg(root, {
name: 'bench-tiny', version: '1.0.0', main: 'index.js',
dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
pgh: { bin: 'bench-tiny', entrypoint: 'index.js', runtime: 'node' }
})
writeIndex(root, `// bench-tiny — 5 stub deps, no payload\nconsole.log('bench-tiny up')\n`)
for (const d of deps) makeDep(root, d, { seed: 'tiny' })
return root
}
function buildMedium () {
const root = makeFixtureRoot('bench-medium')
const count = 50
const deps = Array.from({ length: count }, (_, i) => `med-${String(i).padStart(3, '0')}`)
writePkg(root, {
name: 'bench-medium', version: '1.0.0', main: 'index.js',
dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
pgh: { bin: 'bench-medium', entrypoint: 'index.js', runtime: 'node' }
})
writeIndex(root, `console.log('bench-medium up — ${count} deps')\n`)
// Each dep carries ~40 KB of deterministic content (2 MB total).
for (const d of deps) makeDep(root, d, { seed: 'medium-' + d, payloadBytes: 40 * 1024 })
return root
}
function buildLarge () {
const root = makeFixtureRoot('bench-large')
const count = 400
const deps = Array.from({ length: count }, (_, i) => `lg-${String(i).padStart(4, '0')}`)
writePkg(root, {
name: 'bench-large', version: '1.0.0', main: 'index.js',
dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
pgh: { bin: 'bench-large', entrypoint: 'index.js', runtime: 'node' }
})
writeIndex(root, `console.log('bench-large up — ${count} deps')\n`)
// 50 KB per dep, varied a little so trees differ. 400 × 50 KB ≈ 20 MB total.
for (let i = 0; i < count; i++) {
const d = deps[i]
const size = 30 * 1024 + (i % 7) * 4 * 1024 // 30-54 KB
makeDep(root, d, { seed: 'large-' + d, payloadBytes: size })
}
return root
}
function buildBlob2G () {
const root = makeFixtureRoot('bench-blob-2g')
writePkg(root, {
name: 'bench-blob-2g', version: '1.0.0', main: 'index.js',
dependencies: { 'big-payload': '1.0.0' },
pgh: { bin: 'bench-blob-2g', entrypoint: 'index.js', runtime: 'node' }
})
writeIndex(root, `console.log('bench-blob-2g up')\n`)
// 2 GB single blob inside one dep. This is the "what does NetworkFetcher
// actually deliver on a big file" test.
const TWO_GB = 2 * 1024 * 1024 * 1024
makeDep(root, 'big-payload', { seed: 'blob2g', payloadBytes: TWO_GB })
return root
}
function buildMany2G () {
const root = makeFixtureRoot('bench-many-2g')
const count = 2000
const perDepKB = 1024 // 1 MB per dep × 2000 = 2 GB total
const deps = Array.from({ length: count }, (_, i) => `m2g-${String(i).padStart(4, '0')}`)
writePkg(root, {
name: 'bench-many-2g', version: '1.0.0', main: 'index.js',
dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
pgh: { bin: 'bench-many-2g', entrypoint: 'index.js', runtime: 'node' }
})
writeIndex(root, `console.log('bench-many-2g up — ${count} deps × 1MB')\n`)
for (const d of deps) {
makeDep(root, d, { seed: 'many2g-' + d, payloadBytes: perDepKB * 1024 })
}
return root
}
// ── Dispatch ─────────────────────────────────────────────────────
const BUILDERS = {
'bench-tiny': buildTiny,
'bench-medium': buildMedium,
'bench-large': buildLarge,
'bench-blob-2g': buildBlob2G,
'bench-many-2g': buildMany2G
}
const fixtures = ONLY ? [ONLY] : Object.keys(BUILDERS)
for (const name of fixtures) {
if (!BUILDERS[name]) {
console.error('unknown fixture: ' + name)
process.exit(2)
}
if (SKIP_BLOBS && name.includes('-2g')) {
console.log('skipping ' + name + ' (--skip-blobs)')
continue
}
console.log('building ' + name + '...')
const t0 = Date.now()
const root = BUILDERS[name]()
// Quick size report
const cmd = `du -sh "${root}" 2>/dev/null | awk '{print $1}'`
let sz = 'unknown'
try { sz = (await import('child_process')).execSync(cmd).toString().trim() } catch {}
console.log(' ' + name + ': ' + sz + ' in ' + ((Date.now() - t0) / 1000).toFixed(1) + 's → ' + root)
}