/**
* PghRuntime tests — standalone
*
* Uses a minimal in-memory store mock + in-memory fetcher so this test
* has no dependency on the pgh CLI package. Any store implementing
* { init, hasTree, materialize } works with pgh-runtime.
*/
import test from 'brittle'
import crypto from 'hypercore-crypto'
import { PghRuntime, installTree, installRelease } from '../index.js'
import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree'
function hashBytes (b) { return crypto.data(b).toString('hex') }
/**
* In-memory store that implements the interface pgh-runtime expects.
* materialize just records (tree hash → entries); no real filesystem.
*/
function makeMemoryStore () {
const trees = new Map()
return {
init () {},
hasTree (hash) { return trees.has(hash) },
async materialize (hash, entries) {
trees.set(hash, { entries, pulledAt: Date.now() })
},
_trees: trees
}
}
function makeFetcher () {
const trees = {}
const contents = {}
let latest = null
function build (entries) {
const outEntries = entries.map(e => {
if (e.content) {
const buf = Buffer.from(e.content)
const h = hashBytes(buf)
contents[h] = buf
return { kind: KIND_FILE, exec: 0, name: e.name, childHash: h, size: buf.length }
}
return e
})
const h = computeTreeHash(outEntries)
trees[h] = { entries: outEntries, size: outEntries.reduce((a, c) => a + (c.size || 0), 0) }
return h
}
return {
build,
async getTree (h) {
if (!trees[h]) throw new Error('no tree: ' + h.slice(0, 12))
return trees[h]
},
async getContent (h) {
if (!contents[h]) throw new Error('no content: ' + h.slice(0, 12))
return contents[h]
},
async getLatestRelease () { return latest },
setLatest (entry) { latest = entry }
}
}
// ── Lifecycle ────────────────────────────────────────────────
test('start + stop lifecycle', async (t) => {
const runtime = new PghRuntime({
name: 'test',
fetcher: makeFetcher(),
store: makeMemoryStore(),
interval: 0,
startImmediately: false
})
await runtime.start()
await runtime.stop()
t.pass('clean')
})
test('constructor rejects missing required opts', (t) => {
t.exception(() => new PghRuntime({}))
t.exception(() => new PghRuntime({ name: 'x' }))
t.exception(() => new PghRuntime({ name: 'x', fetcher: {} }))
})
// ── No-update scenarios ─────────────────────────────────────
test('no release → no updated event', async (t) => {
let updated = false
const runtime = new PghRuntime({
name: 'test', fetcher: makeFetcher(), store: makeMemoryStore(), interval: 0
})
runtime.on('updated', () => { updated = true })
await runtime.start()
t.absent(updated)
await runtime.stop()
})
test('same tree hash → no update', async (t) => {
const fetcher = makeFetcher()
const h = fetcher.build([{ name: 'a', content: 'x' }])
fetcher.setLatest({ name: 'test', version: '1.0.0', treeHash: h })
let updated = false
const runtime = new PghRuntime({
name: 'test', fetcher, store: makeMemoryStore(), interval: 0, currentTreeHash: h
})
runtime.on('updated', () => { updated = true })
await runtime.start()
t.absent(updated)
await runtime.stop()
})
// ── Happy path ───────────────────────────────────────────────
test('new release → updating + updated events + stored tree', async (t) => {
const fetcher = makeFetcher()
const h = fetcher.build([{ name: 'index.js', content: 'ok' }])
fetcher.setLatest({ name: 'test', version: '1.0.0', treeHash: h })
const store = makeMemoryStore()
const events = []
const runtime = new PghRuntime({
name: 'test', fetcher, store, interval: 0, currentTreeHash: 'x'.repeat(64)
})
runtime.on('updating', (r) => events.push({ type: 'updating', version: r.version }))
runtime.on('updated', (i) => events.push({ type: 'updated', hash: i.newTreeHash }))
await runtime.start()
t.is(events.length, 2)
t.is(events[0].type, 'updating')
t.is(events[1].type, 'updated')
t.is(events[1].hash, h)
t.ok(store.hasTree(h))
t.is(runtime.updated, true)
await runtime.stop()
})
test('readyToApply descriptor after update', async (t) => {
const fetcher = makeFetcher()
const h = fetcher.build([{ name: 'a', content: 'a' }])
fetcher.setLatest({ name: 'test', version: '2.0.0', treeHash: h })
const previous = 'p'.repeat(64)
const runtime = new PghRuntime({
name: 'test', fetcher, store: makeMemoryStore(), interval: 0, currentTreeHash: previous
})
t.is(runtime.readyToApply(), null)
await runtime.start()
const desc = runtime.readyToApply()
t.ok(desc)
t.is(desc.name, 'test')
t.is(desc.previousTreeHash, previous)
t.is(desc.newTreeHash, h)
t.is(desc.release.version, '2.0.0')
await runtime.stop()
})
// ── Dependency closure (installRelease) ─────────────────────
test('GAP: installTree fetches only the root tree, not the dep closure', async (t) => {
const fetcher = makeFetcher()
const depH = fetcher.build([{ name: 'index.js', content: 'module.exports = "dep"' }])
const rootH = fetcher.build([{ name: 'index.js', content: 'require("dep")' }])
const store = makeMemoryStore()
await installTree(store, rootH, fetcher)
t.ok(store.hasTree(rootH), 'root tree fetched')
t.absent(store.hasTree(depH), 'dep tree NOT fetched — this is the gap installRelease closes')
})
test('installRelease fetches the FULL closure (root + dep.tree + prebuilds[host])', async (t) => {
const fetcher = makeFetcher()
const depH = fetcher.build([{ name: 'index.js', content: 'module.exports = "dep"' }])
const preH = fetcher.build([{ name: 'native.node', content: 'PREBUILT' }])
const rootH = fetcher.build([{ name: 'index.js', content: 'require("dep")' }])
const store = makeMemoryStore()
const release = {
name: 'app',
treeHash: rootH,
deps: [{ name: 'dep', version: '1.0.0', tree: depH, prebuilds: { 'linux-x64': preH } }]
}
const res = await installRelease(store, release, fetcher, { host: 'linux-x64' })
t.ok(store.hasTree(rootH), 'root tree present')
t.ok(store.hasTree(depH), 'dep tree present (closure fetched)')
t.ok(store.hasTree(preH), 'per-host prebuild tree present')
t.is(res.closure, 3, 'reported 3 closure trees')
})
test('installRelease ignores a foreign-host prebuild', async (t) => {
const fetcher = makeFetcher()
const depH = fetcher.build([{ name: 'index.js', content: 'x' }])
const preLinux = fetcher.build([{ name: 'n', content: 'LINUX' }])
const preMac = fetcher.build([{ name: 'n', content: 'MAC' }])
const rootH = fetcher.build([{ name: 'index.js', content: 'y' }])
const store = makeMemoryStore()
const release = {
name: 'app',
treeHash: rootH,
deps: [{ name: 'dep', version: '1.0.0', tree: depH, prebuilds: { 'linux-x64': preLinux, 'darwin-arm64': preMac } }]
}
await installRelease(store, release, fetcher, { host: 'linux-x64' })
t.ok(store.hasTree(preLinux), 'this host prebuild fetched')
t.absent(store.hasTree(preMac), 'foreign-host prebuild NOT fetched')
})
test('installRelease hands the closure to an injected materializer', async (t) => {
const fetcher = makeFetcher()
const depH = fetcher.build([{ name: 'index.js', content: 'd' }])
const rootH = fetcher.build([{ name: 'index.js', content: 'r' }])
const store = makeMemoryStore()
const release = { name: 'app', treeHash: rootH, deps: [{ name: 'dep', version: '1.0.0', tree: depH }] }
let call = null
await installRelease(store, release, fetcher, {
host: 'linux-x64',
appName: 'app',
materialize: (args) => { call = args; return { installPath: '/fake/app' } }
})
t.ok(call, 'materializer was invoked')
t.is(call.rootTreeHash, rootH, 'rootTreeHash passed')
t.is(call.appName, 'app', 'appName passed')
t.is(call.host, 'linux-x64', 'host passed')
t.alike(call.deps.map(d => d.name), ['dep'], 'deps passed through')
})
test('PghRuntime._check now installs the full closure (not just root)', async (t) => {
const fetcher = makeFetcher()
const depH = fetcher.build([{ name: 'index.js', content: 'dep' }])
const rootH = fetcher.build([{ name: 'index.js', content: 'app' }])
fetcher.setLatest({
name: 'app', version: '1.0.0', treeHash: rootH,
deps: [{ name: 'dep', version: '1.0.0', tree: depH }]
})
const store = makeMemoryStore()
const runtime = new PghRuntime({ name: 'app', fetcher, store, interval: 0, host: 'linux-x64' })
await runtime.start()
t.ok(store.hasTree(rootH), 'root installed by runtime')
t.ok(store.hasTree(depH), 'dep closure installed by runtime (the fix)')
t.is(runtime.updated, true)
await runtime.stop()
})
// ── Errors ──────────────────────────────────────────────────
test('fetcher throws → error event', async (t) => {
const fetcher = {
async getLatestRelease () { throw new Error('nope') },
async getTree () {},
async getContent () {}
}
let err = null
const runtime = new PghRuntime({
name: 'test', fetcher, store: makeMemoryStore(), interval: 0
})
runtime.on('error', (e) => { err = e })
await runtime.start()
t.ok(err)
t.ok(err.message.includes('nope'))
await runtime.stop()
})
test('installTree failure mid-update → error event, not "updated"', async (t) => {
const fetcher = makeFetcher()
fetcher.setLatest({ name: 'test', version: '1.0.0', treeHash: 'z'.repeat(64) })
let err = null, updated = false
const runtime = new PghRuntime({
name: 'test', fetcher, store: makeMemoryStore(), interval: 0, currentTreeHash: 'x'.repeat(64)
})
runtime.on('error', (e) => { err = e })
runtime.on('updated', () => { updated = true })
await runtime.start()
t.ok(err)
t.absent(updated)
t.is(runtime.updating, false)
await runtime.stop()
})
// ── Polling ─────────────────────────────────────────────────
test('polling picks up a new release between checks', async (t) => {
const fetcher = makeFetcher()
let updatedHash = null
const runtime = new PghRuntime({
name: 'test', fetcher, store: makeMemoryStore(),
interval: 30, startImmediately: true
})
runtime.on('updated', (i) => { updatedHash = i.newTreeHash })
await runtime.start()
t.is(updatedHash, null)
const h = fetcher.build([{ name: 'q', content: 'q' }])
fetcher.setLatest({ name: 'test', version: '1.0.0', treeHash: h })
await new Promise(r => setTimeout(r, 100))
t.is(updatedHash, h)
await runtime.stop()
})
// ── installTree (direct) ────────────────────────────────────
test('installTree — direct call walks a nested tree', async (t) => {
const fetcher = makeFetcher()
// leaf subtree
const leafEntries = [{ kind: KIND_FILE, exec: 0, name: 'util.js', childHash: hashBytes(Buffer.from('u')), size: 1 }]
const leafHash = computeTreeHash(leafEntries)
fetcher.build([{ name: 'util.js', content: 'u' }]) // registers content for util.js
// Replace fetcher.getTree to serve our leaf + root
const rootEntries = [{ kind: 1, exec: 0, name: 'lib', childHash: leafHash, size: 1 }]
const rootHash = computeTreeHash(rootEntries)
const trees = { [leafHash]: { entries: leafEntries, size: 1 }, [rootHash]: { entries: rootEntries, size: 1 } }
const customFetcher = {
async getTree (h) { if (!trees[h]) throw new Error('no tree'); return trees[h] },
async getContent (h) { return fetcher.getContent(h) }
}
const store = makeMemoryStore()
const result = await installTree(store, rootHash, customFetcher)
// Single self-contained materialize: the root tree is materialized once and
// store.materialize inlines the 'lib' subtree via fetchTree — the subtree
// does NOT become its own store tree (no redundant per-subtree dir).
t.is(result.materialized, 1, 'one self-contained materialize of the root')
t.ok(store.hasTree(rootHash), 'root tree present')
t.absent(store.hasTree(leafHash), 'subtree inlined into root, not a separate store tree')
})
test('installTree — skips already-present trees', async (t) => {
const fetcher = makeFetcher()
const h = fetcher.build([{ name: 'a', content: 'a' }])
const store = makeMemoryStore()
const first = await installTree(store, h, fetcher)
t.is(first.materialized, 1)
t.is(first.skipped, 0)
const second = await installTree(store, h, fetcher)
t.is(second.materialized, 0)
t.is(second.skipped, 1)
})