OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / pgh-publish.test.js

372 lines · javascript ref:
/**
 * 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'))
})