OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / pgh-app-tree.test.js

214 lines · javascript ref:
/**
 * pgh app tree-based install/update/rollback CLI tests — 0.5f
 *
 * Uses the default TreeStore location (~/.local/share/store/dependencies)
 * and apps dir (~/.local/share/store/apps/<name>) — we restore after each
 * test to avoid touching real user state.
 */

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 { run } from '../lib/commands/app.js'
import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../lib/store.js'
import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree'

// NOTE: pgh app commands default to ~/.local/share and ~/.local/bin. We
// don't try to relocate those — instead, we use UNIQUE random names so
// tests never collide with real app installs, and we clean up on exit.

function uniqueName (label) {
  return 'pgh-test-' + label + '-' + crypto.randomBytes(4).toString('hex')
}

function seedTree (store, entries) {
  const hash = computeTreeHash(entries)
  if (!store.hasTree(hash)) {
    fs.mkdirSync(store.treePath(hash), { recursive: true })
    for (const e of entries) {
      if (e.kind === KIND_FILE) {
        // Content isn't required for the CLI tests — the store just needs the dir to exist.
        fs.writeFileSync(path.join(store.treePath(hash), e.name), e.content || '')
      }
    }
    store._writeMeta(hash, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })
  }
  return hash
}

async function cleanup (name) {
  const APPS_DIR = path.join(DEFAULT_STORE_ROOT, 'apps')
  const BIN_DIR = path.join(os.homedir(), '.local', 'bin')
  try { fs.unlinkSync(path.join(APPS_DIR, name)) } catch {}
  try { fs.rmSync(path.join(APPS_DIR, name), { recursive: true, force: true }) } catch {}
  try { fs.unlinkSync(path.join(BIN_DIR, name)) } catch {}
}

// ── install-tree ─────────────────────────────────────────────

test('install-tree creates symlink + launcher, increments refcount', async (t) => {
  const name = uniqueName('inst')
  const store = new TreeStore()
  store.init()
  const hash = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'index.js', childHash: 'a'.repeat(64), size: 0, content: 'console.log(1)' }
  ])

  try {
    const result = await run(null, ['install-tree', name, '--tree', hash])
    t.is(result.installed, name)
    t.is(result.treeHash, hash)

    const installPath = path.join(DEFAULT_STORE_ROOT, 'apps', name)
    t.ok(fs.lstatSync(installPath).isSymbolicLink(), 'installed as symlink')
    t.is(fs.readlinkSync(installPath), store.treePath(hash))

    const launcher = path.join(os.homedir(), '.local', 'bin', name)
    t.ok(fs.existsSync(launcher))
    t.ok(fs.readFileSync(launcher, 'utf8').includes('bare'))

    t.alike(store.getRefs(hash), [name])
  } finally {
    await cleanup(name)
    store.removeRef(hash, name)
  }
})

test('install-tree errors if tree not in store', async (t) => {
  const name = uniqueName('missing')
  const absentHash = 'f'.repeat(64)
  let err
  try { await run(null, ['install-tree', name, '--tree', absentHash]) } catch (e) { err = e }
  t.ok(err)
  t.ok(err.message.includes('not in store'))
})

// ── update-tree ──────────────────────────────────────────────

test('update-tree swaps symlink + adjusts refcounts', async (t) => {
  const name = uniqueName('upd')
  const store = new TreeStore()
  store.init()
  const hashV1 = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'v', childHash: 'a'.repeat(64), size: 0, content: '1' }
  ])
  const hashV2 = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'v', childHash: 'b'.repeat(64), size: 0, content: '2' }
  ])

  try {
    await run(null, ['install-tree', name, '--tree', hashV1])
    t.alike(store.getRefs(hashV1), [name])

    const result = await run(null, ['update-tree', name, '--tree', hashV2])
    t.is(result.updated, name)
    t.is(result.previous, hashV1)
    t.is(result.current, hashV2)

    t.alike(store.getRefs(hashV1), [])
    t.alike(store.getRefs(hashV2), [name])

    const installPath = path.join(DEFAULT_STORE_ROOT, 'apps', name)
    t.is(fs.readlinkSync(installPath), store.treePath(hashV2))
  } finally {
    await cleanup(name)
    store.removeRef(hashV1, name)
    store.removeRef(hashV2, name)
  }
})

// ── rollback ─────────────────────────────────────────────────

test('rollback swaps back to a previous tree still in store', async (t) => {
  const name = uniqueName('roll')
  const store = new TreeStore()
  store.init()
  const v1 = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'a', childHash: '1'.repeat(64), size: 0, content: 'v1' }
  ])
  const v2 = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'a', childHash: '2'.repeat(64), size: 0, content: 'v2' }
  ])
  store.pin(v1) // keep v1 GC-safe so rollback works

  try {
    await run(null, ['install-tree', name, '--tree', v1])
    await run(null, ['update-tree', name, '--tree', v2])

    const result = await run(null, ['rollback', name, '--tree', v1])
    t.is(result.rolledBack, name)
    t.is(result.current, v1)

    const installPath = path.join(DEFAULT_STORE_ROOT, 'apps', name)
    t.is(fs.readlinkSync(installPath), store.treePath(v1))
  } finally {
    await cleanup(name)
    store.removeRef(v1, name)
    store.removeRef(v2, name)
    store.unpin(v1)
  }
})

test('rollback errors if target tree is no longer in store', async (t) => {
  const name = uniqueName('roll-miss')
  let err
  try {
    await run(null, ['rollback', name, '--tree', 'd'.repeat(64)])
  } catch (e) { err = e }
  t.ok(err)
  t.ok(err.message.includes('not in store'))
})

// ── current-tree ─────────────────────────────────────────────

test('current-tree reports the linked tree hash', async (t) => {
  const name = uniqueName('cur')
  const store = new TreeStore()
  store.init()
  const hash = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'a', childHash: 'c'.repeat(64), size: 0, content: '' }
  ])

  try {
    await run(null, ['install-tree', name, '--tree', hash])
    const result = await run(null, ['current-tree', name])
    t.is(result.name, name)
    t.is(result.treeHash, hash)
  } finally {
    await cleanup(name)
    store.removeRef(hash, name)
  }
})

test('current-tree reports installed:false for uninstalled apps', async (t) => {
  const result = await run(null, ['current-tree', uniqueName('ghost')])
  t.absent(result.installed)
})

// ── remove with refcount decrement ──────────────────────────

test('remove on a tree-installed app decrements refcount', async (t) => {
  const name = uniqueName('rm')
  const store = new TreeStore()
  store.init()
  const hash = seedTree(store, [
    { kind: KIND_FILE, exec: 0, name: 'a', childHash: 'e'.repeat(64), size: 0, content: '' }
  ])

  await run(null, ['install-tree', name, '--tree', hash])
  t.alike(store.getRefs(hash), [name])

  const result = await run(null, ['remove', name])
  t.is(result.removed, name)
  t.is(result.treeDecremented, hash)
  t.alike(store.getRefs(hash), [])

  // Tree itself still in store (GC handles it separately)
  t.ok(store.hasTree(hash))

  await cleanup(name)
})