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