mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
/**
|
|
* Tests for scripts/astra-review.js (CLI)
|
|
*
|
|
* Run with: node tests/scripts/astra-review.test.js
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const cli = require('../../scripts/astra-review');
|
|
|
|
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'astra-review.js');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
passed++;
|
|
} catch (err) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${err.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function sink() {
|
|
const chunks = [];
|
|
return { write: (text) => chunks.push(String(text)), text: () => chunks.join('') };
|
|
}
|
|
|
|
const passingReview = { verdict: 'PASS', summary: 'fine', checks: [], findings: [] };
|
|
const failingReview = {
|
|
verdict: 'FAIL', summary: 'bad', checks: [],
|
|
findings: [{ severity: 'HIGH', file: 'a.js', line: 1, title: 'Bug', detail: '', suggestion: '' }],
|
|
};
|
|
|
|
function baseDeps(overrides = {}) {
|
|
return {
|
|
cwd: '/repo',
|
|
collectChanges: () => ({ files: ['a.js'], diff: '+x', truncated: true, root: '/repo-root', recovery: 'git show sha:<path>' }),
|
|
runCodexReview: () => passingReview,
|
|
stdout: sink(),
|
|
stderr: sink(),
|
|
writeFile: () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
console.log('=== Testing scripts/astra-review.js ===\n');
|
|
|
|
test('parseArgs defaults to uncommitted scope and gpt-6-astra', () => {
|
|
const options = cli.parseArgs([], {});
|
|
|
|
assert.strictEqual(options.scope.kind, 'uncommitted');
|
|
assert.strictEqual(options.model, 'gpt-6-astra');
|
|
assert.strictEqual(options.consent, false);
|
|
});
|
|
|
|
test('parseArgs honors ECC_ASTRA_MODEL and ECC_ASTRA_CONSENT', () => {
|
|
const options = cli.parseArgs([], { ECC_ASTRA_MODEL: 'gpt-5.5', ECC_ASTRA_CONSENT: '1' });
|
|
|
|
assert.strictEqual(options.model, 'gpt-5.5');
|
|
assert.strictEqual(options.consent, true);
|
|
});
|
|
|
|
test('parseArgs collects multiple --files paths', () => {
|
|
const options = cli.parseArgs(['--files', 'a.js', 'b.js', '--json'], {});
|
|
|
|
assert.deepStrictEqual(options.scope, { kind: 'files', value: ['a.js', 'b.js'] });
|
|
assert.strictEqual(options.json, true);
|
|
});
|
|
|
|
test('parseArgs accepts --files-from-commit for repair rounds', () => {
|
|
const options = cli.parseArgs(['--files-from-commit', 'HEAD~1'], {});
|
|
|
|
assert.deepStrictEqual(options.scope, { kind: 'files-from-commit', value: 'HEAD~1' });
|
|
});
|
|
|
|
test('parseArgs rejects unknown flags and bad timeouts', () => {
|
|
assert.throws(() => cli.parseArgs(['--bogus'], {}), /unknown argument/);
|
|
assert.throws(() => cli.parseArgs(['--timeout-seconds', '5'], {}), /timeout-seconds/);
|
|
assert.throws(() => cli.parseArgs(['--model', 'bad slug!'], {}), /invalid model/);
|
|
});
|
|
|
|
test('runCli prints "Nothing to review" and passes when there are no changes', () => {
|
|
const deps = baseDeps({ collectChanges: () => ({ files: [], diff: '', truncated: false }) });
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--consent-to-openai'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_PASS);
|
|
assert.ok(deps.stdout.text().includes('Nothing to review'));
|
|
});
|
|
|
|
test('runCli --dry-run prints the prompt and never calls Codex', () => {
|
|
let called = false;
|
|
const deps = baseDeps({ runCodexReview: () => { called = true; return passingReview; } });
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--dry-run'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_PASS);
|
|
assert.strictEqual(called, false);
|
|
assert.ok(deps.stdout.text().includes('--- BEGIN DIFF ---'));
|
|
});
|
|
|
|
test('runCli refuses to call Codex without consent', () => {
|
|
let called = false;
|
|
const deps = baseDeps({ runCodexReview: () => { called = true; return passingReview; } });
|
|
|
|
const code = cli.runCli(cli.parseArgs([], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_ERROR);
|
|
assert.strictEqual(called, false);
|
|
assert.ok(deps.stderr.text().includes('consent'));
|
|
});
|
|
|
|
test('runCli returns 0 and a markdown report on PASS', () => {
|
|
const deps = baseDeps();
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--consent-to-openai'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_PASS);
|
|
assert.ok(deps.stdout.text().includes('# Astra Review (gpt-6-astra)'));
|
|
assert.ok(deps.stdout.text().includes('**PASS**'));
|
|
});
|
|
|
|
test('runCli returns 1 on FAIL and writes JSON to --output', () => {
|
|
let written = null;
|
|
const deps = baseDeps({
|
|
runCodexReview: () => failingReview,
|
|
writeFile: (file, content) => { written = { file, content }; },
|
|
});
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--consent-to-openai', '--output', '/tmp/r.json', '--json'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_FAIL);
|
|
assert.strictEqual(written.file, '/tmp/r.json');
|
|
assert.strictEqual(JSON.parse(written.content).verdict, 'FAIL');
|
|
assert.strictEqual(JSON.parse(deps.stdout.text()).review.findings.length, 1);
|
|
});
|
|
|
|
test('runCli passes model, prompt, and timeout through to the reviewer', () => {
|
|
let seen = null;
|
|
const deps = baseDeps({ runCodexReview: (input) => { seen = input; return passingReview; } });
|
|
|
|
cli.runCli(cli.parseArgs(['--consent-to-openai', '--model', 'gpt-5.5', '--timeout-seconds', '60'], {}), deps);
|
|
|
|
assert.strictEqual(seen.model, 'gpt-5.5');
|
|
assert.strictEqual(seen.timeoutMs, 60_000);
|
|
assert.strictEqual(seen.cwd, '/repo-root');
|
|
assert.ok(seen.prompt.includes('+x'));
|
|
assert.ok(seen.prompt.includes('git show sha:<path>'));
|
|
});
|
|
|
|
const savedVerdict = JSON.stringify({ model: 'gpt-6-astra', verdict: 'FAIL', review: failingReview });
|
|
const fixResult = { summary: 'fixed', fixed: [{ title: 'Bug', file: 'a.js', change: 'guard' }], skipped: [] };
|
|
|
|
function fixDeps(overrides = {}) {
|
|
return baseDeps({
|
|
readFile: () => savedVerdict,
|
|
resolveRepoRoot: () => '/repo-root',
|
|
runCodexFix: () => fixResult,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
test('parseArgs accepts --fix-findings and refuses to combine it with a scope flag', () => {
|
|
const options = cli.parseArgs(['--fix-findings', '/tmp/v.json'], {});
|
|
|
|
assert.strictEqual(options.fixFindings, '/tmp/v.json');
|
|
assert.throws(() => cli.parseArgs(['--fix-findings', '/tmp/v.json', '--base', 'main'], {}), /--fix-findings/);
|
|
});
|
|
|
|
test('runCli fix mode --dry-run prints the fix prompt and never calls Codex', () => {
|
|
let called = false;
|
|
const deps = fixDeps({ runCodexFix: () => { called = true; return fixResult; } });
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--fix-findings', '/tmp/v.json', '--dry-run'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_PASS);
|
|
assert.strictEqual(called, false);
|
|
assert.ok(deps.stdout.text().includes('[HIGH] a.js:1 — Bug'));
|
|
});
|
|
|
|
test('runCli fix mode refuses to call Codex without consent', () => {
|
|
let called = false;
|
|
const deps = fixDeps({ runCodexFix: () => { called = true; return fixResult; } });
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--fix-findings', '/tmp/v.json'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_ERROR);
|
|
assert.strictEqual(called, false);
|
|
});
|
|
|
|
test('runCli fix mode sends the findings to Astra at the repo root and prints the fix report', () => {
|
|
let seen = null;
|
|
let readPath = null;
|
|
const deps = fixDeps({
|
|
readFile: (file) => { readPath = file; return savedVerdict; },
|
|
runCodexFix: (input) => { seen = input; return fixResult; },
|
|
});
|
|
|
|
const code = cli.runCli(cli.parseArgs(['--fix-findings', '/tmp/v.json', '--consent-to-openai', '--instructions', 'keep API'], {}), deps);
|
|
|
|
assert.strictEqual(code, cli.EXIT_PASS);
|
|
assert.strictEqual(readPath, '/tmp/v.json');
|
|
assert.strictEqual(seen.cwd, '/repo-root');
|
|
assert.strictEqual(seen.model, 'gpt-6-astra');
|
|
assert.ok(seen.prompt.includes('keep API'));
|
|
assert.ok(deps.stdout.text().includes('# Astra Fix (gpt-6-astra)'));
|
|
});
|
|
|
|
test('runCli fix mode --json prints the fix payload', () => {
|
|
const deps = fixDeps();
|
|
|
|
cli.runCli(cli.parseArgs(['--fix-findings', '/tmp/v.json', '--consent-to-openai', '--json'], {}), deps);
|
|
|
|
const payload = JSON.parse(deps.stdout.text());
|
|
assert.strictEqual(payload.mode, 'fix');
|
|
assert.strictEqual(payload.fix.fixed.length, 1);
|
|
});
|
|
|
|
function fakeReportIo({ renameFails = false, writeFails = false } = {}) {
|
|
const events = [];
|
|
return {
|
|
events,
|
|
openSync: (file, flags, mode) => { events.push(['open', file, flags, mode]); return 7; },
|
|
writeFileSync: (fd, content) => {
|
|
events.push(['write', fd, content]);
|
|
if (writeFails) throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' });
|
|
},
|
|
writeSync: () => { throw new Error('writeSync can return short; use writeFileSync on the descriptor'); },
|
|
closeSync: (fd) => { events.push(['close', fd]); },
|
|
renameSync: (from, to) => {
|
|
events.push(['rename', from, to]);
|
|
if (renameFails) throw new Error('EXDEV');
|
|
},
|
|
unlinkSync: (file) => { events.push(['unlink', file]); },
|
|
};
|
|
}
|
|
|
|
const OUT_DIR = path.resolve('/out');
|
|
const REPORT = path.join(OUT_DIR, 'report.json');
|
|
|
|
test('writeReport creates a private temp file exclusively and renames it over the destination', () => {
|
|
const io = fakeReportIo();
|
|
|
|
cli.writeReport(REPORT, '{}', io);
|
|
|
|
const open = io.events.find((event) => event[0] === 'open');
|
|
assert.ok(open[1].startsWith(path.join(OUT_DIR, '.report.json.')) && open[1].endsWith('.tmp'), `temp file in same dir: ${open[1]}`);
|
|
assert.strictEqual(open[2], 'wx');
|
|
assert.strictEqual(open[3], 0o600);
|
|
const rename = io.events.find((event) => event[0] === 'rename');
|
|
assert.strictEqual(rename[1], open[1]);
|
|
assert.strictEqual(rename[2], REPORT);
|
|
assert.ok(io.events.findIndex((event) => event[0] === 'close') < io.events.indexOf(rename), 'closed before rename');
|
|
});
|
|
|
|
test('writeReport removes the temp file when the rename fails', () => {
|
|
const io = fakeReportIo({ renameFails: true });
|
|
|
|
assert.throws(() => cli.writeReport(REPORT, '{}', io), /EXDEV/);
|
|
assert.ok(io.events.some((event) => event[0] === 'unlink' && event[1].endsWith('.tmp')));
|
|
});
|
|
|
|
test('writeReport writes the whole content via the descriptor and cleans up when the write fails', () => {
|
|
const ok = fakeReportIo();
|
|
cli.writeReport(REPORT, '{"full":true}', ok);
|
|
assert.deepStrictEqual(ok.events.find((event) => event[0] === 'write').slice(1), [7, '{"full":true}']);
|
|
|
|
const io = fakeReportIo({ writeFails: true });
|
|
assert.throws(() => cli.writeReport(REPORT, '{}', io), /ENOSPC/);
|
|
assert.ok(io.events.some((event) => event[0] === 'close'), 'descriptor closed after failure');
|
|
assert.ok(io.events.some((event) => event[0] === 'unlink' && event[1].endsWith('.tmp')), 'temp file removed');
|
|
assert.ok(!io.events.some((event) => event[0] === 'rename'), 'nothing published');
|
|
});
|
|
|
|
test('writeReport replaces a symlink and a world-readable file with a private regular file (real fs)', () => {
|
|
const posix = process.platform !== 'win32';
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-report-'));
|
|
try {
|
|
const victim = path.join(dir, 'victim.txt');
|
|
fs.writeFileSync(victim, 'keep me');
|
|
const linked = path.join(dir, 'linked.json');
|
|
let canSymlink = true;
|
|
try {
|
|
fs.symlinkSync(victim, linked);
|
|
} catch {
|
|
canSymlink = false; // Windows without symlink privilege: replacement is still checked below
|
|
}
|
|
const shared = path.join(dir, 'shared.json');
|
|
fs.writeFileSync(shared, 'old', { mode: 0o644 });
|
|
|
|
if (canSymlink) cli.writeReport(linked, '{"a":1}');
|
|
cli.writeReport(shared, '{"b":2}');
|
|
|
|
if (canSymlink) {
|
|
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'keep me', 'symlink target untouched');
|
|
const fd = fs.openSync(linked, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
try {
|
|
assert.strictEqual(fs.fstatSync(fd).isFile(), true, 'published report is regular');
|
|
assert.strictEqual(fs.readFileSync(fd, 'utf8'), '{"a":1}');
|
|
} finally { fs.closeSync(fd); }
|
|
}
|
|
assert.strictEqual(fs.readFileSync(shared, 'utf8'), '{"b":2}');
|
|
if (posix) assert.strictEqual((fs.statSync(shared).mode & 0o777), 0o600, 'existing 0644 file becomes 0600');
|
|
assert.deepStrictEqual(fs.readdirSync(dir).filter((name) => name.endsWith('.tmp')), [], 'no temp files left');
|
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('CLI exits 2 with usage on a bad argument', () => {
|
|
const result = spawnSync('node', [SCRIPT, '--nope'], { encoding: 'utf8' });
|
|
|
|
assert.strictEqual(result.status, 2);
|
|
assert.ok(result.stderr.includes('unknown argument'));
|
|
assert.ok(result.stderr.includes('Usage'));
|
|
});
|
|
|
|
test('CLI --help exits 0 and prints usage', () => {
|
|
const result = spawnSync('node', [SCRIPT, '--help'], { encoding: 'utf8' });
|
|
|
|
assert.strictEqual(result.status, 0);
|
|
assert.ok(result.stdout.includes('--consent-to-openai'));
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed > 0 ? 1 : 0);
|