import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { scan, opportunitySummary } from '../../scanners/feature-gap-scanner.mjs'; import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const FIXTURES = resolve(__dirname, '../fixtures'); // Pre-discover fixture files WITHOUT includeGlobal so tests are environment-independent. // The GAP scanner uses shared discovery when it has files, avoiding its own includeGlobal scan. async function fixtureDiscovery(name) { return discoverConfigFiles(resolve(FIXTURES, name)); } describe('GAP scanner — healthy project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await fixtureDiscovery('healthy-project'); result = await scan(resolve(FIXTURES, 'healthy-project'), discovery); }); it('returns status ok', () => { assert.equal(result.status, 'ok'); }); it('reports scanner prefix GAP', () => { assert.equal(result.scanner, 'GAP'); }); it('scans multiple files', () => { assert.ok(result.files_scanned >= 1); }); it('finding IDs match CA-GAP-NNN pattern', () => { for (const f of result.findings) { assert.match(f.id, /^CA-GAP-\d{3}$/); } }); it('does NOT report missing CLAUDE.md', () => { assert.ok(!result.findings.some(f => f.title === 'No CLAUDE.md file')); }); it('does NOT report missing MCP', () => { assert.ok(!result.findings.some(f => f.title === 'No MCP servers configured')); }); it('does NOT report missing hooks', () => { assert.ok(!result.findings.some(f => f.title === 'No hooks configured')); }); it('has counts object with all severity levels', () => { assert.ok('critical' in result.counts); assert.ok('high' in result.counts); assert.ok('medium' in result.counts); assert.ok('low' in result.counts); assert.ok('info' in result.counts); }); it('has no critical or high findings', () => { assert.equal(result.counts.critical, 0); assert.equal(result.counts.high, 0); }); it('all findings have recommendations', () => { for (const f of result.findings) { assert.ok(f.recommendation, `Finding ${f.id} missing recommendation`); } }); it('T3/T4 findings are info severity', () => { const infoFindings = result.findings.filter(f => f.category === 't3' || f.category === 't4'); for (const f of infoFindings) { assert.equal(f.severity, 'info', `${f.id} (${f.category}) should be info, got ${f.severity}`); } }); }); describe('GAP scanner — minimal project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await fixtureDiscovery('minimal-project'); result = await scan(resolve(FIXTURES, 'minimal-project'), discovery); }); it('returns status ok', () => { assert.equal(result.status, 'ok'); }); it('reports missing hooks', () => { assert.ok(result.findings.some(f => f.title === 'No hooks configured')); }); it('reports missing MCP', () => { assert.ok(result.findings.some(f => f.title === 'No MCP servers configured')); }); it('T1 gaps are medium severity', () => { const t1 = result.findings.filter(f => f.category === 't1'); for (const f of t1) { assert.equal(f.severity, 'medium', `${f.id} should be medium, got ${f.severity}`); } }); it('T2 gaps are low severity', () => { const t2 = result.findings.filter(f => f.category === 't2'); for (const f of t2) { assert.equal(f.severity, 'low', `${f.id} should be low, got ${f.severity}`); } }); it('has more findings than healthy project', async () => { resetCounter(); const discovery = await fixtureDiscovery('healthy-project'); const healthyResult = await scan(resolve(FIXTURES, 'healthy-project'), discovery); assert.ok(result.findings.length > healthyResult.findings.length); }); }); describe('GAP scanner — empty project', () => { let result; beforeEach(async () => { resetCounter(); const discovery = await fixtureDiscovery('empty-project'); result = await scan(resolve(FIXTURES, 'empty-project'), discovery); }); it('returns status ok (never skips)', () => { assert.equal(result.status, 'ok'); }); it('has multiple medium findings (T1 gaps)', () => { const mediums = result.findings.filter(f => f.severity === 'medium'); assert.ok(mediums.length >= 1); }); it('all findings have category field', () => { for (const f of result.findings) { assert.ok(f.category, `Finding ${f.id} missing category`); assert.match(f.category, /^t[1-4]$/); } }); it('reports T1 gaps including missing CLAUDE.md', () => { assert.ok(result.findings.some(f => f.title === 'No CLAUDE.md file')); }); }); describe('opportunitySummary', () => { it('returns empty arrays for no findings', () => { const result = opportunitySummary([]); assert.deepEqual(result.highImpact, []); assert.deepEqual(result.mediumImpact, []); assert.deepEqual(result.explore, []); }); it('routes T1 to highImpact', () => { const findings = [{ category: 't1', title: 'No CLAUDE.md' }]; const result = opportunitySummary(findings); assert.equal(result.highImpact.length, 1); assert.equal(result.mediumImpact.length, 0); assert.equal(result.explore.length, 0); }); it('routes T2 to mediumImpact', () => { const findings = [{ category: 't2', title: 'Low hook diversity' }]; const result = opportunitySummary(findings); assert.equal(result.highImpact.length, 0); assert.equal(result.mediumImpact.length, 1); }); it('routes T3 and T4 to explore', () => { const findings = [ { category: 't3', title: 'No status line' }, { category: 't4', title: 'No custom plugin' }, ]; const result = opportunitySummary(findings); assert.equal(result.explore.length, 2); }); it('handles mixed tiers', () => { const findings = [ { category: 't1', title: 'A' }, { category: 't2', title: 'B' }, { category: 't2', title: 'C' }, { category: 't3', title: 'D' }, { category: 't4', title: 'E' }, ]; const result = opportunitySummary(findings); assert.equal(result.highImpact.length, 1); assert.equal(result.mediumImpact.length, 2); assert.equal(result.explore.length, 2); }); });