diff --git a/src/services/autoFix/autoFixRunner.test.ts b/src/services/autoFix/autoFixRunner.test.ts new file mode 100644 index 00000000..f9fc9551 --- /dev/null +++ b/src/services/autoFix/autoFixRunner.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + runAutoFixCheck, + type AutoFixResult, + type AutoFixCheckOptions, +} from './autoFixRunner.js' + +describe('runAutoFixCheck', () => { + test('returns success when lint command exits 0', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "all clean"', + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + expect(result.lintOutput).toContain('all clean') + expect(result.testOutput).toBeUndefined() + }) + + test('returns errors when lint command exits non-zero', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "error: unused var" && exit 1', + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.lintOutput).toContain('unused var') + expect(result.lintExitCode).toBe(1) + }) + + test('returns errors when test command exits non-zero', async () => { + const result = await runAutoFixCheck({ + test: 'echo "FAIL test_foo" && exit 1', + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.testOutput).toContain('FAIL test_foo') + expect(result.testExitCode).toBe(1) + }) + + test('runs both lint and test commands', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "lint ok"', + test: 'echo "test ok"', + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + expect(result.lintOutput).toContain('lint ok') + expect(result.testOutput).toContain('test ok') + }) + + test('skips test if lint fails', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "lint error" && exit 1', + test: 'echo "should not run"', + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.lintOutput).toContain('lint error') + expect(result.testOutput).toBeUndefined() + }) + + test('handles timeout gracefully', async () => { + const result = await runAutoFixCheck({ + lint: 'sleep 10', + timeout: 100, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + expect(result.timedOut).toBe(true) + }) + + test('returns success with no commands configured', async () => { + const result = await runAutoFixCheck({ + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(false) + }) + + test('formats error summary for AI consumption', async () => { + const result = await runAutoFixCheck({ + lint: 'echo "src/foo.ts:10:5 error no-unused-vars" && exit 1', + timeout: 5000, + cwd: '/tmp', + }) + expect(result.hasErrors).toBe(true) + const summary = result.errorSummary + expect(summary).toContain('Lint errors') + expect(summary).toContain('no-unused-vars') + }) +}) diff --git a/src/services/autoFix/autoFixRunner.ts b/src/services/autoFix/autoFixRunner.ts new file mode 100644 index 00000000..0d45359d --- /dev/null +++ b/src/services/autoFix/autoFixRunner.ts @@ -0,0 +1,135 @@ +import { spawn } from 'child_process' + +export interface AutoFixCheckOptions { + lint?: string + test?: string + timeout: number + cwd: string + signal?: AbortSignal +} + +export interface AutoFixResult { + hasErrors: boolean + lintOutput?: string + lintExitCode?: number + testOutput?: string + testExitCode?: number + timedOut?: boolean + errorSummary?: string +} + +async function runCommand( + command: string, + cwd: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> { + return new Promise((resolve) => { + let timedOut = false + let stdout = '' + let stderr = '' + + const proc = spawn('bash', ['-c', command], { + cwd, + env: { ...process.env }, + }) + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString() + }) + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + const timer = setTimeout(() => { + timedOut = true + proc.kill('SIGTERM') + }, timeout) + + proc.on('close', (code) => { + clearTimeout(timer) + resolve({ + stdout: stdout.slice(0, 10000), + stderr: stderr.slice(0, 10000), + exitCode: code ?? 1, + timedOut, + }) + }) + + proc.on('error', () => { + clearTimeout(timer) + resolve({ + stdout, + stderr: stderr || 'Command failed to start', + exitCode: 1, + timedOut: false, + }) + }) + }) +} + +function buildErrorSummary(result: AutoFixResult): string | undefined { + if (!result.hasErrors) return undefined + const parts: string[] = [] + + if (result.timedOut) { + parts.push('Command timed out.') + } + if (result.lintExitCode !== undefined && result.lintExitCode !== 0) { + parts.push(`Lint errors (exit code ${result.lintExitCode}):\n${result.lintOutput ?? ''}`) + } + if (result.testExitCode !== undefined && result.testExitCode !== 0) { + parts.push(`Test failures (exit code ${result.testExitCode}):\n${result.testOutput ?? ''}`) + } + + return parts.join('\n\n') +} + +export async function runAutoFixCheck( + options: AutoFixCheckOptions, +): Promise { + const { lint, test, timeout, cwd, signal } = options + + if (!lint && !test) { + return { hasErrors: false } + } + + const result: AutoFixResult = { hasErrors: false } + + // Run lint first + if (lint) { + const lintResult = await runCommand(lint, cwd, timeout, signal) + result.lintOutput = (lintResult.stdout + '\n' + lintResult.stderr).trim() + result.lintExitCode = lintResult.exitCode + + if (lintResult.timedOut) { + result.hasErrors = true + result.timedOut = true + result.errorSummary = buildErrorSummary(result) + return result + } + + if (lintResult.exitCode !== 0) { + result.hasErrors = true + result.errorSummary = buildErrorSummary(result) + return result + } + } + + // Run tests only if lint passed (or no lint configured) + if (test) { + const testResult = await runCommand(test, cwd, timeout, signal) + result.testOutput = (testResult.stdout + '\n' + testResult.stderr).trim() + result.testExitCode = testResult.exitCode + + if (testResult.timedOut) { + result.hasErrors = true + result.timedOut = true + } else if (testResult.exitCode !== 0) { + result.hasErrors = true + } + } + + result.errorSummary = buildErrorSummary(result) + return result +}