|
| 1 | +// scanNpxStale / pruneNpxStale — the stale npx-env prune behind `ak sync`. |
| 2 | +// Uses a synthetic _npx fixture and an injected baseline, so the test is |
| 3 | +// hermetic (no npm, no network, never the machine's real cache). |
| 4 | +// |
| 5 | +// The invariant under test is the conservative prune rule: a stale env is |
| 6 | +// removed ONLY when every package it is keyed to is judgeable (managed + |
| 7 | +// installed baseline + readable cached copy) and at least one cached copy is |
| 8 | +// strictly older. Anything unjudgeable is left alone — misses must fail safe |
| 9 | +// as "not pruned", never as a wrong prune. |
| 10 | +import { test } from 'node:test'; |
| 11 | +import assert from 'node:assert/strict'; |
| 12 | +import fs from 'node:fs'; |
| 13 | +import os from 'node:os'; |
| 14 | +import path from 'node:path'; |
| 15 | +import { scanNpxStale, pruneNpxStale, managedBaseline } from '../../src/lib/npx.mjs'; |
| 16 | +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; |
| 17 | + |
| 18 | +// One npx env: <root>/<name>/package.json (keyed spec) + node_modules/<pkg> copies. |
| 19 | +function env(root, name, keyed, copies = {}) { |
| 20 | + const dir = path.join(root, name); |
| 21 | + fs.mkdirSync(dir, { recursive: true }); |
| 22 | + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: keyed })); |
| 23 | + for (const [pkg, version] of Object.entries(copies)) { |
| 24 | + const p = path.join(dir, 'node_modules', pkg); |
| 25 | + fs.mkdirSync(p, { recursive: true }); |
| 26 | + fs.writeFileSync(path.join(p, 'package.json'), JSON.stringify({ name: pkg, version })); |
| 27 | + } |
| 28 | + return dir; |
| 29 | +} |
| 30 | + |
| 31 | +const INSTALLED = { ruflo: '3.32.2', '@claude-flow/cli': '3.32.2', 'agentic-qe': '3.12.2' }; |
| 32 | +const baseline = (pkg) => INSTALLED[pkg] ?? null; |
| 33 | +const mkroot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-')); |
| 34 | + |
| 35 | +test('scan flags an env whose cached copy is strictly older than the baseline', () => { |
| 36 | + const root = mkroot(); |
| 37 | + const dir = env(root, 'aaa', { ruflo: '^3.21.1' }, { ruflo: '3.21.1' }); |
| 38 | + const found = scanNpxStale({ root, baseline }); |
| 39 | + assert.deepEqual(found, [{ dir, stale: [{ pkg: 'ruflo', installed: '3.32.2', cached: '3.21.1' }] }]); |
| 40 | + fs.rmSync(root, { recursive: true, force: true }); |
| 41 | +}); |
| 42 | + |
| 43 | +test('scan keeps an env whose cached copy matches the installed version', () => { |
| 44 | + const root = mkroot(); |
| 45 | + env(root, 'bbb', { '@claude-flow/cli': '^3.32.0' }, { '@claude-flow/cli': '3.32.2' }); |
| 46 | + assert.deepEqual(scanNpxStale({ root, baseline }), []); |
| 47 | + fs.rmSync(root, { recursive: true, force: true }); |
| 48 | +}); |
| 49 | + |
| 50 | +test('scan keeps an env cached NEWER than the install — only strictly older is stale', () => { |
| 51 | + const root = mkroot(); |
| 52 | + env(root, 'ccc', { ruflo: '^3.33.0' }, { ruflo: '3.33.0' }); |
| 53 | + assert.deepEqual(scanNpxStale({ root, baseline }), []); |
| 54 | + fs.rmSync(root, { recursive: true, force: true }); |
| 55 | +}); |
| 56 | + |
| 57 | +test('scan keeps an env keyed to any unmanaged package — partial verdicts never prune', () => { |
| 58 | + const root = mkroot(); |
| 59 | + // ruflo copy is stale, but the pnpm key is unjudgeable → whole env exempt. |
| 60 | + env(root, 'ddd', { ruflo: '^3.21.1', pnpm: '^9' }, { ruflo: '3.21.1', pnpm: '9.0.0' }); |
| 61 | + assert.deepEqual(scanNpxStale({ root, baseline }), []); |
| 62 | + fs.rmSync(root, { recursive: true, force: true }); |
| 63 | +}); |
| 64 | + |
| 65 | +test('scan keeps a managed env when no installed baseline exists to judge against', () => { |
| 66 | + const root = mkroot(); |
| 67 | + env(root, 'eee', { 'agentic-qe': '^3.11.5' }, { 'agentic-qe': '3.11.5' }); |
| 68 | + assert.deepEqual(scanNpxStale({ root, baseline: () => null }), []); |
| 69 | + fs.rmSync(root, { recursive: true, force: true }); |
| 70 | +}); |
| 71 | + |
| 72 | +test('scan keeps an env whose cached copy is unreadable — no version, no verdict', () => { |
| 73 | + const root = mkroot(); |
| 74 | + env(root, 'fff', { ruflo: '^3.21.1' }, {}); // keyed but node_modules copy missing |
| 75 | + assert.deepEqual(scanNpxStale({ root, baseline }), []); |
| 76 | + fs.rmSync(root, { recursive: true, force: true }); |
| 77 | +}); |
| 78 | + |
| 79 | +test('scan of a missing cache dir returns empty, never throws', () => { |
| 80 | + assert.deepEqual(scanNpxStale({ root: path.join(os.tmpdir(), 'ak-npx-does-not-exist'), baseline }), []); |
| 81 | +}); |
| 82 | + |
| 83 | +test('prune removes exactly the stale envs and reports what it removed', () => { |
| 84 | + const root = mkroot(); |
| 85 | + const stale = env(root, 'stale', { '@claude-flow/cli': '^3.28.0' }, { '@claude-flow/cli': '3.28.0' }); |
| 86 | + const current = env(root, 'current', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' }); |
| 87 | + const foreign = env(root, 'foreign', { typescript: '^5' }, { typescript: '5.5.0' }); |
| 88 | + |
| 89 | + const r = pruneNpxStale({ root, baseline }); |
| 90 | + |
| 91 | + assert.equal(r.ok, true); |
| 92 | + assert.match(r.detail, /@claude-flow\/cli@3\.28\.0/); |
| 93 | + assert.equal(fs.existsSync(stale), false, 'stale env removed'); |
| 94 | + assert.equal(fs.existsSync(current), true, 'current env kept'); |
| 95 | + assert.equal(fs.existsSync(foreign), true, 'foreign env kept'); |
| 96 | + fs.rmSync(root, { recursive: true, force: true }); |
| 97 | +}); |
| 98 | + |
| 99 | +test('prune reports "no stale envs" on a clean cache without touching anything', () => { |
| 100 | + const root = mkroot(); |
| 101 | + const kept = env(root, 'ok', { ruflo: '^3.32.2' }, { ruflo: '3.32.2' }); |
| 102 | + const r = pruneNpxStale({ root, baseline }); |
| 103 | + assert.deepEqual(r, { ok: true, detail: 'no stale envs' }); |
| 104 | + assert.equal(fs.existsSync(kept), true); |
| 105 | + fs.rmSync(root, { recursive: true, force: true }); |
| 106 | +}); |
| 107 | + |
| 108 | +test('managedBaseline resolves @claude-flow/cli from its NESTED location under ruflo', () => { |
| 109 | + const groot = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npx-groot-')); |
| 110 | + const nested = path.join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli'); |
| 111 | + fs.mkdirSync(nested, { recursive: true }); |
| 112 | + fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ version: '3.32.2' })); |
| 113 | + _setGlobalRootForTest(groot); |
| 114 | + assert.equal(managedBaseline('@claude-flow/cli'), '3.32.2'); |
| 115 | + assert.equal(managedBaseline('left-pad'), null, 'unmanaged packages are never judged'); |
| 116 | + fs.rmSync(groot, { recursive: true, force: true }); |
| 117 | +}); |
0 commit comments