OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / pgh-tree-install.test.js

266 lines · javascript ref:
/**
 * tree-install tests — recursive materialization + app symlinking + swap
 */

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 { installTree, linkApp, unlinkApp, swapApp } from '../lib/tree-install.js'
import { KIND_FILE, KIND_DIR, computeTreeHash } from 'hyper-overlay/tree'

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

function hashBytes (b) { return crypto.data(b).toString('hex') }

/**
 * Build an app fixture: returns { rootHash, fetcher }.
 * The fetcher holds an in-memory map of trees + content.
 */
function fixture (layout) {
  const trees = {} // treeHash → { entries, size }
  const contents = {} // contentHash → Buffer

  function build (node) {
    if (node.type === 'file') {
      const buf = Buffer.from(node.content)
      const h = hashBytes(buf)
      contents[h] = buf
      return { kind: KIND_FILE, exec: node.exec ? 1 : 0, name: node.name, childHash: h, size: buf.length }
    }
    if (node.type === 'dir') {
      const childEntries = node.children.map(build)
      const subHash = computeTreeHash(childEntries)
      trees[subHash] = { entries: childEntries, size: childEntries.reduce((a, c) => a + c.size, 0) }
      return { kind: KIND_DIR, exec: 0, name: node.name, childHash: subHash, size: trees[subHash].size }
    }
  }

  const rootEntries = layout.map(build)
  const rootHash = computeTreeHash(rootEntries)
  trees[rootHash] = { entries: rootEntries, size: rootEntries.reduce((a, c) => a + c.size, 0) }

  const fetcher = {
    async getTree (h) {
      if (!trees[h]) throw new Error('no tree: ' + h)
      return trees[h]
    },
    async getContent (h) {
      if (!contents[h]) throw new Error('no content: ' + h)
      return contents[h]
    }
  }
  return { rootHash, fetcher, trees, contents }
}

// ── Tests ─────────────────────────────────────────────────────

test('installTree — flat app, single file', async (t) => {
  const root = mktemp('store')
  const store = new TreeStore({ root })

  const { rootHash, fetcher } = fixture([
    { type: 'file', name: 'index.js', content: 'console.log(1)' }
  ])

  const result = await installTree(store, rootHash, fetcher)
  t.is(result.materialized, 1)
  t.ok(store.hasTree(rootHash))
  const file = path.join(store.treePath(rootHash), 'index.js')
  t.is(fs.readFileSync(file, 'utf8'), 'console.log(1)')

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

test('installTree — nested app with subtrees', async (t) => {
  const root = mktemp('store')
  const store = new TreeStore({ root })

  const { rootHash, fetcher, trees } = fixture([
    { type: 'file', name: 'index.js', content: 'root' },
    { type: 'dir', name: 'lib', children: [
      { type: 'file', name: 'util.js', content: 'u' },
      { type: 'file', name: 'helper.js', content: 'h' }
    ]},
    { type: 'dir', name: 'assets', children: [
      { type: 'file', name: 'logo.txt', content: 'L' }
    ]}
  ])

  const result = await installTree(store, rootHash, fetcher)
  // installTree now materializes each top-level tree as a single
  // self-contained dir; subtree content is inlined by store.materialize,
  // not given its own dependencies/<hash>/ dir. So one tree in = one
  // materialized top-level tree out.
  t.is(result.materialized, 1, 'one top-level tree materialized')

  t.is(fs.readFileSync(path.join(store.treePath(rootHash), 'index.js'), 'utf8'), 'root')
  t.is(fs.readFileSync(path.join(store.treePath(rootHash), 'lib', 'util.js'), 'utf8'), 'u')
  t.is(fs.readFileSync(path.join(store.treePath(rootHash), 'assets', 'logo.txt'), 'utf8'), 'L')

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

test('installTree — skips already-materialized trees', async (t) => {
  const root = mktemp('store')
  const store = new TreeStore({ root })

  const { rootHash, fetcher } = fixture([
    { type: 'file', name: 'a.txt', content: 'a' }
  ])

  const first = await installTree(store, rootHash, fetcher)
  t.is(first.materialized, 1)
  t.is(first.skipped, 0)

  const second = await installTree(store, rootHash, fetcher)
  t.is(second.materialized, 0)
  t.is(second.skipped, 1)

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

test('linkApp — creates symlink in appsDir pointing at store tree', async (t) => {
  const root = mktemp('store')
  const appsDir = mktemp('apps')
  const store = new TreeStore({ root })

  const { rootHash, fetcher } = fixture([{ type: 'file', name: 'a', content: 'x' }])
  await installTree(store, rootHash, fetcher)

  const { installPath, target } = linkApp(store, 'my-app', rootHash, appsDir)
  t.is(installPath, path.join(appsDir, 'my-app'))
  t.is(target, store.treePath(rootHash))

  const stat = fs.lstatSync(installPath)
  t.ok(stat.isSymbolicLink(), 'is a symlink')
  t.is(fs.readlinkSync(installPath), target)

  // Refcount incremented
  t.alike(store.getRefs(rootHash), ['my-app'])

  // File visible through symlink
  t.is(fs.readFileSync(path.join(installPath, 'a'), 'utf8'), 'x')

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

test('linkApp — refuses to overwrite existing path', async (t) => {
  const root = mktemp('store')
  const appsDir = mktemp('apps')
  const store = new TreeStore({ root })

  const { rootHash, fetcher } = fixture([{ type: 'file', name: 'a', content: 'x' }])
  await installTree(store, rootHash, fetcher)
  linkApp(store, 'my-app', rootHash, appsDir)

  t.exception(() => linkApp(store, 'my-app', rootHash, appsDir), /already exists/)

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

test('unlinkApp — removes symlink, decrements refcount', async (t) => {
  const root = mktemp('store')
  const appsDir = mktemp('apps')
  const store = new TreeStore({ root })

  const { rootHash, fetcher } = fixture([{ type: 'file', name: 'a', content: 'x' }])
  await installTree(store, rootHash, fetcher)
  linkApp(store, 'my-app', rootHash, appsDir)
  t.alike(store.getRefs(rootHash), ['my-app'])

  const result = unlinkApp(store, 'my-app', appsDir)
  t.ok(result.removed)
  t.is(result.rootHash, rootHash)
  t.alike(store.getRefs(rootHash), [])
  t.absent(fs.existsSync(path.join(appsDir, 'my-app')))
  // Tree itself still present in store (GC decides later)
  t.ok(store.hasTree(rootHash))

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

test('swapApp — updates symlink target, adjusts refcounts', async (t) => {
  const root = mktemp('store')
  const appsDir = mktemp('apps')
  const store = new TreeStore({ root })

  const v1 = fixture([{ type: 'file', name: 'v', content: '1' }])
  const v2 = fixture([{ type: 'file', name: 'v', content: '2' }])
  await installTree(store, v1.rootHash, v1.fetcher)
  await installTree(store, v2.rootHash, v2.fetcher)

  linkApp(store, 'my-app', v1.rootHash, appsDir)
  t.alike(store.getRefs(v1.rootHash), ['my-app'])

  const swap = swapApp(store, 'my-app', v2.rootHash, appsDir)
  t.is(swap.previous, v1.rootHash)
  t.is(swap.current, v2.rootHash)

  t.alike(store.getRefs(v1.rootHash), [], 'v1 refcount removed')
  t.alike(store.getRefs(v2.rootHash), ['my-app'], 'v2 refcount added')

  // Content visible through symlink is now v2
  t.is(fs.readFileSync(path.join(appsDir, 'my-app', 'v'), 'utf8'), '2')

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

test('swapApp — REFUSES a hydrated (real-directory) install, leaves it intact', async (t) => {
  const root = mktemp('store')
  const appsDir = mktemp('apps')
  const store = new TreeStore({ root })

  const v1 = fixture([{ type: 'file', name: 'v', content: '1' }])
  await installTree(store, v1.rootHash, v1.fetcher)

  // Simulate a HYDRATED install: a real directory with a materialized
  // node_modules (what `pgh app install`/`adopt` produces), NOT a symlink.
  const installPath = path.join(appsDir, 'my-app')
  fs.mkdirSync(path.join(installPath, 'node_modules', 'dep'), { recursive: true })
  fs.writeFileSync(path.join(installPath, 'index.js'), 'console.log(1)')

  // swapApp would otherwise replace it with a symlink to a bare root tree
  // (no node_modules) — bricking the app. It must refuse instead.
  t.exception(() => swapApp(store, 'my-app', v1.rootHash, appsDir), /hydrated|materialized/i)

  // And the hydrated install must be untouched (not deleted/replaced).
  t.ok(fs.lstatSync(installPath).isDirectory(), 'install still a real directory')
  t.ok(fs.existsSync(path.join(installPath, 'node_modules', 'dep')), 'node_modules preserved')
  t.is(fs.readFileSync(path.join(installPath, 'index.js'), 'utf8'), 'console.log(1)')

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

test('swapApp — rollback swaps back to a previously-seen tree instantly', async (t) => {
  const root = mktemp('store')
  const appsDir = mktemp('apps')
  const store = new TreeStore({ root })

  const v1 = fixture([{ type: 'file', name: 'v', content: '1' }])
  const v2 = fixture([{ type: 'file', name: 'v', content: '2' }])
  await installTree(store, v1.rootHash, v1.fetcher)
  await installTree(store, v2.rootHash, v2.fetcher)

  linkApp(store, 'my-app', v1.rootHash, appsDir)
  swapApp(store, 'my-app', v2.rootHash, appsDir)
  // Rollback to v1 — tree is still in store
  const rollback = swapApp(store, 'my-app', v1.rootHash, appsDir)
  t.is(rollback.previous, v2.rootHash)
  t.is(rollback.current, v1.rootHash)
  t.is(fs.readFileSync(path.join(appsDir, 'my-app', 'v'), 'utf8'), '1')

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