/**
* 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()
})