199 lines
6.4 KiB
JavaScript
199 lines
6.4 KiB
JavaScript
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);
|
|
});
|
|
});
|