/**
* pgh publish tests — P.10 Phase 0.5d
*
* Exercises:
* - computeLockfile walks node_modules/, gets per-dep tree hashes
* - writeLockfile / readLockfile round-trip
* - PublishedIndex persists + lists
* - detectConflicts catches version mismatches + build skew
* - formatConflicts produces loud, actionable output
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { computeLockfile, writeLockfile, readLockfile } from '../lib/publish.js'
import { PublishedIndex } from '../lib/published-index.js'
import { detectConflicts, formatConflicts, CONFLICT_VERSION, CONFLICT_BUILD_SKEW } from '../lib/conflict.js'
function mktemp (prefix = 'pgh-publish') {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}
function makeApp ({ name, version, deps = [] }) {
const dir = mktemp('app-' + name)
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version }))
fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("' + name + '")')
if (deps.length > 0) {
const nmDir = path.join(dir, 'node_modules')
fs.mkdirSync(nmDir)
for (const d of deps) {
const depDir = path.join(nmDir, d.name)
fs.mkdirSync(depDir)
fs.writeFileSync(path.join(depDir, 'package.json'), JSON.stringify({ name: d.name, version: d.version }))
fs.writeFileSync(path.join(depDir, 'index.js'), d.content || '// ' + d.name + ' ' + d.version)
}
}
return dir
}
// ── computeLockfile ──────────────────────────────────────────
test('computeLockfile — bare app with no deps', async (t) => {
const dir = makeApp({ name: 'lonely', version: '0.1.0' })
const lock = await computeLockfile(dir)
t.is(lock.name, 'lonely')
t.is(lock.version, '0.1.0')
t.ok(lock.rootTreeHash)
t.is(lock.rootTreeHash.length, 64, 'hex hash')
t.alike(lock.deps, [])
t.ok(lock.publishedAt)
fs.rmSync(dir, { recursive: true, force: true })
})
test('computeLockfile — app with flat deps', async (t) => {
const dir = makeApp({
name: 'app', version: '1.0.0',
deps: [
{ name: 'alpha', version: '1.0.0' },
{ name: 'beta', version: '2.1.0' }
]
})
const lock = await computeLockfile(dir)
t.is(lock.deps.length, 2)
const alpha = lock.deps.find(d => d.name === 'alpha')
const beta = lock.deps.find(d => d.name === 'beta')
t.is(alpha.version, '1.0.0')
t.ok(alpha.tree, 'alpha has tree hash')
t.is(beta.version, '2.1.0')
t.ok(beta.tree, 'beta has tree hash')
t.unlike(alpha.tree, beta.tree, 'different deps have different tree hashes')
fs.rmSync(dir, { recursive: true, force: true })
})
test('computeLockfile — scoped @foo/bar packages resolved one level deeper', async (t) => {
const dir = mktemp('scoped')
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'scoped-app', version: '0.0.1' }))
const nm = path.join(dir, 'node_modules')
const scope = path.join(nm, '@myorg')
fs.mkdirSync(scope, { recursive: true })
const dep = path.join(scope, 'thing')
fs.mkdirSync(dep)
fs.writeFileSync(path.join(dep, 'package.json'), JSON.stringify({ name: '@myorg/thing', version: '1.0.0' }))
fs.writeFileSync(path.join(dep, 'index.js'), 'x')
const lock = await computeLockfile(dir)
t.is(lock.deps.length, 1)
t.is(lock.deps[0].name, '@myorg/thing')
t.is(lock.deps[0].version, '1.0.0')
t.ok(lock.deps[0].tree)
fs.rmSync(dir, { recursive: true, force: true })
})
test('computeLockfile — identical deps across apps produce identical tree hashes', async (t) => {
const app1 = makeApp({
name: 'one', version: '0.1.0',
deps: [{ name: 'shared', version: '1.0.0', content: 'exports.x = 1' }]
})
const app2 = makeApp({
name: 'two', version: '0.1.0',
deps: [{ name: 'shared', version: '1.0.0', content: 'exports.x = 1' }]
})
const lock1 = await computeLockfile(app1)
const lock2 = await computeLockfile(app2)
const shared1 = lock1.deps.find(d => d.name === 'shared')
const shared2 = lock2.deps.find(d => d.name === 'shared')
t.is(shared1.tree, shared2.tree, 'same shared dep content → same tree hash')
fs.rmSync(app1, { recursive: true, force: true })
fs.rmSync(app2, { recursive: true, force: true })
})
test('writeLockfile + readLockfile round-trip', async (t) => {
const dir = makeApp({ name: 'rt', version: '0.0.1' })
const lock = await computeLockfile(dir)
const p = writeLockfile(dir, lock)
t.ok(fs.existsSync(p))
const back = readLockfile(dir)
t.is(back.name, lock.name)
t.is(back.rootTreeHash, lock.rootTreeHash)
fs.rmSync(dir, { recursive: true, force: true })
})
// ── PublishedIndex ──────────────────────────────────────────
test('PublishedIndex — empty by default', (t) => {
const tmp = mktemp('idx')
const idx = new PublishedIndex({ path: path.join(tmp, 'idx.json') })
t.alike(idx.list(), [])
fs.rmSync(tmp, { recursive: true, force: true })
})
test('PublishedIndex — add, list, re-add replaces same version', async (t) => {
const tmp = mktemp('idx')
const idx = new PublishedIndex({ path: path.join(tmp, 'idx.json') })
const appDir = makeApp({ name: 'app', version: '1.0.0' })
const lock = await computeLockfile(appDir)
idx.add(lock)
const list = idx.list()
t.is(list.length, 1)
t.is(list[0].name, 'app')
t.is(list[0].version, '1.0.0')
// Re-publishing the same version overwrites the entry (not append)
idx.add(lock)
t.is(idx.list().length, 1, 're-publish replaces rather than duplicating')
fs.rmSync(tmp, { recursive: true, force: true })
fs.rmSync(appDir, { recursive: true, force: true })
})
test('PublishedIndex — persists across instances', async (t) => {
const tmp = mktemp('idx')
const p = path.join(tmp, 'idx.json')
const idx1 = new PublishedIndex({ path: p })
const appDir = makeApp({ name: 'persist', version: '0.1.0' })
const lock = await computeLockfile(appDir)
idx1.add(lock)
const idx2 = new PublishedIndex({ path: p })
const list = idx2.list()
t.is(list.length, 1)
t.is(list[0].name, 'persist')
fs.rmSync(tmp, { recursive: true, force: true })
fs.rmSync(appDir, { recursive: true, force: true })
})
// ── Conflict detection ──────────────────────────────────────
test('detectConflicts — no conflicts when deps agree', async (t) => {
const tmp = mktemp('idx')
const idx = new PublishedIndex({ path: path.join(tmp, 'idx.json') })
const appA = makeApp({
name: 'a', version: '1.0.0',
deps: [{ name: 'shared', version: '1.0.0', content: 'same' }]
})
const appB = makeApp({
name: 'b', version: '1.0.0',
deps: [{ name: 'shared', version: '1.0.0', content: 'same' }]
})
const lockA = await computeLockfile(appA)
const lockB = await computeLockfile(appB)
idx.add(lockA)
const conflicts = detectConflicts(lockB, idx)
t.is(conflicts.length, 0, 'identical shared deps produce zero conflicts')
fs.rmSync(tmp, { recursive: true, force: true })
fs.rmSync(appA, { recursive: true, force: true })
fs.rmSync(appB, { recursive: true, force: true })
})
test('detectConflicts — VERSION_CONFLICT when two apps pin different versions', async (t) => {
const tmp = mktemp('idx')
const idx = new PublishedIndex({ path: path.join(tmp, 'idx.json') })
const appA = makeApp({
name: 'a', version: '1.0.0',
deps: [{ name: 'shared', version: '1.0.0' }]
})
const appB = makeApp({
name: 'b', version: '1.0.0',
deps: [{ name: 'shared', version: '2.0.0' }]
})
idx.add(await computeLockfile(appA))
const conflicts = detectConflicts(await computeLockfile(appB), idx)
t.is(conflicts.length, 1)
t.is(conflicts[0].type, CONFLICT_VERSION)
t.is(conflicts[0].dep, 'shared')
t.is(conflicts[0].newVersion, '2.0.0')
t.is(conflicts[0].existing[0].appName, 'a')
t.is(conflicts[0].existing[0].version, '1.0.0')
fs.rmSync(tmp, { recursive: true, force: true })
fs.rmSync(appA, { recursive: true, force: true })
fs.rmSync(appB, { recursive: true, force: true })
})
// ── Multi-version sets (name@version dedup) ──────────────────────────
// computeLockfile records EVERY distinct version of a dep (npm hoisting
// leaves several), so detectConflicts compares version SETS, not a single
// lossy representative. These guard the phantom-conflict fix.
test('detectConflicts — shared version across multi-version sets is NOT a conflict', (t) => {
// The new app ships bare-net at BOTH 1.0.1 (top-level) and 2.3.1 (nested);
// the rival recorded only 2.3.1. They share 2.3.1 → compatible, no conflict.
// (Before the fix, the lockfiles recorded different single representatives
// and this looked like a version conflict.)
const newLockfile = {
name: 'new',
deps: [
{ name: 'bare-net', version: '1.0.1', tree: 'tA' },
{ name: 'bare-net', version: '2.3.1', tree: 'tB' }
]
}
const index = {
allLockfiles: () => [
{ appName: 'rival', lockfile: { deps: [{ name: 'bare-net', version: '2.3.1', tree: 'tB' }] } }
]
}
t.is(detectConflicts(newLockfile, index).length, 0, 'overlapping version sets → no conflict')
})
test('detectConflicts — disjoint version sets ARE a version conflict', (t) => {
const newLockfile = { name: 'new', deps: [{ name: 'x', version: '1.0.0', tree: 'tA' }] }
const index = { allLockfiles: () => [
{ appName: 'rival', lockfile: { deps: [{ name: 'x', version: '2.0.0', tree: 'tB' }] } }
] }
const conflicts = detectConflicts(newLockfile, index)
t.is(conflicts.length, 1)
t.is(conflicts[0].type, CONFLICT_VERSION)
t.is(conflicts[0].dep, 'x')
})
test('detectConflicts — BUILD_SKEW fires on a shared version inside multi-version sets', (t) => {
const newLockfile = { name: 'new', deps: [
{ name: 'x', version: '1.0.0', tree: 'tA' },
{ name: 'x', version: '2.0.0', tree: 'tB' }
] }
const index = { allLockfiles: () => [
{ appName: 'rival', lockfile: { deps: [{ name: 'x', version: '2.0.0', tree: 'tDIFFERENT' }] } }
] }
const conflicts = detectConflicts(newLockfile, index)
t.is(conflicts.length, 1)
t.is(conflicts[0].type, CONFLICT_BUILD_SKEW)
t.is(conflicts[0].newVersion, '2.0.0', 'skew reported for the shared version')
})
test('detectConflicts — BUILD_SKEW when same version has different tree hash', async (t) => {
const tmp = mktemp('idx')
const idx = new PublishedIndex({ path: path.join(tmp, 'idx.json') })
const appA = makeApp({
name: 'a', version: '1.0.0',
deps: [{ name: 'shared', version: '1.0.0', content: 'build-variant-1' }]
})
const appB = makeApp({
name: 'b', version: '1.0.0',
deps: [{ name: 'shared', version: '1.0.0', content: 'build-variant-2' }]
})
idx.add(await computeLockfile(appA))
const conflicts = detectConflicts(await computeLockfile(appB), idx)
t.is(conflicts.length, 1)
t.is(conflicts[0].type, CONFLICT_BUILD_SKEW)
t.is(conflicts[0].dep, 'shared')
t.is(conflicts[0].newVersion, '1.0.0')
fs.rmSync(tmp, { recursive: true, force: true })
fs.rmSync(appA, { recursive: true, force: true })
fs.rmSync(appB, { recursive: true, force: true })
})
test('detectConflicts — self-publishes (new version of same app) are not conflicts', async (t) => {
const tmp = mktemp('idx')
const idx = new PublishedIndex({ path: path.join(tmp, 'idx.json') })
const v1 = makeApp({
name: 'app', version: '1.0.0',
deps: [{ name: 'shared', version: '1.0.0' }]
})
const v2 = makeApp({
name: 'app', version: '2.0.0',
deps: [{ name: 'shared', version: '2.0.0' }]
})
idx.add(await computeLockfile(v1))
const conflicts = detectConflicts(await computeLockfile(v2), idx)
t.is(conflicts.length, 0, 'upgrading my own app is not a conflict')
fs.rmSync(tmp, { recursive: true, force: true })
fs.rmSync(v1, { recursive: true, force: true })
fs.rmSync(v2, { recursive: true, force: true })
})
test('formatConflicts — empty input produces empty string', (t) => {
t.is(formatConflicts([], 'anything'), '')
})
test('formatConflicts — version conflict output mentions both apps + fix', (t) => {
const out = formatConflicts([{
type: CONFLICT_VERSION,
dep: 'shared',
newVersion: '2.0.0',
newTree: 'a'.repeat(64),
existing: [{ appName: 'other-app', version: '1.0.0', tree: 'b'.repeat(64) }]
}], 'my-app')
t.ok(out.includes('my-app'), 'names the publishing app')
t.ok(out.includes('other-app'), 'names the conflicting app')
t.ok(out.includes('shared'), 'names the dep')
t.ok(out.includes('1.0.0'))
t.ok(out.includes('2.0.0'))
t.ok(out.includes('--accept-conflict'), 'shows override path')
t.ok(out.includes('Fix:'), 'suggests a fix')
})
test('formatConflicts — build skew output calls out non-determinism', (t) => {
const out = formatConflicts([{
type: CONFLICT_BUILD_SKEW,
dep: 'shared',
newVersion: '1.0.0',
newTree: 'a'.repeat(64),
existing: [{ appName: 'other', version: '1.0.0', tree: 'b'.repeat(64) }]
}], 'mine')
t.ok(out.includes('NON-DETERMINISTIC'), 'flags the class of error')
t.ok(out.includes('timestamps'))
t.ok(out.includes('absolute paths'))
})