/**
* Integration: pgh-runtime's installRelease + pgh's REAL node materializer.
*
* This is the test that was missing — the reason the "pgh-runtime only fetches
* root trees, not dep closures" gap was never caught. pgh-runtime's own tests
* mock the store + a no-op materialize, so a real dependency closure is never
* exercised. Here we use a real TreeStore + the real node materializer and
* assert an actually-runnable install (node_modules wired) comes out.
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import crypto from 'hypercore-crypto'
import { TreeStore } from '../lib/store.js'
import { materialize } from '../lib/materializers/node.js'
import { atomicMaterializer } from '../lib/materializers/atomic.js'
import { installComplete } from '../lib/runtime-factory.js'
import { PghRuntime, installRelease, installTree } from 'pgh-runtime'
import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree'
function mktemp () {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-rt-int-'))
}
// Build a content-addressed tree from { fileName: stringBody } and register
// the bytes in `contents` so a fetcher can serve them. childHash is the store's
// blake2b content hash (crypto.data), matching what materialize verifies.
function buildTree (files, contents) {
const entries = []
for (const [name, body] of Object.entries(files)) {
const buf = Buffer.from(body)
const h = crypto.data(buf).toString('hex')
contents[h] = buf
entries.push({ kind: KIND_FILE, name, exec: 0, childHash: h, size: buf.length })
}
return { hash: computeTreeHash(entries), entries }
}
function makeFixture () {
const contents = {}
const trees = {}
const tree = (files) => {
const t = buildTree(files, contents)
trees[t.hash] = { entries: t.entries }
return t.hash
}
const depHash = tree({
'package.json': JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' }),
'index.js': 'module.exports = "DEP-OK"'
})
const rootHash = tree({
'package.json': JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { dep: '^1.0.0' } }),
'index.js': 'console.log(require("dep"))'
})
const fetcher = {
getTree: async (h) => trees[h],
getContent: async (h) => contents[h]
}
const release = { name: 'app', treeHash: rootHash, deps: [{ name: 'dep', version: '1.0.0', tree: depHash }] }
return { fetcher, release, rootHash, depHash }
}
test('installRelease + real node materializer → working install with node_modules wired', async (t) => {
const { fetcher, release } = makeFixture()
const store = new TreeStore({ root: mktemp() })
await installRelease(store, release, fetcher, { appName: 'app', materialize })
const installPath = store.appPath('app')
const depEntry = path.join(installPath, 'node_modules', 'dep', 'index.js')
t.ok(fs.existsSync(depEntry), 'dep is wired into node_modules')
t.is(fs.readFileSync(depEntry, 'utf8'), 'module.exports = "DEP-OK"', 'dep content resolves correctly')
t.ok(fs.existsSync(path.join(installPath, 'index.js')), 'root app file present')
fs.rmSync(store.root, { recursive: true, force: true })
})
test('GAP: installTree alone does NOT fetch the closure, so the materializer cannot wire it', async (t) => {
const { fetcher, release, rootHash, depHash } = makeFixture()
const store = new TreeStore({ root: mktemp() })
// Old behavior: only the root tree is fetched.
await installTree(store, rootHash, fetcher)
t.ok(store.hasTree(rootHash), 'root fetched')
t.absent(store.hasTree(depHash), 'dep closure NOT fetched (the gap)')
// Without the dep tree in the store, the materializer cannot build the install.
await t.exception(
async () => materialize({ store, appName: 'app', rootTreeHash: rootHash, deps: release.deps, host: null }),
'materializer fails when the dep closure is missing from the store'
)
fs.rmSync(store.root, { recursive: true, force: true })
})
test('installRelease updates over an existing install atomically (no "already exists" throw)', async (t) => {
const contents = {}
const trees = {}
const tree = (files) => { const x = buildTree(files, contents); trees[x.hash] = { entries: x.entries }; return x.hash }
const depH = tree({
'package.json': JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' }),
'index.js': 'module.exports = "DEP"'
})
const v1 = tree({
'package.json': JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { dep: '^1.0.0' } }),
'index.js': 'VERSION-1'
})
const v2 = tree({
'package.json': JSON.stringify({ name: 'app', version: '2.0.0', dependencies: { dep: '^1.0.0' } }),
'index.js': 'VERSION-2'
})
const fetcher = { getTree: async (h) => trees[h], getContent: async (h) => contents[h] }
const deps = [{ name: 'dep', version: '1.0.0', tree: depH }]
const store = new TreeStore({ root: mktemp() })
const mz = atomicMaterializer({ materialize })
// Fresh install (v1).
await installRelease(store, { name: 'app', treeHash: v1, deps }, fetcher, { appName: 'app', materialize: mz })
const idx = path.join(store.appPath('app'), 'index.js')
t.is(fs.readFileSync(idx, 'utf8'), 'VERSION-1', 'v1 installed')
// Update to v2 OVER the existing install — must not throw, must swap content.
await installRelease(store, { name: 'app', treeHash: v2, deps }, fetcher, { appName: 'app', materialize: mz })
t.is(fs.readFileSync(idx, 'utf8'), 'VERSION-2', 'v2 atomically swapped in over the existing install')
t.ok(fs.existsSync(path.join(store.appPath('app'), 'node_modules', 'dep', 'index.js')), 'deps still wired after update')
fs.rmSync(store.root, { recursive: true, force: true })
})
test('CONTRAST: plain materialize throws on a second install (why atomic is needed)', async (t) => {
const { fetcher, release } = makeFixture()
const store = new TreeStore({ root: mktemp() })
await installRelease(store, release, fetcher, { appName: 'app', materialize }) // fresh OK
await t.exception(
async () => installRelease(store, release, fetcher, { appName: 'app', materialize }),
'plain materialize throws "already exists" on a second install — installRelease needs the atomic materializer for updates'
)
fs.rmSync(store.root, { recursive: true, force: true })
})
test('PROOF: real PghRuntime self-update via real pgh internals — first install + atomic update', async (t) => {
// Wired exactly as pgh's CLI would for self-update: the REAL PghRuntime
// class + REAL TreeStore + REAL node materializer + atomicMaterializer.
// Only the network transport is a fixture-backed fetcher (the seam).
const contents = {}
const trees = {}
const tree = (files) => { const x = buildTree(files, contents); trees[x.hash] = { entries: x.entries }; return x.hash }
const depH = tree({
'package.json': JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' }),
'index.js': 'module.exports = "DEP"'
})
const v1 = tree({ 'package.json': JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { dep: '^1.0.0' } }), 'index.js': 'V1' })
const v2 = tree({ 'package.json': JSON.stringify({ name: 'app', version: '2.0.0', dependencies: { dep: '^1.0.0' } }), 'index.js': 'V2' })
const deps = [{ name: 'dep', version: '1.0.0', tree: depH }]
let latest = { name: 'app', version: '1.0.0', treeHash: v1, deps }
const fetcher = {
getTree: async (h) => trees[h],
getContent: async (h) => contents[h],
getLatestRelease: async () => latest
}
const store = new TreeStore({ root: mktemp() })
const runtime = new PghRuntime({
name: 'app', fetcher, store, interval: 0,
materialize: atomicMaterializer({ materialize })
})
const idx = path.join(store.appPath('app'), 'index.js')
// First install (v1) driven by the real runtime.
await runtime.start()
t.is(fs.readFileSync(idx, 'utf8'), 'V1', 'runtime installed v1 (full closure, real materializer)')
t.ok(fs.existsSync(path.join(store.appPath('app'), 'node_modules', 'dep', 'index.js')), 'deps wired on first install')
// Publish v2; the runtime atomically updates the LIVE install in place.
latest = { name: 'app', version: '2.0.0', treeHash: v2, deps }
const res = await runtime.checkNow()
t.ok(res, 'checkNow detected + applied v2')
t.is(fs.readFileSync(idx, 'utf8'), 'V2', 'runtime atomically self-updated the live install to v2')
t.ok(fs.existsSync(path.join(store.appPath('app'), 'node_modules', 'dep', 'index.js')), 'deps still wired after self-update')
t.is(runtime.readyToApply().newTreeHash, v2, 'readyToApply reports new tree for supervisor respawn')
await runtime.stop()
fs.rmSync(store.root, { recursive: true, force: true })
})
test('installComplete: full parity — materialize + launcher + manifest', async (t) => {
const { fetcher, release } = makeFixture()
const store = new TreeStore({ root: mktemp() })
const binDir = mktemp()
const out = await installComplete({ store, release, fetcher, name: 'app', host: 'linux-x64', binDir, runtimePath: 'bare' })
// 1. materialized install (the substantial part, in the store)
t.ok(fs.existsSync(path.join(store.appPath('app'), 'index.js')), 'app tree materialized in store')
t.ok(fs.existsSync(path.join(store.appPath('app'), 'node_modules', 'dep', 'index.js')), 'deps wired')
// 2. launcher shim (PATH entry pointing INTO the store)
const launcher = path.join(binDir, 'app')
t.ok(fs.existsSync(launcher), 'launcher written')
const body = fs.readFileSync(launcher, 'utf8')
t.ok(body.startsWith('#!/bin/sh'), 'launcher is a shell shim')
t.ok(body.includes(path.join(store.appPath('app'), 'index.js')), 'launcher execs the install entrypoint')
t.is(out.launcher, launcher, 'returns launcher path')
// 3. install manifest (so `pgh app list` sees it)
const man = JSON.parse(fs.readFileSync(path.join(store.appPath('app'), '.pgh-install.json'), 'utf8'))
t.is(man.name, 'app', 'manifest name')
t.is(man.treeHash, release.treeHash, 'manifest records the tree')
t.is(man.entrypoint, 'index.js', 'manifest entrypoint')
t.is(man.source, 'pgh-runtime', 'manifest tags the installer')
fs.rmSync(store.root, { recursive: true, force: true })
fs.rmSync(binDir, { recursive: true, force: true })
})
test('installComplete parity: pkg.pgh-driven launcher + full install manifest', async (t) => {
const contents = {}
const trees = {}
const tree = (files) => { const x = buildTree(files, contents); trees[x.hash] = { entries: x.entries }; return x.hash }
const depH = tree({ 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' }), 'index.js': 'module.exports="D"' })
const rootH = tree({
'package.json': JSON.stringify({
name: 'app', version: '3.0.0', dependencies: { dep: '^1.0.0' },
pgh: { entrypoint: 'main.js', bin: 'myapp', runtime: 'bare', defaults: { env: { X: '1' } }, depends: ['dep'], autostart: true, readiness: { type: 'uds' } }
}),
'main.js': 'console.log("hi")'
})
const fetcher = { getTree: async (h) => trees[h], getContent: async (h) => contents[h] }
const release = { name: 'app', version: '3.0.0', treeHash: rootH, deps: [{ name: 'dep', version: '1.0.0', tree: depH }] }
const store = new TreeStore({ root: mktemp() })
const binDir = mktemp()
await installComplete({ store, release, fetcher, name: 'app', host: 'linux-x64', binDir })
// launcher uses pgh.bin name + pgh.entrypoint, bare interp (no bundled runtime here)
const launcher = path.join(binDir, 'myapp')
t.ok(fs.existsSync(launcher), 'launcher uses pgh.bin name')
const body = fs.readFileSync(launcher, 'utf8')
// The interp must be bare. It used to be asserted as the literal string 'exec bare ', which also pinned
// the PATH-DEPENDENT form — and that is the bug that made `ssh ashatron 'hyper-proto-peer diag'` fail
// with "exec: bare: not found" while the same launcher worked elsewhere. resolveBundledRuntime now
// resolves an absolute runtime whenever one is reachable, so assert the INTENT (which interpreter) and
// leave the fallback ORDER to test/launcher-runtime-path.test.js, which injects execPath to cover it.
const rt = body.trim().split('\n').pop().split(' ')[1] // the exec line is LAST (the in-use lock lines precede it)
t.is(path.basename(rt), 'bare', 'bare interp (absolute whenever a runtime can be resolved)')
t.ok(body.includes(path.join(store.appPath('app'), 'main.js')), 'launcher execs pgh.entrypoint')
// full manifest — parity with installViaRegistry
const m = JSON.parse(fs.readFileSync(path.join(store.appPath('app'), '.pgh-install.json'), 'utf8'))
t.is(m.publishedName, 'app', 'publishedName')
t.is(m.version, '3.0.0', 'version')
t.is(m.entrypoint, 'main.js', 'entrypoint from pkg.pgh')
t.is(m.runtime, 'bare', 'runtime')
t.alike(m.defaults, { env: { X: '1' } }, 'defaults carried')
t.alike(m.depends, ['dep'], 'depends carried')
t.is(m.autostart, true, 'autostart carried')
t.alike(m.readiness, { type: 'uds' }, 'readiness carried')
t.is(m.sourcePath, store.appPath('app'), 'sourcePath')
fs.rmSync(store.root, { recursive: true, force: true })
fs.rmSync(binDir, { recursive: true, force: true })
})