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 { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { createLedger, addRepo, setRepoStatus, saveLedger } from '../../scanners/lib/campaign-ledger.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const CLI = resolve(__dirname, '../../scanners/campaign-export-cli.mjs'); const NOW = '2026-06-23'; const SESSION = '20260623_101500'; const PLAN_BODY = '## Executive summary\n\n2 actions, 1 auto-fixable.\n\n## Action 1\nFix the thing.\n'; // Every test points --ledger-file + --sessions-dir at temp dirs, so the CLI never touches the // real ~/.claude/ — 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 || '' }; } } /** * Build a hermetic world: a temp dir holding the ledger + a sessions/ store + a repo/ dir. * `opts.withSession` links a sessionId; `opts.withPlan` writes that session's action-plan.md. */ function world({ withSession = true, withPlan = true } = {}) { const root = mkdtempSync(join(tmpdir(), 'camp-exp-')); const sessionsDir = join(root, 'sessions'); const repoDir = join(root, 'repo'); mkdirSync(sessionsDir, { recursive: true }); mkdirSync(repoDir, { recursive: true }); let l = createLedger({ now: NOW }); l = addRepo(l, { path: repoDir, name: 'repo' }, { now: NOW }); l = setRepoStatus(l, repoDir, 'planned', { now: NOW, findingsBySeverity: { critical: 0, high: 1, medium: 0, low: 2 }, sessionId: withSession ? SESSION : undefined, }); const ledgerFile = join(root, 'campaign-ledger.json'); // saveLedger is async via the lib; call synchronously through writeFileSync to keep tests simple. writeFileSync(ledgerFile, `${JSON.stringify(l, null, 2)}\n`); if (withSession && withPlan) { mkdirSync(join(sessionsDir, SESSION), { recursive: true }); writeFileSync(join(sessionsDir, SESSION, 'action-plan.md'), PLAN_BODY); } return { root, sessionsDir, repoDir, ledgerFile }; } const baseArgs = (w) => ['--ledger-file', w.ledgerFile, '--sessions-dir', w.sessionsDir, '--reference-date', NOW]; describe('campaign-export-cli — preview (read-only default)', () => { it('exits 0 and returns an exportable preview without writing', () => { const w = world(); const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]); assert.equal(status, 0); const out = JSON.parse(stdout); assert.equal(out.exportable, true); assert.equal(out.written, false); assert.deepEqual(out.problems, []); assert.equal(out.targetPath, join(w.repoDir, 'docs', `config-audit-plan-${SESSION}.md`)); assert.ok(out.document.startsWith('# Config-Audit Action Plan — repo')); assert.ok(out.document.includes('## Action 1\nFix the thing.')); // preview must NOT have created the file assert.equal(existsSync(out.targetPath), false); }); }); describe('campaign-export-cli — --write', () => { it('writes a byte-exact copy of the assembled document into the repo docs/', () => { const w = world(); const preview = JSON.parse(runCli([...baseArgs(w), '--repo', w.repoDir]).stdout); const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--write']); assert.equal(status, 0); const out = JSON.parse(stdout); assert.equal(out.written, true); assert.ok(existsSync(out.targetPath)); // file on disk equals the document the preview reported (no drift) assert.equal(readFileSync(out.targetPath, 'utf-8'), preview.document); }); it('creates the docs/ dir if missing', () => { const w = world(); const out = JSON.parse(runCli([...baseArgs(w), '--repo', w.repoDir, '--write']).stdout); assert.ok(existsSync(join(w.repoDir, 'docs'))); assert.ok(existsSync(out.targetPath)); }); }); describe('campaign-export-cli — gates (advisory exit 1)', () => { it('exits 1 with no-session-linked when the repo has no sessionId', () => { const w = world({ withSession: false }); const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]); assert.equal(status, 1); const out = JSON.parse(stdout); assert.equal(out.exportable, false); assert.deepEqual(out.problems, ['no-session-linked']); assert.equal(out.written, false); }); it('exits 1 with no-action-plan when the linked session has no action-plan.md', () => { const w = world({ withPlan: false }); const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]); assert.equal(status, 1); const out = JSON.parse(stdout); assert.equal(out.exportable, false); assert.deepEqual(out.problems, ['no-action-plan']); }); it('--write writes nothing when the repo is not exportable', () => { const w = world({ withPlan: false }); const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--write']); assert.equal(status, 1); assert.equal(JSON.parse(stdout).written, false); assert.equal(existsSync(join(w.repoDir, 'docs')), false); }); }); describe('campaign-export-cli — errors (exit 3)', () => { it('exits 3 when --repo is missing', () => { const w = world(); assert.equal(runCli(baseArgs(w)).status, 3); }); it('exits 3 when the repo is not tracked in the ledger', () => { const w = world(); assert.equal(runCli([...baseArgs(w), '--repo', join(w.root, 'other')]).status, 3); }); it('exits 3 when no ledger file exists', () => { const w = world(); const status = runCli(['--ledger-file', join(w.root, 'nope.json'), '--sessions-dir', w.sessionsDir, '--repo', w.repoDir]).status; assert.equal(status, 3); }); }); describe('campaign-export-cli — --output-file', () => { it('writes the payload JSON to the file and stays silent on stdout', () => { const w = world(); const out = join(w.root, 'report.json'); const { stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--output-file', out]); assert.equal(stdout.trim(), ''); const written = JSON.parse(readFileSync(out, 'utf-8')); assert.equal(written.exportable, true); assert.equal(written.targetPath, join(w.repoDir, 'docs', `config-audit-plan-${SESSION}.md`)); }); });