OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar / test / sidecar.test.js

448 lines · javascript ref:
/**
 * pgh-sidecar tests — P.10 Phase 0.5e
 *
 * Exercises: start/stop lifecycle, identity vault creation, UDS RPC
 * handlers (ping, identity-info, store-stats, store-list, stubs).
 */

import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import process from '#node:process'
import crypto from 'hypercore-crypto'

import { Client } from 'hyper-svc'
import { Sidecar } from '../lib/index.js'

function mktemp (prefix = 'pgh-sidecar') {
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}

async function makeSidecar (opts = {}) {
  const root = mktemp('sidecar-root')
  const storeRoot = mktemp('sidecar-store')
  const registryStore = mktemp('sidecar-registry')
  const nameRegistryStore = mktemp('sidecar-names')
  const releaseRegistryReplicasDir = mktemp('sidecar-replicas')
  const sock = path.join(root, 'svc.sock')
  const sidecar = new Sidecar({
    root,
    socketPath: sock,
    storeRoot,
    registryStore,
    nameRegistryStore,
    releaseRegistryReplicasDir,
    // Default off for hermetic tests — no Hyperswarm, no DeployServer.
    // Individual tests that need seeding can pass enableSeed: true.
    enableSeed: opts.enableSeed || false,
    passphrase: opts.passphrase || 'test-passphrase',
    ...opts
  })
  await sidecar.start()
  return { sidecar, root, storeRoot, registryStore, nameRegistryStore, releaseRegistryReplicasDir, sock }
}

async function makeClient (sock) {
  return Client.connectUds(sock)
}

// ── Lifecycle ────────────────────────────────────────────────

test('start creates identity + store dirs and opens UDS', async (t) => {
  const { sidecar, root, storeRoot, sock } = await makeSidecar()

  t.ok(fs.existsSync(path.join(root, 'identity')), 'identity dir exists')
  t.ok(sidecar.identity && sidecar.identity.publicKeyHex, 'identity loaded')
  t.is(sidecar.identity.publicKey.length, 32, 'pubkey 32 bytes')
  t.is(sidecar.identity.secretKey.length, 64, 'secret 64 bytes')
  // Only the raw/legacy fallback writes <root>/identity/identity.json (mode
  // 0o600). A hyperident vault lives at ~/.hyperident/, and a LINKED device
  // reads ~/.pgh/linked-device.json — neither writes a local keypair.
  //
  // This was `!== 'hyperident'`, which made the assertion fire for
  // source === 'linked' too and failed on any machine that has ever run
  // `pgh device join` (i.e. every mirror, and tabertron). Pre-existing and
  // environment-dependent — it fails identically at 0.2.52.
  if (sidecar.identity.source === 'raw' || sidecar.identity.source === 'legacy') {
    t.ok(fs.existsSync(path.join(root, 'identity', 'identity.json')),
      'raw keypair on disk')
  }
  t.ok(fs.existsSync(path.join(storeRoot, 'dependencies')), 'tree store ready')
  t.ok(fs.existsSync(sock), 'UDS socket listening')

  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('start twice is an error', async (t) => {
  const { sidecar, root, storeRoot } = await makeSidecar()
  let err
  try { await sidecar.start() } catch (e) { err = e }
  t.ok(err, 'second start rejected')

  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('stop is idempotent', async (t) => {
  const { sidecar, root, storeRoot } = await makeSidecar()
  await sidecar.stop()
  await sidecar.stop() // second stop shouldn't throw
  t.pass('second stop ok')

  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

// ── RPC handlers ─────────────────────────────────────────────

test('ping RPC', async (t) => {
  const { sidecar, root, storeRoot, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const res = await client.call('ping', {})
  t.ok(res.ok)
  t.is(res.sidecar, 'pgh-sidecar')
  t.ok(res.version)
  t.is(typeof res.uptime, 'number')

  await client.close()
  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('identity-info RPC returns a device pubkey', async (t) => {
  const { sidecar, root, storeRoot, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const res = await client.call('identity-info', {})
  t.ok(res.devicePubkey)
  t.is(res.devicePubkey.length, 64, 'hex 32 bytes')
  t.is(res.method, 'ed25519')

  await client.close()
  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('identity-info — same passphrase produces same device pubkey across restarts', async (t) => {
  const root = mktemp('sidecar-root')
  const storeRoot = mktemp('sidecar-store')
  const registryStore = mktemp('sidecar-registry')
  const nameRegistryStore = mktemp('sidecar-names')
  const releaseRegistryReplicasDir = mktemp('sidecar-replicas')
  const sock = path.join(root, 'svc.sock')
  const baseOpts = {
    root, socketPath: sock, storeRoot, registryStore,
    nameRegistryStore, releaseRegistryReplicasDir,
    enableSeed: false, passphrase: 'same-pass'
  }

  const s1 = new Sidecar(baseOpts)
  await s1.start()
  const c1 = await makeClient(sock)
  const first = await c1.call('identity-info', {})
  await c1.close()
  await s1.stop()

  const s2 = new Sidecar(baseOpts)
  await s2.start()
  const c2 = await makeClient(sock)
  const second = await c2.call('identity-info', {})
  await c2.close()
  await s2.stop()

  t.is(first.devicePubkey, second.devicePubkey, 'identity persists across restarts')

  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
  fs.rmSync(registryStore, { recursive: true, force: true })
})

test('store-stats RPC reports empty store initially', async (t) => {
  const { sidecar, root, storeRoot, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const res = await client.call('store-stats', {})
  t.is(res.treeCount, 0)
  t.is(res.totalBytes, 0)

  await client.close()
  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('store-list RPC — returns added trees', async (t) => {
  const { sidecar, root, storeRoot, sock } = await makeSidecar()
  const client = await makeClient(sock)

  // Seed a tree directly into the store for visibility via the RPC
  const fakeHash = crypto.randomBytes(32).toString('hex')
  fs.mkdirSync(sidecar.treeStore.treePath(fakeHash), { recursive: true })
  sidecar.treeStore._writeMeta(fakeHash, { pulledAt: Date.now() })
  sidecar.treeStore.addRef(fakeHash, 'test-app')

  const res = await client.call('store-list', {})
  t.is(res.length, 1)
  t.is(res[0].hash, fakeHash)
  t.alike(res[0].refs, ['test-app'])

  await client.close()
  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('stubbed RPCs (check-updates / device-invite) return implemented:false', async (t) => {
  const { sidecar, root, storeRoot, sock } = await makeSidecar()
  const client = await makeClient(sock)

  for (const method of ['check-updates', 'device-invite']) {
    const res = await client.call(method, {})
    t.absent(res.implemented, method + ' is still stubbed')
    t.ok(res.note, method + ' includes a note on what phase wires it')
  }

  await client.close()
  await sidecar.stop()
  fs.rmSync(root, { recursive: true, force: true })
  fs.rmSync(storeRoot, { recursive: true, force: true })
})

test('registry-info / publish-release / releases-list round-trip', async (t) => {
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const info = await client.call('registry-info', {})
  t.ok(info.keyHex && info.keyHex.length === 64, 'registry key exposed')
  t.ok(info.writable, 'registry writable (sidecar owns it)')

  await client.call('publish-release', {
    entry: {
      name: 'test-app',
      version: '1.0.0',
      treeHash: 'abc123',
      publishedAt: Date.now()
    }
  })

  const list = await client.call('releases-list', {})
  t.is(list.length, 1, 'one release')
  t.is(list[0].name, 'test-app')
  t.is(list[0].version, '1.0.0')

  const latest = await client.call('releases-latest', { name: 'test-app' })
  t.is(latest.version, '1.0.0', 'latest resolves')

  const get = await client.call('releases-get', { name: 'test-app', version: '1.0.0' })
  t.is(get.treeHash, 'abc123', 'get by name+version')

  const missing = await client.call('releases-latest', { name: 'does-not-exist' })
  t.is(missing, null, 'unknown name returns null')

  await client.close()
  await sidecar.stop()
})

test('releases-summary omits the dep closure', async (t) => {
  // Every registry entry embeds its full dep closure. Measured on prod
  // 2026-08-07, ONE pear-git-v3 entry is ~120 KB (285 deps x per-arch prebuild
  // hash maps), so `releases-list` never returns across many versions —
  // `pgh releases list pear-git-v3` times out at 60s. releases-summary exists so
  // a caller that only DISPLAYS releases does not pay for the closure.
  //
  // PASS = summary carries identity + provenance + depCount, carries NO deps,
  // and is dramatically smaller than the same entry from releases-list.
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  // A closure big enough that "did we actually drop it" is unambiguous.
  const deps = []
  for (let i = 0; i < 200; i++) {
    deps.push({
      name: 'dep-' + i,
      version: '1.0.0',
      tree: 'f'.repeat(64),
      prebuilds: {
        'linux-x64': 'a'.repeat(64),
        'linux-arm64': 'b'.repeat(64),
        'darwin-arm64': 'c'.repeat(64)
      }
    })
  }

  await client.call('publish-release', {
    entry: {
      name: 'fat-app',
      version: '2.0.0',
      treeHash: 'deadbeef',
      upgrade: 'pgh://reg/fat-app',
      publisherDevicePubkey: 'pub123',
      deps,
      publishedAt: Date.now()
    }
  })

  const summary = await client.call('releases-summary', {})
  t.is(summary.length, 1, 'one entry')
  const s = summary[0]
  t.is(s.name, 'fat-app')
  t.is(s.version, '2.0.0')
  t.is(s.treeHash, 'deadbeef', 'treeHash retained — it is the install identity')
  t.is(s.upgrade, 'pgh://reg/fat-app', 'upgrade URL retained')
  t.is(s.publisherDevicePubkey, 'pub123', 'provenance retained')
  t.is(s.depCount, 200, 'closure SIZE reported without the closure')
  t.absent(s.deps, 'deps dropped — this is the whole point')

  const full = await client.call('releases-list', {})
  t.is(full[0].deps.length, 200, 'releases-list still carries deps for install paths')

  const sBytes = JSON.stringify(summary).length
  const fBytes = JSON.stringify(full).length
  t.ok(sBytes * 20 < fBytes, 'summary is >20x smaller (' + sBytes + ' vs ' + fBytes + ' bytes)')

  const filtered = await client.call('releases-summary', { name: 'nope' })
  t.is(filtered.length, 0, 'name filter applies')

  await client.close()
  await sidecar.stop()
})

test('seed-status reports enabled:false when enableSeed:false', async (t) => {
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const s = await client.call('seed-status', {})
  t.is(s.enabled, false)
  t.is(s.deployServer, 'off')
  t.is(s.registrySwarm, 'off')

  await client.close()
  await sidecar.stop()
})

test('name-registry-info exposes a writable registry on fresh sidecar', async (t) => {
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const info = await client.call('name-registry-info', {})
  t.ok(info.keyHex && info.keyHex.length === 64, 'name registry key exposed')
  t.ok(info.writable, 'sidecar is writable (owns the autobase)')

  await client.close()
  await sidecar.stop()
})

test('claim-name / resolve-name / list-names round-trip', async (t) => {
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  const claimed = await client.call('claim-name', {
    name: 'pear-git-v3',
    description: 'p2p git forge'
  })
  t.is(claimed.name, 'pear-git-v3')
  t.ok(claimed.publisher.devicePubkey, 'publisher.devicePubkey populated')
  t.is(claimed.description, 'p2p git forge')

  const resolved = await client.call('resolve-name', { name: 'pear-git-v3' })
  t.is(resolved.name, 'pear-git-v3', 'resolves the claimed name')
  t.is(resolved.publisher.devicePubkey, claimed.publisher.devicePubkey)

  const missing = await client.call('resolve-name', { name: 'not-claimed' })
  t.is(missing, null, 'unclaimed → null')

  const list = await client.call('list-names', {})
  t.is(list.length, 1)
  t.is(list[0].name, 'pear-git-v3')

  await client.close()
  await sidecar.stop()
})

// _notifyAppsOfNewReleases: same version + different treeHash must NOT
// trigger reinstall. The pre-0.2.7 behaviour was to oscillate between
// two replicas advertising the same version with different trees, each
// install rmSync'ing the live install dir. Repro:
//   - register an app at version 0.1.51 with on-disk treeHash A
//   - publish into the local registry version 0.1.51 with treeHash B
//   - run _notifyAppsOfNewReleases
//   - expect: zero install calls, _lastNotifiedVersion populated
test('autoupdate dedupe — same version, different treeHash skips install', async (t) => {
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  // Register a fake app so it shows up as a candidate.
  await client.call('app-register', {
    name: 'demo-app',
    udsPath: path.join(os.tmpdir(), 'unused.sock'),
    pid: process.pid,
    runtime: 'node',
    version: '0.1.51',
    treeHash: 'a'.repeat(64) // currentTreeHash
  })

  // Same version, different treeHash — the cross-replica scenario.
  await client.call('publish-release', {
    entry: {
      name: 'demo-app',
      version: '0.1.51',
      treeHash: 'b'.repeat(64),
      publishedAt: Date.now()
    }
  })

  // Spy on the install path.
  let installCalls = 0
  sidecar._installRelease = async () => { installCalls++ }

  await sidecar._notifyAppsOfNewReleases()
  t.is(installCalls, 0, 'no install triggered for same-version different-tree')
  t.is(
    sidecar._lastNotifiedVersion.get('demo-app'),
    '0.1.51@' + 'b'.repeat(64),
    '_lastNotifiedVersion populated so subsequent ticks dedupe'
  )

  // Sanity: a real version bump still triggers install.
  await client.call('publish-release', {
    entry: {
      name: 'demo-app',
      version: '0.1.52',
      treeHash: 'c'.repeat(64),
      publishedAt: Date.now() + 1
    }
  })
  await sidecar._notifyAppsOfNewReleases()
  t.is(installCalls, 1, 'version bump triggers install')

  await client.close()
  await sidecar.stop()
})

test('release-name tombstones; resolve returns null; reclaim works', async (t) => {
  const { sidecar, sock } = await makeSidecar()
  const client = await makeClient(sock)

  await client.call('claim-name', { name: 'transient' })
  t.ok(await client.call('resolve-name', { name: 'transient' }), 'claimed')

  await client.call('release-name', { name: 'transient' })
  t.is(await client.call('resolve-name', { name: 'transient' }), null, 'tombstoned')

  // Reclaim by same publisher (there's only one writer in this test)
  await client.call('claim-name', { name: 'transient', description: 'round 2' })
  const reclaimed = await client.call('resolve-name', { name: 'transient' })
  t.ok(reclaimed, 'reclaim succeeded')
  t.is(reclaimed.description, 'round 2')

  await client.close()
  await sidecar.stop()
})