OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-runtime / test / runtime.test.js

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