import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { createLedger, addRepo, setRepoStatus, rollUp, buildBacklog, saveLedger, } from '../../scanners/lib/campaign-ledger.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const CLI = resolve(__dirname, '../../scanners/campaign-cli.mjs'); const NOW = '2026-06-22'; // Every test passes an explicit --ledger-file in a temp dir, so the CLI never touches // the real default path under HOME — the suite is hermetic by construction. function runCli(extraArgs) { try { const stdout = execFileSync('node', [CLI, ...extraArgs], { encoding: 'utf-8', timeout: 15000 }); return { status: 0, stdout }; } catch (err) { return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' }; } } // Round-trip a populated ledger through the real lib + saveLedger so the CLI reads // exactly what the command layer would persist. async function seedLedger(dir) { let l = createLedger({ now: NOW }); l = addRepo(l, { path: '/r/a', name: 'a' }, { now: NOW }); l = addRepo(l, { path: '/r/b', name: 'b' }, { now: NOW }); l = addRepo(l, { path: '/r/c', name: 'c' }, { now: NOW }); l = setRepoStatus(l, '/r/a', 'audited', { now: NOW, findingsBySeverity: { critical: 1, high: 2, medium: 0, low: 4 }, sessionId: '20260622_120000', }); l = setRepoStatus(l, '/r/b', 'implemented', { now: NOW, findingsBySeverity: { critical: 0, high: 1, medium: 3, low: 0 }, }); // c stays pending, no findings const file = join(dir, 'campaign-ledger.json'); await saveLedger(file, l); return { file, ledger: l }; } const EMPTY_ROLLUP = { totalRepos: 0, byStatus: { pending: 0, audited: 0, planned: 0, implemented: 0 }, bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, reposWithFindings: 0, tokens: { sharedGlobal: { always: 0, onDemand: 0, external: 0, unknown: 0 }, perRepoDelta: { always: 0, onDemand: 0, external: 0, unknown: 0 }, machineWide: { always: 0, onDemand: 0, external: 0, unknown: 0 }, reposWithTokens: 0, byRepo: [], }, }; describe('campaign-cli — exit codes', () => { it('exits 0 when the ledger exists and is valid', async () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const { file } = await seedLedger(dir); const { status, stdout } = runCli(['--ledger-file', file]); assert.equal(status, 0); assert.equal(JSON.parse(stdout).initialized, true); }); it('exits 1 (advisory) when no ledger file exists yet', () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const { status, stdout } = runCli(['--ledger-file', join(dir, 'nope.json')]); assert.equal(status, 1); const out = JSON.parse(stdout); assert.equal(out.initialized, false); assert.deepEqual(out.repos, []); }); it('exits 3 on a malformed (non-JSON) ledger file', () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const file = join(dir, 'campaign-ledger.json'); writeFileSync(file, '{ not valid json'); assert.equal(runCli(['--ledger-file', file]).status, 3); }); it('exits 3 on a schema-invalid ledger', () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const file = join(dir, 'campaign-ledger.json'); writeFileSync(file, JSON.stringify({ schemaVersion: 99, repos: 'nope' })); assert.equal(runCli(['--ledger-file', file]).status, 3); }); }); describe('campaign-cli — payload shape', () => { it('emits the expected keys and a roll-up matching the lib', async () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const { file, ledger } = await seedLedger(dir); const out = JSON.parse(runCli(['--ledger-file', file]).stdout); assert.equal(out.status, 'ok'); assert.equal(out.initialized, true); assert.equal(out.ledgerPath, resolve(file)); assert.equal(out.schemaVersion, 1); assert.equal(out.createdDate, NOW); assert.equal(out.updatedDate, NOW); assert.equal(out.repos.length, 3); assert.deepEqual(out.rollUp, rollUp(ledger)); }); it('repos carry per-repo provenance (status, findingsBySeverity, sessionId)', async () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const { file } = await seedLedger(dir); const out = JSON.parse(runCli(['--ledger-file', file]).stdout); const a = out.repos.find((r) => r.name === 'a'); assert.equal(a.status, 'audited'); assert.deepEqual(a.findingsBySeverity, { critical: 1, high: 2, medium: 0, low: 4 }); assert.equal(a.sessionId, '20260622_120000'); }); it('uninitialized payload has empty repos and an all-zero roll-up', () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const out = JSON.parse(runCli(['--ledger-file', join(dir, 'nope.json')]).stdout); assert.equal(out.initialized, false); assert.equal(out.schemaVersion, null); assert.deepEqual(out.repos, []); assert.deepEqual(out.rollUp, EMPTY_ROLLUP); }); }); describe('campaign-cli — backlog', () => { it('emits a backlog matching the lib, excluding implemented/pending/zero-finding repos', async () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const { file, ledger } = await seedLedger(dir); const out = JSON.parse(runCli(['--ledger-file', file]).stdout); // seed: a=audited(with findings), b=implemented(excluded), c=pending(excluded) assert.deepEqual(out.backlog, buildBacklog(ledger)); assert.equal(out.backlog.length, 1); assert.equal(out.backlog[0].name, 'a'); assert.equal(out.backlog[0].rank, 1); }); it('ranks multiple actionable repos by severity', async () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); let l = createLedger({ now: NOW }); l = addRepo(l, { path: '/r/low', name: 'low' }, { now: NOW }); l = addRepo(l, { path: '/r/crit', name: 'crit' }, { now: NOW }); l = setRepoStatus(l, '/r/low', 'audited', { now: NOW, findingsBySeverity: { critical: 0, high: 0, medium: 0, low: 9 } }); l = setRepoStatus(l, '/r/crit', 'planned', { now: NOW, findingsBySeverity: { critical: 1, high: 0, medium: 0, low: 0 } }); const file = join(dir, 'campaign-ledger.json'); await saveLedger(file, l); const out = JSON.parse(runCli(['--ledger-file', file]).stdout); assert.deepEqual(out.backlog.map((i) => i.name), ['crit', 'low']); }); it('uninitialized payload has an empty backlog', () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const out = JSON.parse(runCli(['--ledger-file', join(dir, 'nope.json')]).stdout); assert.deepEqual(out.backlog, []); }); }); describe('campaign-cli — --output-file', () => { it('writes JSON to the file and stays silent on stdout', async () => { const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); const { file } = await seedLedger(dir); const out = join(dir, 'report.json'); const { stdout } = runCli(['--ledger-file', file, '--output-file', out]); assert.equal(stdout.trim(), '', 'stdout is silent when --output-file is given'); const written = JSON.parse(readFileSync(out, 'utf-8')); assert.equal(written.status, 'ok'); assert.equal(written.repos.length, 3); }); });