import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js'; function makeTmpDir(): string { const dir = join(tmpdir(), `macp-gate-${randomUUID()}`); mkdirSync(dir, { recursive: true }); return dir; } describe('normalizeGate', () => { it('normalizes a string to mechanical gate', () => { expect(normalizeGate('echo test')).toEqual({ command: 'echo test', type: 'mechanical', fail_on: 'blocker', }); }); it('normalizes an object gate with defaults', () => { expect(normalizeGate({ command: 'lint' })).toEqual({ command: 'lint', type: 'mechanical', fail_on: 'blocker', }); }); it('preserves explicit type and fail_on', () => { expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({ command: 'review', type: 'ai-review', fail_on: 'any', }); }); it('handles non-string/non-object input', () => { expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); }); }); describe('countAIFindings', () => { it('returns zeros for non-object', () => { expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 }); expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 }); expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 }); }); it('counts from stats block', () => { const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } }; expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 }); }); it('counts from findings array when stats has no blockers', () => { const output = { stats: { blockers: 0 }, findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }], }; expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 }); }); it('uses stats blockers over findings array when stats has blockers', () => { const output = { stats: { blockers: 5 }, findings: [{ severity: 'blocker' }, { severity: 'warning' }], }; // stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 }); }); it('counts findings length as total when stats has zero total', () => { const output = { findings: [{ severity: 'warning' }, { severity: 'info' }], }; expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 }); }); }); describe('runGate', () => { let tmp: string; let logPath: string; beforeEach(() => { tmp = makeTmpDir(); logPath = join(tmp, 'gate.log'); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); it('passes mechanical gate on exit 0', () => { const result = runGate('echo hello', tmp, logPath, 30); expect(result.passed).toBe(true); expect(result.exit_code).toBe(0); expect(result.type).toBe('mechanical'); expect(result.output).toContain('hello'); }); it('fails mechanical gate on non-zero exit', () => { const result = runGate('exit 1', tmp, logPath, 30); expect(result.passed).toBe(false); expect(result.exit_code).toBe(1); }); it('ci-pipeline always passes', () => { const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30); expect(result.passed).toBe(true); expect(result.type).toBe('ci-pipeline'); expect(result.output).toBe('CI pipeline gate placeholder'); }); it('empty command passes', () => { const result = runGate({ command: '' }, tmp, logPath, 30); expect(result.passed).toBe(true); }); it('ai-review gate parses JSON output', () => { const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); expect(result.passed).toBe(true); expect(result.blockers).toBe(0); expect(result.findings).toBe(1); }); it('ai-review gate fails on blockers', () => { const json = JSON.stringify({ stats: { blockers: 2 } }); const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); expect(result.passed).toBe(false); expect(result.blockers).toBe(2); }); it('ai-review gate with fail_on=any fails on any findings', () => { const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); const result = runGate( { command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' }, tmp, logPath, 30, ); expect(result.passed).toBe(false); expect(result.fail_on).toBe('any'); }); it('ai-review gate fails on invalid JSON output', () => { const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30); expect(result.passed).toBe(false); expect(result.parse_error).toBeDefined(); }); it('writes to log file', () => { runGate('echo logged', tmp, logPath, 30); const log = readFileSync(logPath, 'utf-8'); expect(log).toContain('COMMAND: echo logged'); expect(log).toContain('logged'); expect(log).toContain('EXIT:'); }); }); describe('runGates', () => { let tmp: string; let logPath: string; let eventsPath: string; beforeEach(() => { tmp = makeTmpDir(); logPath = join(tmp, 'gates.log'); eventsPath = join(tmp, 'events.ndjson'); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); it('runs multiple gates and returns results', () => { const { allPassed, gateResults } = runGates( ['echo one', 'echo two'], tmp, logPath, 30, eventsPath, 'task-1', ); expect(allPassed).toBe(true); expect(gateResults).toHaveLength(2); }); it('reports failure when any gate fails', () => { const { allPassed, gateResults } = runGates( ['echo ok', 'exit 1'], tmp, logPath, 30, eventsPath, 'task-2', ); expect(allPassed).toBe(false); expect(gateResults[0]!.passed).toBe(true); expect(gateResults[1]!.passed).toBe(false); }); it('emits events for each gate', () => { runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3'); const events = readFileSync(eventsPath, 'utf-8') .trim() .split('\n') .map((l) => JSON.parse(l)); expect(events).toHaveLength(2); // started + passed expect(events[0].event_type).toBe('rail.check.started'); expect(events[1].event_type).toBe('rail.check.passed'); }); it('skips gates with empty command (non ci-pipeline)', () => { const { gateResults } = runGates( [{ command: '', type: 'mechanical' }, 'echo real'], tmp, logPath, 30, eventsPath, 'task-4', ); expect(gateResults).toHaveLength(1); }); it('does not skip ci-pipeline even with empty command', () => { const { gateResults } = runGates( [{ command: '', type: 'ci-pipeline' }], tmp, logPath, 30, eventsPath, 'task-5', ); expect(gateResults).toHaveLength(1); expect(gateResults[0]!.passed).toBe(true); }); it('emits failed event with correct message', () => { runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6'); const events = readFileSync(eventsPath, 'utf-8') .trim() .split('\n') .map((l) => JSON.parse(l)); const failEvent = events.find( (e: Record) => e.event_type === 'rail.check.failed', ); expect(failEvent).toBeDefined(); expect(failEvent.message).toContain('Gate failed ('); }); });