/** * SC-5 — default-output snapshot test (Wave 4 Step 12). * * Captures the humanized stdout of three representative CLIs running in * default mode against tests/fixtures/marketplace-medium and asserts * byte-equal output against tests/snapshots/default-output/.json. * * Set UPDATE_SNAPSHOT=1 to seed or refresh a snapshot. Subsequent runs * assert byte-equal — any drift fails the test, so humanizer prose * changes must be intentional and re-approved by re-running with * UPDATE_SNAPSHOT=1. * * Time-varying fields are normalized before comparison (timestamp, * target path, duration_ms). Humanizer-added prose fields * (titleHumanized / descriptionHumanized / recommendationHumanized, * userImpactCategory, userActionLanguage, relevanceContext) are kept — * they are the contract being snapshotted. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { readFile, writeFile } from 'node:fs/promises'; const exec = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(__dirname, '..'); const FIXTURE = resolve(REPO, 'tests/fixtures/marketplace-medium'); const SNAPSHOT_DIR = resolve(REPO, 'tests/snapshots/default-output'); const UPDATE = process.env.UPDATE_SNAPSHOT === '1'; async function runCli(scriptPath, args) { try { const { stdout, stderr } = await exec('node', [scriptPath, ...args], { timeout: 60000, cwd: REPO, maxBuffer: 10 * 1024 * 1024, }); return { stdout: stdout || '', stderr: stderr || '' }; } catch (err) { return { stdout: err.stdout || '', stderr: err.stderr || '' }; } } // --------------------------------------------------------------------------- // Normalizers — same shape per CLI as json-backcompat / cli-humanizer tests. // --------------------------------------------------------------------------- function normalizeScanOrchestrator(env) { const out = JSON.parse(JSON.stringify(env)); if (out.meta) { out.meta.target = ''; out.meta.timestamp = ''; } if (Array.isArray(out.scanners)) { for (const s of out.scanners) { s.duration_ms = 0; // claudeMdEstimatedTokens reflects walkClaudeMdCascade walking up from // the fixture into this plugin's own CLAUDE.md; any docs edit ripples // into it independently of scanner internals. Strip to keep the // default-output snapshot focused on humanizer prose stability. if (s.activeConfig && 'claudeMdEstimatedTokens' in s.activeConfig) { s.activeConfig.claudeMdEstimatedTokens = ''; } } } return out; } function normalizeTokenHotspots(p) { const out = JSON.parse(JSON.stringify(p)); out.duration_ms = 0; return out; } const CLIS = [ { name: 'scan-orchestrator', script: 'scanners/scan-orchestrator.mjs', snapshotName: 'scan-orchestrator.json', normalize: normalizeScanOrchestrator, captureStream: 'stdout', }, { name: 'token-hotspots', script: 'scanners/token-hotspots-cli.mjs', snapshotName: 'token-hotspots.json', normalize: normalizeTokenHotspots, captureStream: 'stdout', }, { name: 'posture', script: 'scanners/posture.mjs', snapshotName: 'posture.json', // Posture default mode emits the humanized scorecard to stderr; stdout is // empty unless --json/--raw. Snapshot the scorecard text. normalize: (s) => s.replace(/\(\d+ms\)/g, '(0ms)'), captureStream: 'stderr-text', }, ]; async function captureForCli(cli) { const script = resolve(REPO, cli.script); const { stdout, stderr } = await runCli(script, [FIXTURE]); if (cli.captureStream === 'stdout') { const parsed = JSON.parse(stdout); return { kind: 'json', payload: cli.normalize(parsed), }; } if (cli.captureStream === 'stderr-text') { return { kind: 'text', payload: cli.normalize(stderr.trim()), }; } throw new Error(`unknown captureStream: ${cli.captureStream}`); } async function loadSnapshot(snapshotPath) { const raw = await readFile(snapshotPath, 'utf8'); // Snapshot files are stored as JSON envelopes — text snapshots are wrapped // as { kind: 'text', payload: '...' } so all snapshots look uniform on disk. return JSON.parse(raw); } async function writeSnapshot(snapshotPath, captured) { const serialized = JSON.stringify(captured, null, 2) + '\n'; await writeFile(snapshotPath, serialized, 'utf8'); } describe('SC-5 default-output snapshot test', () => { for (const cli of CLIS) { it(`${cli.name} default mode matches tests/snapshots/default-output/${cli.snapshotName}`, async () => { const captured = await captureForCli(cli); const snapshotPath = resolve(SNAPSHOT_DIR, cli.snapshotName); if (UPDATE) { await writeSnapshot(snapshotPath, captured); return; } let expected; try { expected = await loadSnapshot(snapshotPath); } catch (err) { if (err.code === 'ENOENT') { assert.fail( `Snapshot missing: ${snapshotPath}. ` + `Re-run with UPDATE_SNAPSHOT=1 to seed it.`, ); } throw err; } assert.deepStrictEqual( captured, expected, `${cli.name}: default-output drift detected. ` + `If intentional, re-run with UPDATE_SNAPSHOT=1.`, ); }); } });