OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / runtime-closure-install.test.js

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