Compare commits

..

2 Commits

Author SHA1 Message Date
L'électron rare ac04f97067 feat: add Mascarade as LLM provider — multi-agent orchestration engine
- Add 'mascarade' to SupportedProvider enum (types.ts)
- Add Mascarade case in createProviderInstance using OpenAI-compatible SDK (factory.ts)
- Add 'mascarade' to BuiltinProvider type (provider-account.ts)
- Add mascarade- prefix to MODEL_PROVIDER_MAP (config/types.ts)
- Add 5 mascarade models: router, writer, coder, analyst, planner (models.ts)
- Add mascarade provider presets for all agent profiles (models.ts)
- Add mascarade to PROVIDER_INFOS with UI metadata (providers.ts)

Mascarade is a self-hosted LLM orchestration engine (50K LOC Python)
that routes requests across Claude, OpenAI, Mistral, Google and Ollama
with intelligent routing strategies (best/cheapest/fastest/domain).
Default endpoint: http://localhost:8100/v1 (OpenAI-compatible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:24:21 +01:00
AndyMik90 76fdbade6f docs: update README beta download links to 2.8.0-beta.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:32:15 +01:00
29 changed files with 83 additions and 4484 deletions
+7 -7
View File
@@ -36,18 +36,18 @@
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![Beta](https://img.shields.io/badge/beta-2.8.0--beta.1-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.1)
[![Beta](https://img.shields.io/badge/beta-2.8.0--beta.5-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.5)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.8.0-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.8.0-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.8.0-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.8.0-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.8.0-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.8.0-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-x86_64.flatpak) |
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
-1
View File
@@ -60,4 +60,3 @@ bun.lockb
# Test files in root
test-*.js
test-*.cjs
.fix-pr-data/
-12
View File
@@ -25,12 +25,6 @@ export const app = {
class MockIpcMain extends EventEmitter {
private handlers: Map<string, Function> = new Map();
constructor() {
super();
// Increase maxListeners to accommodate test suites that register many handlers
this.setMaxListeners(50);
}
handle(channel: string, handler: Function): void {
this.handlers.set(channel, handler);
}
@@ -43,12 +37,6 @@ class MockIpcMain extends EventEmitter {
this.handlers.delete(channel);
}
// Reset all handlers and listeners (for test cleanup)
reset(): void {
this.handlers.clear();
this.removeAllListeners();
}
// Helper for tests to invoke handlers
async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise<unknown> {
const handler = this.handlers.get(channel);
+5 -21
View File
@@ -4,11 +4,6 @@
import { vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, existsSync } from 'fs';
import path from 'path';
import { EventEmitter } from 'events';
// Increase default maxListeners for all EventEmitters in tests
// This prevents MaxListenersExceededWarning when multiple test files register handlers
EventEmitter.defaultMaxListeners = 100;
// Mock localStorage for tests that need it
const localStorageMock = (() => {
@@ -60,7 +55,9 @@ beforeEach(() => {
// Clear localStorage
localStorageMock.clear();
// Each test uses unique test data; parallel tests are isolated by worktree
// Use a unique subdirectory per test to avoid race conditions in parallel tests
const testId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const _testDir = path.join(TEST_DATA_DIR, testId);
try {
if (existsSync(TEST_DATA_DIR)) {
@@ -80,22 +77,9 @@ beforeEach(() => {
});
// Clean up test directory after each test
afterEach(async () => {
afterEach(() => {
vi.clearAllMocks();
// Reset MockIpcMain to clear all handlers and prevent EventEmitter leaks
// Import dynamically to avoid circular import issues
try {
const electron = await import('electron');
const reset = (electron.ipcMain as { reset?: () => void }).reset;
if (typeof reset === 'function') {
reset();
}
} catch {
// Ignore if mock isn't available
} finally {
vi.resetModules();
}
vi.resetModules();
});
// Mock window.electronAPI for renderer tests
@@ -88,11 +88,7 @@ describe('resolveModelId', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset environment variables, explicitly clearing model overrides
// that may be set in the test environment
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
process.env = { ...originalEnv };
});
afterEach(() => {
+1
View File
@@ -147,6 +147,7 @@ export const MODEL_PROVIDER_MAP: Record<string, SupportedProvider> = {
'llama-': 'groq',
'grok-': 'xai',
'glm-': 'zai',
'mascarade-': 'mascarade',
} as const;
// ============================================
@@ -1,8 +1,7 @@
/**
* MemoryObserver Tests
*
* Tests observe() with mock messages and verifies performance budget.
* 50ms threshold catches real regressions while being robust to system load.
* Tests observe() with mock messages and verifies the <2ms budget.
*/
import { describe, it, expect, beforeEach } from 'vitest';
@@ -17,7 +16,7 @@ describe('MemoryObserver', () => {
});
describe('observe() budget', () => {
it('processes tool-call messages within 50ms', () => {
it('processes tool-call messages within 2ms', () => {
const msg: MemoryIpcRequest = {
type: 'memory:tool-call',
toolName: 'Read',
@@ -29,10 +28,10 @@ describe('MemoryObserver', () => {
observer.observe(msg);
const elapsed = Number(process.hrtime.bigint() - start) / 1_000_000;
expect(elapsed).toBeLessThan(50);
expect(elapsed).toBeLessThan(2);
});
it('processes reasoning messages within 50ms', () => {
it('processes reasoning messages within 2ms', () => {
const msg: MemoryIpcRequest = {
type: 'memory:reasoning',
text: 'I need to read the file first to understand the structure.',
@@ -43,10 +42,10 @@ describe('MemoryObserver', () => {
observer.observe(msg);
const elapsed = Number(process.hrtime.bigint() - start) / 1_000_000;
expect(elapsed).toBeLessThan(50);
expect(elapsed).toBeLessThan(2);
});
it('processes step-complete messages within 50ms', () => {
it('processes step-complete messages within 2ms', () => {
const msg: MemoryIpcRequest = {
type: 'memory:step-complete',
stepNumber: 5,
@@ -56,7 +55,7 @@ describe('MemoryObserver', () => {
observer.observe(msg);
const elapsed = Number(process.hrtime.bigint() - start) / 1_000_000;
expect(elapsed).toBeLessThan(50);
expect(elapsed).toBeLessThan(2);
});
it('does not throw on malformed messages', () => {
@@ -156,6 +156,24 @@ function createProviderInstance(config: ProviderConfig) {
});
}
case SupportedProvider.Mascarade: {
// Mascarade LLM orchestration engine — OpenAI-compatible API
// Default: http://localhost:8100/v1 (Tower) or configurable
let mascaradeBaseURL = baseURL ?? 'http://localhost:8100/v1';
if (!mascaradeBaseURL.endsWith('/v1')) {
mascaradeBaseURL = mascaradeBaseURL.replace(/\/+$/, '') + '/v1';
}
return createOpenAICompatible({
name: 'mascarade',
apiKey: apiKey ?? 'mascarade-local',
baseURL: mascaradeBaseURL,
headers: {
...headers,
'Authorization': `Bearer ${apiKey ?? 'mascarade-local'}`,
},
});
}
default: {
const _exhaustive: never = provider;
throw new Error(`Unsupported provider: ${_exhaustive}`);
@@ -21,6 +21,7 @@ export const SupportedProvider = {
OpenRouter: 'openrouter',
ZAI: 'zai',
Ollama: 'ollama',
Mascarade: 'mascarade',
} as const;
export type SupportedProvider = (typeof SupportedProvider)[keyof typeof SupportedProvider];
@@ -1,593 +0,0 @@
/**
* Parallel Orchestrator Unit Tests
* ================================
*
* Comprehensive test suite for parallel-orchestrator.ts covering:
* - Constructor instantiation
* - review() method with mocked streamText
* - Parallel specialist execution
* - Verdict mapping
* - Abort signal handling
* - Specialist failure handling
*
* Note: Cross-validation tests are complex due to MD5-based finding IDs.
* The tests focus on orchestrator behavior that can be reliably tested.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// =============================================================================
// Mocks — must be declared before any imports that use them
// =============================================================================
const mockStreamText = vi.fn();
vi.mock('ai', () => ({
streamText: (...args: unknown[]) => mockStreamText(...args),
stepCountIs: vi.fn((count: number) => ({ type: 'step-count', maxSteps: count })),
Output: {
object: vi.fn((schema: unknown) => schema),
},
}));
const mockCreateSimpleClient = vi.fn();
vi.mock('../../../client/factory', () => ({
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
}));
const mockLoadPrompt = vi.fn();
vi.mock('../../../prompts/prompt-loader', () => ({
loadPrompt: (...args: unknown[]) => mockLoadPrompt(...args),
}));
const mockBuildToolRegistry = vi.fn();
vi.mock('../../../tools/build-registry', () => ({
buildToolRegistry: (...args: unknown[]) => mockBuildToolRegistry(...args),
}));
const mockGetSecurityProfile = vi.fn();
vi.mock('../../../security/security-profile', () => ({
getSecurityProfile: (...args: unknown[]) => mockGetSecurityProfile(...args),
}));
const mockGetAgentConfig = vi.fn();
vi.mock('../../../config/agent-configs', () => ({
getAgentConfig: (...args: unknown[]) => mockGetAgentConfig(...args),
}));
// =============================================================================
// Import after mocking
// =============================================================================
import {
ParallelOrchestratorReviewer,
MergeVerdict,
type ParallelOrchestratorConfig,
} from '../parallel-orchestrator';
import { createMockPRContext } from '@shared/test-utils';
import type { ProgressCallback } from '../pr-review-engine';
// =============================================================================
// Test Constants & Helpers
// =============================================================================
const mockModel = { modelId: 'claude-3-5-sonnet-20241022' };
function createMockClient(
model = mockModel,
systemPrompt = 'You are a helpful assistant.'
): { model: typeof mockModel; systemPrompt: string } {
return { model, systemPrompt };
}
function createConfig(overrides: Partial<ParallelOrchestratorConfig> = {}): ParallelOrchestratorConfig {
return {
repo: 'test/repo',
projectDir: '/tmp/test-project',
model: 'sonnet',
thinkingLevel: 'medium',
...overrides,
};
}
// Helper for creating streamText mock results with structured output
// Note: This differs from shared mockStreamText by including an 'output' field
// for AI SDK structured output (Output.object()), which is required for testing
// the parallel orchestrator's synthesis and validation phases.
function createMockStreamResult(
text: string,
object: unknown = null,
finishReason: 'stop' | 'length' | 'error' = 'stop'
): {
text: string;
output: unknown;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
finishReason: 'stop' | 'length' | 'error';
fullStream: AsyncIterable<unknown>;
} {
async function* generateFullStream() {
yield { type: 'text-delta', textDelta: text };
yield { type: 'finish', finishReason, usage: { promptTokens: 1000, completionTokens: 500 } };
}
return {
text,
output: object,
usage: { promptTokens: 1000, completionTokens: 500, totalTokens: 1500 },
finishReason,
fullStream: generateFullStream(),
};
}
// =============================================================================
// Tests: Constructor
// =============================================================================
describe('ParallelOrchestratorReviewer - Constructor', () => {
it('should create instance with config', () => {
const config = createConfig();
const reviewer = new ParallelOrchestratorReviewer(config);
expect(reviewer).toBeInstanceOf(ParallelOrchestratorReviewer);
});
it('should accept optional progress callback', () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const config = createConfig();
const reviewer = new ParallelOrchestratorReviewer(config, progressCallback);
expect(reviewer).toBeInstanceOf(ParallelOrchestratorReviewer);
});
it('should create instance without progress callback', () => {
const config = createConfig();
const reviewer = new ParallelOrchestratorReviewer(config);
expect(reviewer).toBeInstanceOf(ParallelOrchestratorReviewer);
});
});
// =============================================================================
// Tests: review() - Happy Path
// =============================================================================
describe('ParallelOrchestratorReviewer - review() Happy Path', () => {
let progressCallback: ProgressCallback;
beforeEach(() => {
vi.clearAllMocks();
progressCallback = vi.fn() as unknown as ProgressCallback;
// Setup default mocks
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
mockCreateSimpleClient.mockReturnValue(createMockClient());
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
mockGetSecurityProfile.mockReturnValue({
allowedCommands: [],
allowedPaths: [],
});
mockGetAgentConfig.mockReturnValue({
agentType: 'pr_specialist',
model: 'sonnet',
systemPrompt: 'You are a specialist.',
tools: [],
});
});
it('should run all 4 specialists in parallel', async () => {
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock all specialists to return no findings (simplest case)
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Complete', { findings: [] })
);
}
const result = await reviewer.review(context);
// Should call 4 specialists (no synthesis when no findings)
expect(mockStreamText).toHaveBeenCalledTimes(4);
expect(result.agentsInvoked).toEqual(['security', 'quality', 'logic', 'codebase-fit']);
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
});
it('should synthesize verdict correctly - READY_TO_MERGE with no findings', async () => {
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock all specialists to return no findings
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('No issues', { findings: [] })
);
}
const result = await reviewer.review(context);
// When no findings, synthesis returns early with default verdict
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
expect(result.verdictReasoning).toBe('No issues found by any specialist reviewer.');
expect(result.blockers).toEqual([]);
expect(result.findings).toEqual([]);
});
it('should call progress callback for each phase', async () => {
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock all specialists to return no findings
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Complete', { findings: [] })
);
}
await reviewer.review(context);
// Should have progress updates
expect(progressCallback).toHaveBeenCalled();
});
it('should handle findings from specialists', async () => {
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock all specialists to return no findings except one
mockStreamText
.mockResolvedValueOnce(createMockStreamResult('Complete', {
findings: [{ title: 'Security issue', file: 'src/test.ts', line: 10, severity: 'high', category: 'security' }],
}))
.mockResolvedValueOnce(createMockStreamResult('Complete', { findings: [] }))
.mockResolvedValueOnce(createMockStreamResult('Complete', { findings: [] }))
.mockResolvedValueOnce(createMockStreamResult('Complete', { findings: [] }));
const result = await reviewer.review(context);
// Should call 4 specialists
expect(mockStreamText).toHaveBeenCalledTimes(4);
expect(result.agentsInvoked).toEqual(['security', 'quality', 'logic', 'codebase-fit']);
});
// Note: Cross-validation tests require integration-level testing due to:
// - MD5-based finding ID generation (file:line:title hash)
// - Synthesis phase requires exact finding ID matching in kept_finding_ids
// - Cross-validation groups by file:lineGroup(5):category
// These are tested in integration tests with actual AI SDK responses
});
// =============================================================================
// Tests: Abort Signal Handling
// =============================================================================
describe('ParallelOrchestratorReviewer - Abort Signal', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
mockCreateSimpleClient.mockReturnValue(createMockClient());
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
mockGetSecurityProfile.mockReturnValue({
allowedCommands: [],
allowedPaths: [],
});
mockGetAgentConfig.mockReturnValue({
agentType: 'pr_specialist',
model: 'sonnet',
systemPrompt: 'You are a specialist.',
tools: [],
});
});
it('should handle abort signal during specialist execution', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
const abortController = new AbortController();
abortController.abort();
// Mock streamText to check for abort
mockStreamText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
if (abortSignal?.aborted) {
throw new Error('Aborted');
}
return createMockStreamResult('Complete', { findings: [] });
});
// Should handle abort gracefully
const result = await reviewer.review(context, abortController.signal);
expect(result).toBeDefined();
expect(result.findings).toEqual([]);
});
it('should stop processing when abort signal is received mid-execution', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
const abortController = new AbortController();
// Abort after first call
let callCount = 0;
mockStreamText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
callCount++;
if (callCount > 2) {
abortController.abort();
}
if (abortSignal?.aborted) {
return createMockStreamResult('Aborted', { findings: [] });
}
return createMockStreamResult('Complete', { findings: [] });
});
const result = await reviewer.review(context, abortController.signal);
expect(result).toBeDefined();
});
});
// =============================================================================
// Tests: Specialist Failure Handling
// =============================================================================
describe('ParallelOrchestratorReviewer - Specialist Failures', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
mockCreateSimpleClient.mockReturnValue(createMockClient());
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
mockGetSecurityProfile.mockReturnValue({
allowedCommands: [],
allowedPaths: [],
});
mockGetAgentConfig.mockReturnValue({
agentType: 'pr_specialist',
model: 'sonnet',
systemPrompt: 'You are a specialist.',
tools: [],
});
});
it('should handle specialist failures with Promise.allSettled', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock 2 specialists to succeed, 2 to fail
mockStreamText
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
.mockRejectedValueOnce(new Error('Specialist failed'))
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
.mockRejectedValueOnce(new Error('Another specialist failed'));
// Mock synthesis (no findings from successful specialists)
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Complete', {
verdict: 'ready_to_merge',
verdictReasoning: 'Partial success',
kept_finding_ids: [],
removed_finding_ids: [],
removal_reasons: {},
})
);
const result = await reviewer.review(context);
// Should complete with partial results
expect(result).toBeDefined();
expect(result.agentsInvoked).toEqual(['security', 'quality', 'logic', 'codebase-fit']);
});
it('should continue with remaining specialists when one fails', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock first specialist to fail, others to succeed
mockStreamText
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }));
// Mock synthesis
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Complete', {
verdict: 'ready_to_merge',
verdictReasoning: 'Completed with partial results',
kept_finding_ids: [],
removed_finding_ids: [],
removal_reasons: {},
})
);
const result = await reviewer.review(context);
expect(result).toBeDefined();
expect(result.agentsInvoked.length).toBe(4);
});
it('should handle all specialists failing', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock all specialists to fail
for (let i = 0; i < 4; i++) {
mockStreamText.mockRejectedValueOnce(new Error('All failed'));
}
// Mock synthesis to handle empty findings
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('No findings', {
verdict: 'ready_to_merge',
verdictReasoning: 'No specialists returned findings',
kept_finding_ids: [],
removed_finding_ids: [],
removal_reasons: {},
})
);
const result = await reviewer.review(context);
expect(result).toBeDefined();
expect(result.findings).toEqual([]);
});
});
// =============================================================================
// Tests: Edge Cases
// =============================================================================
describe('ParallelOrchestratorReviewer - Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
mockCreateSimpleClient.mockReturnValue(createMockClient());
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
mockGetSecurityProfile.mockReturnValue({
allowedCommands: [],
allowedPaths: [],
});
mockGetAgentConfig.mockReturnValue({
agentType: 'pr_specialist',
model: 'sonnet',
systemPrompt: 'You are a specialist.',
tools: [],
});
});
it('should handle empty findings from all specialists', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock all specialists to return empty findings
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('No issues', { findings: [] })
);
}
const result = await reviewer.review(context);
expect(result.findings).toEqual([]);
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
expect(result.blockers).toEqual([]);
});
// Note: Testing synthesis failure requires complex mock setup due to MD5-based finding IDs
// This is tested in integration tests instead
it('should handle complex PR with many files', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({
prNumber: 42,
changedFiles: Array.from({ length: 50 }, (_, i) => ({
path: `src/file${i}.ts`,
additions: 10,
deletions: 5,
status: 'modified',
patch: `@@ -1,1 +1,2 @@\n-line\n+line`,
})),
totalAdditions: 500,
totalDeletions: 250,
});
// Mock specialists
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Review complete', { findings: [] })
);
}
const result = await reviewer.review(context);
expect(result).toBeDefined();
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
});
});
// =============================================================================
// Tests: Result Structure
// =============================================================================
describe('ParallelOrchestratorReviewer - Result Structure', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
mockCreateSimpleClient.mockReturnValue(createMockClient());
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
mockGetSecurityProfile.mockReturnValue({
allowedCommands: [],
allowedPaths: [],
});
mockGetAgentConfig.mockReturnValue({
agentType: 'pr_specialist',
model: 'sonnet',
systemPrompt: 'You are a specialist.',
tools: [],
});
});
it('should return result with all required fields', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock specialists
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Complete', { findings: [] })
);
}
const result = await reviewer.review(context);
expect(result).toHaveProperty('findings');
expect(result).toHaveProperty('verdict');
expect(result).toHaveProperty('verdictReasoning');
expect(result).toHaveProperty('summary');
expect(result).toHaveProperty('blockers');
expect(result).toHaveProperty('agentsInvoked');
expect(Array.isArray(result.findings)).toBe(true);
expect(Array.isArray(result.blockers)).toBe(true);
expect(Array.isArray(result.agentsInvoked)).toBe(true);
expect(typeof result.verdict).toBe('string');
expect(typeof result.verdictReasoning).toBe('string');
expect(typeof result.summary).toBe('string');
});
it('should include summary with verdict', async () => {
const progressCallback = vi.fn() as unknown as ProgressCallback;
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
const context = createMockPRContext({ prNumber: 42 });
// Mock specialists
for (let i = 0; i < 4; i++) {
mockStreamText.mockResolvedValueOnce(
createMockStreamResult('Complete', { findings: [] })
);
}
const result = await reviewer.review(context);
expect(result.summary).toContain('Review:');
expect(result.summary).toContain('Ready To Merge');
expect(result.summary).toContain('✅');
});
});
File diff suppressed because it is too large Load Diff
@@ -1,23 +1,7 @@
/**
* Codex Authentication IPC Handlers
*
* IPC handlers for Codex OAuth authentication:
* - codex-auth-login - Start OAuth flow to authenticate with Codex
* - codex-auth-status - Get current authentication status
* - codex-auth-logout - Clear authentication and logout
*/
import { ipcMain } from 'electron';
import { startCodexOAuthFlow, getCodexAuthState, clearCodexAuth } from '../ai/auth/codex-oauth';
/**
* Register all Codex authentication-related IPC handlers
*/
export function registerCodexAuthHandlers(): void {
/**
* Start Codex OAuth login flow
* Opens browser for user authentication and returns the auth result
*/
ipcMain.handle('codex-auth-login', async () => {
try {
const result = await startCodexOAuthFlow();
@@ -27,10 +11,6 @@ export function registerCodexAuthHandlers(): void {
}
});
/**
* Get current Codex authentication status
* Returns the current auth state including tokens and user info
*/
ipcMain.handle('codex-auth-status', async () => {
try {
const state = await getCodexAuthState();
@@ -40,10 +20,6 @@ export function registerCodexAuthHandlers(): void {
}
});
/**
* Logout from Codex and clear authentication
* Removes stored tokens and clears the auth state
*/
ipcMain.handle('codex-auth-logout', async () => {
try {
await clearCodexAuth();
@@ -1,641 +0,0 @@
/**
* Unit tests for GitHub API utility functions
* Tests githubFetch() error handling, header configuration, and response parsing
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { githubFetch, githubFetchWithRetry, validateGitHubToken } from '../utils';
// Mock fetch globally
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
describe('githubFetch', () => {
beforeEach(() => {
mockFetch.mockClear();
});
afterEach(() => {
mockFetch.mockReset();
});
describe('successful requests', () => {
it('should successfully fetch data from GitHub API with proper headers', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ data: 'test', id: 123 }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await githubFetch('test-token', '/repos/test/repo/issues');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/repos/test/repo/issues',
expect.objectContaining({
headers: expect.objectContaining({
'Accept': 'application/vnd.github+json',
'Authorization': 'Bearer test-token',
'User-Agent': 'Aperant'
})
})
);
expect(result).toEqual({ data: 'test', id: 123 });
});
it('should handle full URLs without prefixing', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ result: 'success' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await githubFetch('test-token', 'https://api.github.com/users/test');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/users/test',
expect.any(Object)
);
});
it('should pass through custom options', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await githubFetch('test-token', '/test', {
method: 'POST',
body: JSON.stringify({ test: 'data' })
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ test: 'data' })
})
);
});
it('should handle empty token safely', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await githubFetch('', '/test');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/test',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer '
})
})
);
});
});
describe('error handling', () => {
it('should throw detailed error for 500 Internal Server Error', async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => 'Internal Server Error: Database connection failed',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (500): Internal Server Error: Database connection failed');
});
it('should throw detailed error for 401 Unauthorized', async () => {
const mockResponse = {
ok: false,
status: 401,
text: async () => '{"message": "Bad credentials", "documentation_url": "https://docs.github.com/rest"}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('invalid-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (401): Bad credentials');
});
it('should throw detailed error for 404 Not Found', async () => {
const mockResponse = {
ok: false,
status: 404,
text: async () => '{"message": "Not Found", "documentation_url": "https://docs.github.com/rest"}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/nonexistent/repo/issues')
).rejects.toThrow('GitHub API error (404): Not Found');
});
it('should throw detailed error for 429 Rate Limit', async () => {
const mockResponse = {
ok: false,
status: 429,
text: async () => '{"message": "API rate limit exceeded for user ID 123", "documentation_url": "https://docs.github.com/rest"}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (429): API rate limit exceeded for user ID 123');
});
it('should handle empty response body in error case', async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => '',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (500):');
});
it('should handle failed text() parsing with fallback message', async () => {
const mockResponse = {
ok: false,
status: 503,
text: async () => {
throw new Error('Network error');
},
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (503): Request failed');
});
it('should include User-Agent header in error requests', async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => 'Server Error',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
try {
await githubFetch('test-token', '/test');
} catch (e) {
// Expected to throw
}
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': 'Aperant'
})
})
);
});
it('should handle HTML error responses', async () => {
const mockResponse = {
ok: false,
status: 502,
text: async () => '<html><body>Bad Gateway</body></html>',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (502): <html><body>Bad Gateway</body></html>');
});
it('should handle JSON error responses', async () => {
const mockResponse = {
ok: false,
status: 422,
text: async () => '{"message": "Validation Failed", "errors": [{"resource": "Issue", "field": "title", "code": "missing"}]}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/repos/test/repo/issues')
).rejects.toThrow('GitHub API error (422): Validation Failed');
});
it('should preserve error details with status and body', async () => {
const errorBody = '{"message": "Repository access denied", "documentation_url": "https://docs.github.com/rest"}';
const mockResponse = {
ok: false,
status: 403,
text: async () => errorBody,
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
try {
await githubFetch('test-token', '/repos/private/repo/issues');
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('403');
expect((error as Error).message).toContain('Repository access denied');
}
});
});
describe('edge cases', () => {
it('should handle response with no body text', async () => {
const mockResponse = {
ok: false,
status: 204,
text: async () => '',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetch('test-token', '/test')
).rejects.toThrow('GitHub API error (204):');
});
it('should handle null token', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await githubFetch(null as unknown as string, '/test');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/test',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer '
})
})
);
});
it('should handle undefined token', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await githubFetch(undefined as unknown as string, '/test');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/test',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer '
})
})
);
});
});
});
describe('githubFetchWithRetry', () => {
beforeEach(() => {
mockFetch.mockClear();
});
it('should retry on 500 errors with exponential backoff', async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
headers: new Headers()
};
const mockSuccess = {
ok: true,
status: 200,
json: async () => ({ data: 'success' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse)
.mockResolvedValueOnce(mockResponse)
.mockResolvedValueOnce(mockSuccess);
const result = await githubFetchWithRetry('test-token', '/test');
expect(mockFetch).toHaveBeenCalledTimes(3);
expect(result).toEqual({ data: 'success' });
}, 10000);
it('should NOT retry on 401 Unauthorized', async () => {
const mockResponse = {
ok: false,
status: 401,
text: async () => '{"message": "Bad credentials"}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetchWithRetry('test-token', '/test')
).rejects.toThrow('GitHub API error (401): Bad credentials');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should NOT retry on 404 Not Found', async () => {
const mockResponse = {
ok: false,
status: 404,
text: async () => '{"message": "Not Found"}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetchWithRetry('test-token', '/test')
).rejects.toThrow('GitHub API error (404): Not Found');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should NOT retry on 429 Rate Limit', async () => {
const mockResponse = {
ok: false,
status: 429,
text: async () => '{"message": "API rate limit exceeded"}',
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse);
await expect(
githubFetchWithRetry('test-token', '/test')
).rejects.toThrow('GitHub API error (429): API rate limit exceeded');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should respect custom maxRetries', async () => {
const mockResponse = {
ok: false,
status: 503,
text: async () => 'Service Unavailable',
headers: new Headers()
};
mockFetch.mockResolvedValue(mockResponse);
await expect(
githubFetchWithRetry('test-token', '/test', {}, 1)
).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should retry on 502 Bad Gateway', async () => {
const mockResponse = {
ok: false,
status: 502,
text: async () => 'Bad Gateway',
headers: new Headers()
};
const mockSuccess = {
ok: true,
status: 200,
json: async () => ({ data: 'success' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse)
.mockResolvedValueOnce(mockSuccess);
const result = await githubFetchWithRetry('test-token', '/test');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result).toEqual({ data: 'success' });
});
it('should retry on 503 Service Unavailable', async () => {
const mockResponse = {
ok: false,
status: 503,
text: async () => 'Service Unavailable',
headers: new Headers()
};
const mockSuccess = {
ok: true,
status: 200,
json: async () => ({ data: 'success' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockResponse)
.mockResolvedValueOnce(mockSuccess);
const result = await githubFetchWithRetry('test-token', '/test');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result).toEqual({ data: 'success' });
});
it('should throw after max retries exhausted', async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
headers: new Headers()
};
mockFetch.mockResolvedValue(mockResponse);
await expect(
githubFetchWithRetry('test-token', '/test', {}, 2)
).rejects.toThrow('GitHub API error (500): Internal Server Error');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('should pass custom options to githubFetch', async () => {
const mockSuccess = {
ok: true,
status: 200,
json: async () => ({ data: 'success' }),
headers: new Headers()
};
mockFetch.mockResolvedValueOnce(mockSuccess);
const result = await githubFetchWithRetry('test-token', '/test', {
method: 'POST',
body: JSON.stringify({ test: 'data' })
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ test: 'data' })
})
);
expect(result).toEqual({ data: 'success' });
});
});
describe('validateGitHubToken', () => {
beforeEach(() => {
mockFetch.mockClear();
});
it('should return valid: true for good token', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ login: 'testuser' }),
};
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await validateGitHubToken('good-token');
expect(result).toEqual({ valid: true });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.github.com/user',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer good-token',
'User-Agent': 'Aperant'
})
})
);
});
it('should return valid: false with error message for 401', async () => {
const mockResponse = {
ok: false,
status: 401,
text: async () => '{"message": "Bad credentials"}',
};
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await validateGitHubToken('bad-token');
expect(result.valid).toBe(false);
expect(result.error).toContain('401');
expect(result.error).toContain('Bad credentials');
});
it('should return valid: false for 403 Forbidden', async () => {
const mockResponse = {
ok: false,
status: 403,
text: async () => '{"message": "Forbidden"}',
};
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await validateGitHubToken('expired-token');
expect(result.valid).toBe(false);
expect(result.error).toContain('403');
});
it('should handle network errors', async () => {
// Mock all retry attempts (4 total: initial + 3 retries) since improved error classification now detects this as retryable
for (let i = 0; i < 4; i++) {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
}
const result = await validateGitHubToken('test-token');
expect(result.valid).toBe(false);
expect(result.error).toContain('Network error');
}, 10000); // Increase timeout to accommodate retry delays (1s + 2s + 4s = 7s total)
it('should handle empty token safely', async () => {
const result = await validateGitHubToken('');
expect(result.valid).toBe(false);
expect(result.error).toBe('Token is empty');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should mark 5xx errors as retryable', async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
};
// Provide 4 mocks for retry attempts (0, 1, 2, 3)
mockFetch.mockResolvedValueOnce(mockResponse);
mockFetch.mockResolvedValueOnce(mockResponse);
mockFetch.mockResolvedValueOnce(mockResponse);
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await validateGitHubToken('test-token');
expect(result.valid).toBe(false);
expect(result.error).toContain('500');
expect(result.retryable).toBe(true);
}, 10000); // 10s timeout to accommodate retry delays
it('should handle failed text() parsing with fallback', async () => {
// Create fresh mock objects for each retry attempt
const createMockResponse = () => ({
ok: false,
status: 500,
text: async () => {
throw new Error('Parse error');
},
});
// Provide 4 mocks for retry attempts (0, 1, 2, 3)
mockFetch.mockResolvedValueOnce(createMockResponse());
mockFetch.mockResolvedValueOnce(createMockResponse());
mockFetch.mockResolvedValueOnce(createMockResponse());
mockFetch.mockResolvedValueOnce(createMockResponse());
const result = await validateGitHubToken('test-token');
expect(result.valid).toBe(false);
expect(result.error).toContain('Unknown error');
expect(result.retryable).toBe(true);
}, 10000); // 10s timeout to accommodate retry delays
it('should mark 401/403 as non-retryable auth failures', async () => {
const mockResponse = {
ok: false,
status: 401,
text: async () => 'Bad credentials',
};
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await validateGitHubToken('test-token');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid credentials');
expect(result.retryable).toBe(false);
});
});
@@ -1,398 +0,0 @@
/**
* Integration tests for PR Review IPC handlers
*
* Tests the complete flow from IPC trigger to result on disk:
* - IPC handler invocation (not manual file writes)
* - GitHub API calls (mocked but actually invoked)
* - PR review engine execution (mocked but actually invoked)
* - File system result storage (via handler, not manual)
* - Error scenario handling
*
* This validates end-to-end integration without making real API calls.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ipcMain } from 'electron';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { registerPRHandlers } from '../pr-handlers';
import * as githubUtils from '../utils';
import type { PRReviewResult, PRReviewProgress } from '../pr-handlers';
// =============================================================================
// Mocks - Must be declared before imports
// =============================================================================
const mockStreamText = vi.fn();
vi.mock('ai', () => ({
streamText: (...args: unknown[]) => mockStreamText(...args),
stepCountIs: vi.fn((n: number) => ({ __stepCount: n })),
generateObject: vi.fn(),
generateText: vi.fn(),
}));
const mockCreateSimpleClient = vi.fn();
vi.mock('../../ai/client/factory', () => ({
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
}));
// Mock github utils
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils');
return {
...actual,
githubFetch: vi.fn(),
githubFetchWithRetry: vi.fn(),
validateGitHubToken: vi.fn(),
clearETagCache: vi.fn(),
getGitHubConfig: vi.fn(),
normalizeRepoReference: vi.fn((repo: string) => repo),
githubGraphQL: vi.fn(),
};
});
// Mock memory service
vi.mock('../../context/memory-service-factory', () => ({
getMemoryService: vi.fn(() => ({
store: vi.fn().mockResolvedValue(undefined),
})),
}));
// Mock Sentry
vi.mock('../../sentry', () => ({
safeBreadcrumb: vi.fn(),
safeCaptureException: vi.fn(),
}));
// Mock settings utils
vi.mock('../../settings-utils', () => ({
readSettingsFile: vi.fn(() => ({
featureModels: { githubPrs: 'sonnet' },
featureThinking: { githubPrs: 'medium' },
})),
}));
// =============================================================================
// Test Constants & Helpers
// =============================================================================
const TEST_PR_NUMBER = 42;
const TEST_REPO = 'test/repo';
const TEST_TOKEN = 'test-token';
const mockMainWindow = {
webContents: {
send: vi.fn(),
isDestroyed: () => false,
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
removeAllListeners: vi.fn(),
},
isDestroyed: () => false,
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
removeAllListeners: vi.fn(),
id: 1,
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any -- Mock for testing
function createMockProject(tempDir: string) {
return {
id: 'test-project-id',
name: 'test-project',
path: tempDir,
autoBuildPath: '.auto-claude',
settings: {
model: 'sonnet',
memoryBackend: 'memory' as const,
linearSync: false,
notifications: {
onTaskComplete: false,
onTaskFailed: false,
onReviewNeeded: false,
sound: false,
},
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
function createMockGitHubPRResponse() {
return {
number: TEST_PR_NUMBER,
title: 'Test PR',
body: 'Test description',
state: 'open',
user: { login: 'testuser' },
head: { ref: 'feature', sha: 'abc123' },
base: { ref: 'main' },
additions: 10,
deletions: 5,
labels: [],
};
}
function createMockFilesResponse() {
return [
{
filename: 'src/test.ts',
additions: 10,
deletions: 5,
status: 'modified',
patch: '@@ -1,3 +1,4 @@\n-old line\n+new line',
},
];
}
function createMockCommitsResponse() {
return [
{
sha: 'abc123',
commit: {
message: 'Test commit\n\nThis is a test',
committer: { date: new Date().toISOString() },
},
},
];
}
function createMockFinding() {
return {
id: 'PR-12345678',
severity: 'medium' as const,
category: 'quality' as const,
title: 'Test finding',
description: 'This is a test finding',
file: 'src/test.ts',
line: 10,
suggestedFix: 'Fix the issue',
fixable: true,
sourceAgents: ['quality'],
crossValidated: false,
};
}
function createMockOrchestratorResult() {
return {
findings: [createMockFinding()],
verdict: 'ready_to_merge' as const,
verdictReasoning: 'Code looks good',
summary: 'Test summary',
blockers: [],
agentsInvoked: ['quality', 'security', 'logic', 'codebase-fit'],
reviewedCommitSha: 'abc123',
};
}
// =============================================================================
// Test Setup
// =============================================================================
describe('PR Review Integration Tests', () => {
let tempDir: string;
let project: ReturnType<typeof createMockProject>;
let mockGetMainWindow: () => typeof mockMainWindow;
let handlersRegistered = false;
beforeEach(async () => {
// Create temporary directory for test project
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-review-test-'));
project = createMockProject(tempDir);
// Create .auto-claude directory structure
const autoClaudeDir = path.join(tempDir, '.auto-claude');
fs.mkdirSync(autoClaudeDir, { recursive: true });
fs.mkdirSync(path.join(autoClaudeDir, 'github', 'pr'), { recursive: true });
// Create .env file with GitHub config
const envPath = path.join(autoClaudeDir, '.env');
fs.writeFileSync(
envPath,
`GITHUB_TOKEN=${TEST_TOKEN}\nGITHUB_REPO=${TEST_REPO}\n`,
'utf-8'
);
// Mock getGitHubConfig to return test config
vi.mocked(githubUtils.getGitHubConfig).mockImplementation((proj) => {
if (proj.id === project.id) {
return {
token: TEST_TOKEN,
repo: TEST_REPO,
};
}
return null;
});
// Clear all mocks
vi.clearAllMocks();
// Setup main window mock
mockGetMainWindow = () => mockMainWindow;
// Register handlers only once
if (!handlersRegistered) {
registerPRHandlers(mockGetMainWindow);
handlersRegistered = true;
}
});
afterEach(() => {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
// Remove ipcMain.handle() handlers (must use removeHandler for handle() registrations)
const handlerEmitter = ipcMain as { removeHandler?: (channel: string) => void };
const channelsToRemove = [
'github:pr:review',
'github:pr:reviewProgress',
'github:pr:reviewError',
'github:pr:reviewComplete',
'github:pr:logsUpdated',
'github:pr:get',
'github:authChanged',
];
channelsToRemove.forEach((channel) => handlerEmitter.removeHandler?.(channel));
// Remove ipcMain.on() listeners (if any)
ipcMain.removeAllListeners('github:pr:reviewProgress');
ipcMain.removeAllListeners('github:pr:reviewError');
ipcMain.removeAllListeners('github:pr:reviewComplete');
ipcMain.removeAllListeners('github:pr:logsUpdated');
});
// =============================================================================
// Integration Validation Tests
// =============================================================================
describe('Integration Validation', () => {
it('should register IPC handlers correctly', () => {
// The handlers are registered in beforeEach, so they should be available
const reviewListeners = ipcMain.listenerCount('github:pr:review');
expect(reviewListeners).toBeGreaterThan(0);
const authListeners = ipcMain.listenerCount('github:authChanged');
expect(authListeners).toBeGreaterThan(0);
});
it('should have main window getter functional', () => {
const mainWindow = mockGetMainWindow();
expect(mainWindow).toBeDefined();
expect(mainWindow.isDestroyed()).toBe(false);
});
it('should return GitHub config for valid project', () => {
const config = githubUtils.getGitHubConfig(project);
expect(config).not.toBeNull();
expect(config!.token).toBe(TEST_TOKEN);
expect(config!.repo).toBe(TEST_REPO);
});
it('should return null for invalid project', () => {
const invalidProject = {
id: 'invalid',
name: 'invalid',
path: '/invalid',
autoBuildPath: '',
settings: {
model: 'sonnet',
memoryBackend: 'memory' as const,
linearSync: false,
notifications: {
onTaskComplete: false,
onTaskFailed: false,
onReviewNeeded: false,
sound: false,
},
},
createdAt: new Date(),
updatedAt: new Date(),
};
const config = githubUtils.getGitHubConfig(invalidProject);
expect(config).toBeNull();
});
});
// =============================================================================
// File System Integration Tests
// =============================================================================
describe('File System Integration', () => {
it('should verify directory structure is created correctly', () => {
const autoClaudeDir = path.join(tempDir, '.auto-claude');
expect(fs.existsSync(autoClaudeDir)).toBe(true);
const githubDir = path.join(autoClaudeDir, 'github');
expect(fs.existsSync(githubDir)).toBe(true);
const prDir = path.join(githubDir, 'pr');
expect(fs.existsSync(prDir)).toBe(true);
});
it('should verify file system operations work correctly', () => {
// Test write operation
const testFile = path.join(tempDir, '.auto-claude', 'github', 'pr', 'test.json');
const testData = { test: 'data', number: 42 };
fs.writeFileSync(testFile, JSON.stringify(testData), 'utf-8');
// Verify file exists and content is correct
expect(fs.existsSync(testFile)).toBe(true);
const readData = JSON.parse(fs.readFileSync(testFile, 'utf-8'));
expect(readData.test).toBe('data');
expect(readData.number).toBe(42);
});
});
// =============================================================================
// Mock Configuration Tests
// =============================================================================
describe('Mock Configuration', () => {
it('should create valid mock GitHub PR response', () => {
const response = createMockGitHubPRResponse();
expect(response.number).toBe(TEST_PR_NUMBER);
expect(response.title).toBe('Test PR');
expect(response.state).toBe('open');
expect(response.user.login).toBe('testuser');
});
it('should create valid mock files response', () => {
const response = createMockFilesResponse();
expect(response).toHaveLength(1);
expect(response[0].filename).toBe('src/test.ts');
expect(response[0].status).toBe('modified');
});
it('should create valid mock commits response', () => {
const response = createMockCommitsResponse();
expect(response).toHaveLength(1);
expect(response[0].sha).toBe('abc123');
});
it('should create valid mock finding', () => {
const finding = createMockFinding();
expect(finding.id).toBe('PR-12345678');
expect(finding.severity).toBe('medium');
expect(finding.category).toBe('quality');
expect(finding.file).toBe('src/test.ts');
});
it('should create valid mock orchestrator result', () => {
const result = createMockOrchestratorResult();
expect(result.findings).toHaveLength(1);
expect(result.verdict).toBe('ready_to_merge');
expect(result.summary).toBe('Test summary');
expect(result.agentsInvoked).toContain('quality');
});
});
});
@@ -146,18 +146,12 @@ vi.mock('../../../ai/runners/github/duplicate-detector', () => ({
})),
}));
// Hoisted mock functions for circular dependency resolution
const mockGithubFetch = vi.fn();
const mockGithubFetchWithRetry = vi.fn();
vi.mock('../utils', () => ({
getGitHubConfig: vi.fn(() => ({
token: 'mock-github-token',
repo: 'owner/repo',
})),
githubFetch: mockGithubFetch,
githubFetchWithRetry: mockGithubFetchWithRetry,
validateGitHubToken: vi.fn(() => Promise.resolve({ valid: true })),
githubFetch: vi.fn(),
normalizeRepoReference: vi.fn((r: string) => r),
}));
@@ -269,8 +263,7 @@ function createProject(): Project {
describe('GitHub TypeScript runner usage', () => {
beforeEach(() => {
// Reset all mocks to clean state (resets both call history and implementations)
vi.resetAllMocks();
vi.clearAllMocks();
mockIpcMain.reset();
projectRef.current = createProject();
});
@@ -287,9 +280,12 @@ describe('GitHub TypeScript runner usage', () => {
});
it('calls ParallelOrchestratorReviewer for PR review', async () => {
const { githubFetch } = await import('../utils');
const githubFetchMock = vi.mocked(githubFetch);
// Mock GitHub API calls made by the PR review handler
// Note: order matters — more specific patterns must come before general ones
const mockImplementation = async (_token: string, endpoint: string) => {
githubFetchMock.mockImplementation(async (_token: string, endpoint: string) => {
if (endpoint === '/user') return { login: 'testuser' };
if (endpoint.includes('/assignees')) return {};
if (endpoint.includes('/check-runs')) return { check_runs: [], total_count: 0 };
@@ -316,15 +312,7 @@ describe('GitHub TypeScript runner usage', () => {
labels: [],
};
return {};
};
// TODO: Currently the code uses githubFetch in some places.
// This test stubs both to avoid test failures, but ideally the code
// should be refactored to consistently use githubFetchWithRetry.
// When that's done, this test should only stub mockGithubFetchWithRetry
// and validate that mockGithubFetch is NOT called.
mockGithubFetch.mockImplementation(mockImplementation);
mockGithubFetchWithRetry.mockImplementation(mockImplementation);
});
// Return the shape that ParallelOrchestratorReviewer.review() produces
mockOrchestratorReview.mockResolvedValue({
@@ -15,9 +15,7 @@
*/
import type { BrowserWindow } from 'electron';
import { ipcMain } from 'electron';
import { AgentManager } from '../../agent';
import { IPC_CHANNELS } from '../../../shared/constants';
import { registerRepositoryHandlers } from './repository-handlers';
import { registerIssueHandlers } from './issue-handlers';
import { registerInvestigationHandlers } from './investigation-handlers';
@@ -30,12 +28,11 @@ import { registerTriageHandlers } from './triage-handlers';
/**
* Register all GitHub-related IPC handlers
* @returns Cleanup function to remove all listeners
*/
export function registerGithubHandlers(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): () => void {
): void {
registerRepositoryHandlers();
registerIssueHandlers();
registerInvestigationHandlers(agentManager, getMainWindow);
@@ -45,27 +42,6 @@ export function registerGithubHandlers(
registerAutoFixHandlers(agentManager, getMainWindow);
registerPRHandlers(getMainWindow);
registerTriageHandlers(getMainWindow);
// Return cleanup - removes EventEmitter listeners from sub-handlers
// Note: This only cleans up listeners added via ipcMain.on()
return (): void => {
const emitter = ipcMain as { removeAllListeners?: (event: string) => void };
// Autofix listeners
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTOFIX_START);
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTOFIX_BATCH);
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW);
// Auth changed listener
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTH_CHANGED);
// PR review listeners
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_PR_REVIEW);
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_PR_FOLLOWUP_REVIEW);
// Triage listeners
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_TRIAGE_RUN);
};
}
// Re-export utilities for potential external use
@@ -17,7 +17,7 @@ import {
DEFAULT_FEATURE_MODELS,
DEFAULT_FEATURE_THINKING,
} from "../../../shared/constants";
import { getGitHubConfig, githubFetch, githubFetchWithRetry, validateGitHubToken, normalizeRepoReference } from "./utils";
import { getGitHubConfig, githubFetch, normalizeRepoReference } from "./utils";
import { readSettingsFile } from "../../settings-utils";
import { getAugmentedEnv } from "../../env-utils";
import { getMemoryService } from "../context/memory-service-factory";
@@ -1466,8 +1466,8 @@ async function fetchPRContext(
config: { token: string; repo: string },
prNumber: number
): Promise<PRContext> {
// Fetch PR metadata (with retry for 5xx errors)
const pr = (await githubFetchWithRetry(
// Fetch PR metadata
const pr = (await githubFetch(
config.token,
`/repos/${config.repo}/pulls/${prNumber}`
)) as {
@@ -1483,8 +1483,8 @@ async function fetchPRContext(
labels?: Array<{ name: string }>;
};
// Fetch files with patches (with retry for 5xx errors)
const files = (await githubFetchWithRetry(
// Fetch files with patches
const files = (await githubFetch(
config.token,
`/repos/${config.repo}/pulls/${prNumber}/files?per_page=100`
)) as Array<{
@@ -1495,8 +1495,8 @@ async function fetchPRContext(
patch?: string;
}>;
// Fetch commits (with retry for 5xx errors)
const commits = (await githubFetchWithRetry(
// Fetch commits
const commits = (await githubFetch(
config.token,
`/repos/${config.repo}/pulls/${prNumber}/commits?per_page=100`
)) as Array<{
@@ -2075,7 +2075,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
try {
await withProjectOrNull(projectId, async (project) => {
const { sendProgress, sendComplete, sendError } = createIPCCommunicators<
const { sendProgress, sendComplete } = createIPCCommunicators<
PRReviewProgress,
PRReviewResult
>(
@@ -2125,29 +2125,6 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
const config = getGitHubConfig(project);
if (config) {
try {
// Validate GitHub token before making API calls
const tokenValidation = await validateGitHubToken(config.token);
if (!tokenValidation.valid) {
debugLog("GitHub token validation failed", {
error: tokenValidation.error,
retryable: tokenValidation.retryable,
});
// Clean up registry since we registered before CI wait
runningReviews.delete(reviewKey);
ciWaitAbortControllers.delete(reviewKey);
// Use retryable field to distinguish transient failures from auth errors
const errorMessage = tokenValidation.retryable
? `GitHub is temporarily unavailable: ${tokenValidation.error || 'Please try again.'}`
: `GitHub authentication failed: ${tokenValidation.error || 'Invalid token'}`;
stateManager.handleError(projectId, prNumber, errorMessage);
sendError({ prNumber, error: errorMessage });
return;
}
debugLog("GitHub token validated successfully");
// Get current user
const user = (await githubFetch(config.token, "/user")) as { login: string };
debugLog("Auto-assigning user to PR", { prNumber, username: user.login });
@@ -11,43 +11,9 @@ import { parseEnvFile } from '../utils';
import type { GitHubConfig } from './types';
import { getAugmentedEnv } from '../../env-utils';
import { getToolPath } from '../../cli-tool-manager';
import { debugLog } from './utils/logger';
const execFileAsync = promisify(execFile);
/**
* Retry configuration for githubFetchWithRetry
*/
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 1000;
const RETRY_EXPONENTIAL_BASE = 2;
/**
* Parse GitHub API error response to extract detailed error message
* @param errorBody - Raw error response body
* @returns Detailed error message from JSON or original body
*/
function parseGitHubErrorResponse(errorBody: string): string {
try {
const errorJson = JSON.parse(errorBody);
if (errorJson.message) {
return errorJson.message;
}
} catch {
// Not JSON, use original body
}
return errorBody;
}
/**
* Sanitize token to ensure it's a valid non-empty string
* @param token - Token to sanitize
* @returns Empty string if token is invalid, otherwise the token
*/
function sanitizeToken(token: string): string {
return typeof token === 'string' && token.length > 0 ? token : '';
}
/**
* ETag cache entry for conditional requests
*/
@@ -295,7 +261,7 @@ export async function githubFetch(
: `https://api.github.com${endpoint}`;
// CodeQL: file data in outbound request - validate token is a non-empty string before use
const safeToken = sanitizeToken(token);
const safeToken = typeof token === 'string' && token.length > 0 ? token : '';
const response = await fetch(url, {
...options,
headers: {
@@ -308,8 +274,7 @@ export async function githubFetch(
if (!response.ok) {
const errorBody = await response.text().catch(() => 'Request failed');
const detailedError = parseGitHubErrorResponse(errorBody);
throw new Error(`GitHub API error (${response.status}): ${detailedError}`);
throw new Error(`GitHub API error: ${response.status} - ${errorBody}`);
}
return response.json();
@@ -362,8 +327,7 @@ export async function githubFetchWithETag(
if (!response.ok) {
const errorBody = await response.text().catch(() => 'Request failed');
const detailedError = parseGitHubErrorResponse(errorBody);
throw new Error(`GitHub API error (${response.status}): ${detailedError}`);
throw new Error(`GitHub API error: ${response.status} - ${errorBody}`);
}
const data = await response.json();
@@ -389,132 +353,3 @@ export async function githubFetchWithETag(
rateLimitInfo
};
}
/**
* Make a GitHub API request with retry logic for 5xx errors
* 500, 502, 503, 504 errors are automatically retried with exponential backoff
*/
export async function githubFetchWithRetry(
token: string,
endpoint: string,
options: RequestInit = {},
maxRetries = MAX_RETRIES
): Promise<unknown> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await githubFetch(token, endpoint, options);
} catch (error) {
lastError = error as Error;
const errorMessage = (error as Error).message;
// Only retry on 5xx errors (server errors, not client errors)
const isServerError = errorMessage.match(/GitHub API error \(5\d\d\):/);
if (isServerError && attempt < maxRetries) {
const delay = RETRY_EXPONENTIAL_BASE ** attempt * RETRY_BASE_DELAY_MS;
debugLog('GitHub API', `5xx error detected, retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
break;
}
}
throw lastError;
}
/**
* Validate GitHub token by making a lightweight API call
*
* Retries transient failures (5xx errors, network issues) to avoid treating
* temporary GitHub outages as permanent credential failures.
*
* @param token - GitHub token to validate
* @returns Token validation result with retryable flag for transient errors
*/
export async function validateGitHubToken(
token: string
): Promise<{ valid: boolean; error?: string; retryable?: boolean }> {
const safeToken = sanitizeToken(token);
if (!safeToken) {
return { valid: false, error: 'Token is empty', retryable: false };
}
let lastError: Error | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${safeToken}`,
'User-Agent': 'Aperant',
},
});
if (response.ok) {
return { valid: true };
}
// Auth failures (401/403) are permanent, not retryable
if (response.status === 401 || response.status === 403) {
const errorBody = await response.text().catch(() => 'Unknown error');
return {
valid: false,
error: `Invalid credentials: ${response.status} - ${errorBody.substring(0, 100)}`,
retryable: false
};
}
// 5xx server errors are retryable
if (response.status >= 500 && attempt < MAX_RETRIES) {
const delay = RETRY_EXPONENTIAL_BASE ** attempt * RETRY_BASE_DELAY_MS;
debugLog('GitHub API', `5xx error during token validation, retrying in ${delay}ms... (attempt ${attempt + 1}/${MAX_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// Other client errors (4xx except 401/403) are not retryable
const errorBody = await response.text().catch(() => 'Unknown error');
return {
valid: false,
error: `Token validation failed: ${response.status} - ${errorBody.substring(0, 100)}`,
retryable: response.status >= 500
};
} catch (error) {
const err = error as Error & { cause?: { code?: string } };
lastError = err;
const errorMessage = err.message;
const causeCode = err.cause?.code;
// Retry transport failures, but do not retry explicit aborts
const isNetworkError =
err.name !== 'AbortError' &&
(
/fetch failed|network\s*error/i.test(errorMessage) ||
['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'].includes(causeCode ?? '')
);
if (isNetworkError && attempt < MAX_RETRIES) {
const delay = RETRY_EXPONENTIAL_BASE ** attempt * RETRY_BASE_DELAY_MS;
debugLog('GitHub API', `Network error during token validation, retrying in ${delay}ms... (attempt ${attempt + 1}/${MAX_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
return {
valid: false,
error: errorMessage,
retryable: isNetworkError
};
}
}
// Should not reach here, but handle the case
return {
valid: false,
error: lastError?.message || 'Unknown error after retries',
retryable: true
};
}
@@ -122,7 +122,6 @@ export function registerIdeationHandlers(
agentManager.on("ideation-stopped", handleIdeationStopped);
return (): void => {
// Remove agentManager event listeners
agentManager.off("ideation-progress", handleIdeationProgress);
agentManager.off("ideation-log", handleIdeationLog);
agentManager.off("ideation-type-complete", handleIdeationTypeComplete);
@@ -130,10 +129,5 @@ export function registerIdeationHandlers(
agentManager.off("ideation-complete", handleIdeationComplete);
agentManager.off("ideation-error", handleIdeationError);
agentManager.off("ideation-stopped", handleIdeationStopped);
// Remove ipcMain event listeners
const emitter = ipcMain as { removeAllListeners?: (event: string) => void };
emitter.removeAllListeners?.(IPC_CHANNELS.IDEATION_GENERATE);
emitter.removeAllListeners?.(IPC_CHANNELS.IDEATION_REFRESH);
};
}
@@ -6,7 +6,7 @@
* - Switching to OAuth (null profileId)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { APIProfile, ProfilesFile } from '@shared/types/profile';
// Hoist mocked functions to avoid circular dependency in atomicModifyProfiles
@@ -72,15 +72,9 @@ function getTestConnectionHandler() {
}
describe('profile-handlers - setActiveProfile', () => {
let cleanup: () => void;
beforeEach(() => {
vi.clearAllMocks();
cleanup = registerProfileHandlers();
});
afterEach(() => {
cleanup?.();
registerProfileHandlers();
});
const mockProfiles: APIProfile[] = [
{
@@ -226,15 +220,9 @@ describe('profile-handlers - setActiveProfile', () => {
});
describe('profile-handlers - testConnection', () => {
let cleanup: () => void;
beforeEach(() => {
vi.clearAllMocks();
cleanup = registerProfileHandlers();
});
afterEach(() => {
cleanup?.();
registerProfileHandlers();
});
describe('successful connection tests', () => {
@@ -34,9 +34,8 @@ const activeDiscoverModelsRequests = new Map<number, AbortController>();
/**
* Register all profile-related IPC handlers
* @returns Cleanup function to remove all handlers
*/
export function registerProfileHandlers(): () => void {
export function registerProfileHandlers(): void {
/**
* Get all profiles
*/
@@ -355,35 +354,4 @@ export function registerProfileHandlers(): () => void {
}
}
);
// Return cleanup function to remove IPC handlers and EventEmitter listeners
return (): void => {
// Abort and clear in-flight requests to avoid dangling work during teardown
for (const controller of activeTestConnections.values()) {
controller.abort();
}
activeTestConnections.clear();
for (const controller of activeDiscoverModelsRequests.values()) {
controller.abort();
}
activeDiscoverModelsRequests.clear();
// Remove ipcMain.handle() registrations
const handlerEmitter = ipcMain as { removeHandler?: (channel: string) => void };
[
IPC_CHANNELS.PROFILES_GET,
IPC_CHANNELS.PROFILES_SAVE,
IPC_CHANNELS.PROFILES_UPDATE,
IPC_CHANNELS.PROFILES_DELETE,
IPC_CHANNELS.PROFILES_SET_ACTIVE,
IPC_CHANNELS.PROFILES_TEST_CONNECTION,
IPC_CHANNELS.PROFILES_DISCOVER_MODELS,
].forEach((channel) => handlerEmitter.removeHandler?.(channel));
// Remove on() registrations (these are EventEmitter listeners)
const emitter = ipcMain as { removeAllListeners?: (event: string) => void };
emitter.removeAllListeners?.(IPC_CHANNELS.PROFILES_TEST_CONNECTION_CANCEL);
emitter.removeAllListeners?.(IPC_CHANNELS.PROFILES_DISCOVER_MODELS_CANCEL);
};
}
@@ -71,6 +71,12 @@ export const ALL_AVAILABLE_MODELS: ModelOption[] = [
{ value: 'glm-4.7', label: 'GLM-4.7', provider: 'zai', description: 'Previous flagship', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'glm-4.6v', label: 'GLM-4.6V', provider: 'zai', description: 'Multimodal', capabilities: { thinking: false, tools: true, vision: true, contextWindow: 128000 } },
{ value: 'glm-4.5-flash', label: 'GLM-4.5 Flash', provider: 'zai', description: 'Fast', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
// Mascarade (LLM Orchestration Engine)
{ value: 'mascarade-router', label: 'Mascarade Router', provider: 'mascarade', description: 'Multi-LLM routing (best/cheapest/fastest)', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-writer', label: 'Mascarade Writer', provider: 'mascarade', description: 'Content generation agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-coder', label: 'Mascarade Coder', provider: 'mascarade', description: 'Code review & generation', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-analyst', label: 'Mascarade Analyst', provider: 'mascarade', description: 'Data analysis agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-planner', label: 'Mascarade Planner', provider: 'mascarade', description: 'Project decomposition', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
];
// Maps model shorthand to actual Claude model IDs
@@ -322,6 +328,12 @@ export const PROVIDER_PRESET_DEFINITIONS: Partial<Record<BuiltinProvider, Record
balanced: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
mascarade: {
auto: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
complex: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
balanced: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
};
/**
@@ -73,4 +73,11 @@ export const PROVIDER_REGISTRY: ProviderInfo[] = [
authMethods: ['api-key'], envVars: [],
configFields: ['baseUrl'],
},
{
id: 'mascarade', name: 'Mascarade', description: 'LLM Orchestration Engine — multi-provider routing with 19+ agents',
category: 'local',
authMethods: ['api-key'], envVars: ['MASCARADE_API_KEY'],
configFields: ['baseUrl'],
website: 'https://mascarade.saillant.cc',
},
];
@@ -1,326 +0,0 @@
/**
* Vercel AI SDK Mock Utilities
* ============================
*
* Mock helpers for Vercel AI SDK v6 functions (generateText, streamText).
* Supports single-pass review and multi-turn agent session mocking.
*/
/**
* Mock generateText response for single-pass review
*
* @param result - Mock result object
* @returns Mocked generateText return value
*
* @example
* ```ts
* const mockResult = mockGenerateText({
* text: '{"findings": [{"id": "SEC-1", "severity": "high"}]}',
* usage: { promptTokens: 1000, completionTokens: 500 }
* });
*
* vi.mocked(generateText).mockResolvedValue(mockResult);
* ```
*/
export interface MockGenerateTextResult {
text?: string;
object?: unknown;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
finishReason?: 'stop' | 'length' | 'error';
toolCalls?: Array<{
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}>;
}
export function mockGenerateText(result: MockGenerateTextResult = {}): {
text: string;
object?: unknown;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
finishReason: 'stop' | 'length' | 'error';
warnings?: unknown[];
response?: unknown;
} {
return {
text: result.text || '',
object: result.object,
usage: result.usage || {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500
},
finishReason: result.finishReason || 'stop',
toolCalls: result.toolCalls
} as unknown as {
text: string;
object?: unknown;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
finishReason: 'stop' | 'length' | 'error';
};
}
/**
* Mock streamText steps for multi-turn agent sessions
*
* Simulates a multi-step agent session with tool calls.
*
* @param steps - Array of mock steps
* @returns Mock streamText result with async iterator
*
* @example
* ```ts
* const mockStream = mockStreamText([
* {
* text: 'Let me check the code...',
* toolCalls: [{ toolName: 'read_file', args: { path: 'src/test.ts' } }]
* },
* {
* text: 'Found the issue!',
* finishReason: 'stop'
* }
* ]);
*
* vi.mocked(streamText).mockReturnValue(mockStream);
* ```
*/
export interface MockStreamStep {
text?: string;
toolCalls?: Array<{
toolName: string;
args: Record<string, unknown>;
}>;
finishReason?: 'stop' | 'length' | 'error';
toolResults?: Array<{
toolCallId: string;
result: unknown;
}>;
}
/**
* Counter for generating deterministic tool call IDs
*/
let toolCallIdCounter = 0;
/**
* Generate a deterministic tool call ID
*
* @param toolName - Name of the tool
* @returns Deterministic tool call ID
*/
function generateToolCallId(toolName: string): string {
return `mock-${toolName}-${toolCallIdCounter++}`;
}
export function mockStreamText(steps: MockStreamStep[] = []): {
toDataStreamStream: () => ReadableStream;
text: string;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
finishReason: 'stop' | 'length' | 'error';
toolCalls: Array<{
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}>;
fullStream: AsyncIterable<unknown>;
} {
const allText = steps.map(s => s.text || '').join('');
// Precompute deterministic toolCallIds for each tool call in each step
const stepToolCallIds: Array<Array<string>> = steps.map(step =>
(step.toolCalls || []).map(tc => generateToolCallId(tc.toolName))
);
// Build allToolCalls using precomputed IDs
const allToolCalls = steps.flatMap((step, stepIndex) =>
(step.toolCalls || []).map((tc, toolIndex) => ({
toolCallId: stepToolCallIds[stepIndex][toolIndex],
toolName: tc.toolName,
args: tc.args
}))
);
// Create async generator for fullStream
async function* generateSteps() {
for (const step of steps) {
yield {
type: 'text-delta',
textDelta: step.text || ''
};
if (step.toolCalls && step.toolCalls.length > 0) {
const stepIndex = steps.indexOf(step);
for (const tc of step.toolCalls) {
const toolIndex = step.toolCalls!.indexOf(tc);
yield {
type: 'tool-call',
toolName: tc.toolName,
toolCallId: stepToolCallIds[stepIndex][toolIndex],
args: tc.args
};
}
}
// Emit toolResults if present
if (step.toolResults && step.toolResults.length > 0) {
for (const tr of step.toolResults) {
yield {
type: 'tool-result',
toolCallId: tr.toolCallId,
result: tr.result
};
}
}
if (step.finishReason) {
yield {
type: 'finish',
finishReason: step.finishReason,
usage: {
promptTokens: 1000,
completionTokens: 500
}
};
}
}
}
return {
toDataStreamStream: () =>
new ReadableStream({
async start(controller) {
for (const step of steps) {
if (step.text) {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ type: 'text-delta', textDelta: step.text })}\n\n`)
);
}
}
controller.close();
}
}),
text: allText,
usage: {
promptTokens: 1000 * steps.length,
completionTokens: 500 * steps.length,
totalTokens: 1500 * steps.length
},
finishReason: steps[steps.length - 1]?.finishReason || 'stop',
toolCalls: allToolCalls,
fullStream: generateSteps()
};
}
/**
* Create mock AI client
*
* Returns a minimal mock client with model and systemPrompt.
*
* @param modelOverride - Optional model name override
* @param systemPromptOverride - Optional system prompt override
* @returns Mock client object
*
* @example
* ```ts
* const client = createMockAIClient('claude-3-5-sonnet-20241022');
* expect(client.model).toBe('claude-3-5-sonnet-20241022');
* ```
*/
export function createMockAIClient(
modelOverride: string = 'claude-3-5-sonnet-20241022',
systemPromptOverride: string = 'You are a helpful assistant.'
): {
model: string;
systemPrompt: string;
} {
return {
model: modelOverride,
systemPrompt: systemPromptOverride
};
}
/**
* Mock AI SDK tool execution
*
* Creates a mock tool result for testing tool call handling.
*
* @param toolName - Name of the tool
* @param result - Tool execution result
* @param toolCallId - Optional explicit tool call ID (for deterministic testing)
* @returns Mock tool result
*
* @example
* ```ts
* // Auto-generated ID (non-deterministic)
* const mockResult = mockToolResult('read_file', {
* file_path: 'src/test.ts',
* content: 'export const test = true;'
* });
*
* // Explicit ID (deterministic, correlates with mockStreamText)
* const mockResult = mockToolResult('read_file', {
* file_path: 'src/test.ts',
* content: 'export const test = true;'
* }, 'mock-read_file-0');
* ```
*/
export function mockToolResult<T = unknown>(
toolName: string,
result: T,
toolCallId?: string
): {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result: T;
} {
return {
toolCallId: toolCallId || generateToolCallId(toolName),
toolName,
args: {},
result
};
}
/**
* Mock conversation history
*
* Creates a mock array of message objects for testing.
*
* @param userMessages - Array of user message strings
* @param assistantMessages - Array of assistant message strings
* @returns Mock conversation history
*
* @example
* ```ts
* const history = mockConversationHistory(
* ['Review this PR', 'Check for security issues'],
* ['I will review it', 'Found 2 security issues']
* );
* ```
*/
export function mockConversationHistory(
userMessages: string[] = [],
assistantMessages: string[] = []
): Array<{ role: 'user' | 'assistant'; content: string }> {
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [];
for (let i = 0; i < Math.max(userMessages.length, assistantMessages.length); i++) {
if (i < userMessages.length) {
history.push({ role: 'user', content: userMessages[i] });
}
if (i < assistantMessages.length) {
history.push({ role: 'assistant', content: assistantMessages[i] });
}
}
return history;
}
@@ -1,218 +0,0 @@
/**
* GitHub API Mock Utilities
* =========================
*
* Mock helpers for GitHub REST and GraphQL API responses in PR review tests.
* Supports Vitest vi.fn() mocking patterns.
*/
import { vi } from 'vitest';
import type { PRData } from '@preload/api/modules/github-api';
/**
* Mock successful GitHub PR fetch response
*
* @param prNumber - PR number
* @param overrides - Optional overrides for default PR data
* @returns Mock PR data object
*
* @example
* ```ts
* const mockPR = mockSuccessfulPR(42, {
* title: 'Fix authentication bug',
* additions: 100
* });
* ```
*/
export function mockSuccessfulPR(prNumber: number, overrides: Partial<PRData> = {}): PRData {
return {
number: prNumber,
title: `Test PR #${prNumber}`,
body: 'Test PR description',
state: 'open',
author: { login: 'testuser' },
headRefName: 'feature/test-branch',
baseRefName: 'develop',
additions: 50,
deletions: 20,
changedFiles: 5,
assignees: [],
files: [
{
path: 'src/test.ts',
additions: 30,
deletions: 10,
status: 'modified'
},
{
path: 'src/utils/helpers.ts',
additions: 20,
deletions: 10,
status: 'modified'
}
],
createdAt: '2025-01-15T10:00:00Z',
updatedAt: '2025-01-15T12:00:00Z',
htmlUrl: `https://github.com/test/repo/pull/${prNumber}`,
...overrides
};
}
/**
* Mock GitHub API error response
*
* @param status - HTTP status code
* @param errorBody - Error response body
* @returns Mock error response
*
* @example
* ```ts
* const error = mockGitHubError(401, {
* message: 'Bad credentials',
* documentation_url: 'https://docs.github.com/rest'
* });
* ```
*/
export interface GitHubErrorBody {
message: string;
documentation_url?: string;
errors?: Array<{ resource: string; field: string; code: string }>;
}
export function mockGitHubError(status: number, errorBody: Partial<GitHubErrorBody> = {}): {
status: number;
data: GitHubErrorBody;
} {
return {
status,
data: {
message: 'An error occurred',
documentation_url: 'https://docs.github.com/rest',
...errorBody
}
};
}
/**
* Setup mock fetch for GitHub API
*
* Configures global.fetch to return mocked GitHub responses.
* Use with vi.restoreAllMocks() in afterEach cleanup.
*
* @param mock - Mock response data or error
*
* @example
* ```ts
* beforeEach(() => {
* setupMockGitHubFetch({
* ok: true,
* status: 200,
* json: async () => mockSuccessfulPR(42)
* });
* });
*
* afterEach(() => {
* vi.restoreAllMocks();
* });
* ```
*/
export interface MockFetchResponse {
ok: boolean;
status: number;
statusText?: string;
headers?: Headers;
json: () => Promise<unknown>;
}
/**
* Setup mock fetch for GitHub API
*
* Configures global.fetch to return mocked GitHub responses.
* Returns the mock function for test assertions.
* Use with vi.restoreAllMocks() in afterEach cleanup.
*
* @param mock - Mock response data or error
* @returns The mock fetch function for assertions
*
* @example
* ```ts
* beforeEach(() => {
* mockFetch = setupMockGitHubFetch({
* ok: true,
* status: 200,
* json: async () => mockSuccessfulPR(42)
* });
* });
*
* afterEach(() => {
* vi.restoreAllMocks();
* });
*
* test('fetches PR data', async () => {
* expect(mockFetch).toHaveBeenCalled();
* });
* ```
*/
export function setupMockGitHubFetch(mock: MockFetchResponse): ReturnType<typeof vi.fn> {
const mockFetch = vi.fn(() => Promise.resolve(mock as Response)) as unknown as typeof fetch;
global.fetch = mockFetch;
return mockFetch as unknown as ReturnType<typeof vi.fn>;
}
/**
* Mock GitHub GraphQL response
*
* @param data - GraphQL response data
* @param errors - Optional GraphQL errors
* @returns Mock GraphQL response
*
* @example
* ```ts
* const mockResponse = mockGraphQLResponse({
* repository: {
* pullRequest: mockSuccessfulPR(42)
* }
* });
* ```
*/
export interface GraphQLError {
message: string;
path?: (string | number)[];
locations?: Array<{ line: number; column: number }>;
extensions?: Record<string, unknown>;
}
export function mockGraphQLResponse(data: Record<string, unknown>, errors: GraphQLError[] = []): {
data: Record<string, unknown>;
errors?: GraphQLError[];
} {
const response: { data: Record<string, unknown>; errors?: GraphQLError[] } = { data };
if (errors.length > 0) {
response.errors = errors;
}
return response;
}
/**
* Mock GitHub rate limit error response
*
* @param retryAfter - Seconds until retry (default 60)
* @returns Mock rate limit error
*/
export function mockRateLimitError(retryAfter: number = 60): MockFetchResponse {
const headers = new Headers({
'x-ratelimit-remaining': '0',
'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + retryAfter),
'retry-after': String(retryAfter),
});
return {
ok: false,
status: 403,
statusText: 'Forbidden',
headers,
json: async () => ({
message: 'API rate limit exceeded',
documentation_url: 'https://docs.github.com/rest/rate-limit'
})
};
}
@@ -1,62 +0,0 @@
/**
* Shared Test Utilities for PR Review Suite
* ==========================================
*
* Barrel exports for all test utility modules.
* Import from this file for cleaner imports:
*
* ```ts
* import { createMockProject, createMockFinding } from '@shared/test-utils';
* ```
*/
// GitHub API mocks
export {
mockSuccessfulPR,
mockGitHubError,
setupMockGitHubFetch,
mockGraphQLResponse,
mockRateLimitError
} from './github-mocks';
// GitHub API types
export type { MockFetchResponse, GitHubErrorBody, GraphQLError } from './github-mocks';
// Vercel AI SDK mocks
export {
mockGenerateText,
mockStreamText,
createMockAIClient,
mockToolResult,
mockConversationHistory
} from './ai-sdk-mocks';
// Vercel AI SDK types
export type { MockGenerateTextResult, MockStreamStep } from './ai-sdk-mocks';
// PR fixtures
export {
SIMPLE_PR_CONTEXT,
COMPLEX_PR_CONTEXT,
PR_WITH_SECURITY_ISSUE,
PR_WITH_AI_COMMENTS,
EMPTY_PR_CONTEXT
} from './pr-fixtures';
// Mock factories
export {
createMockProject,
createMockGitHubConfig,
createMockPRContext,
createMockFinding,
createMockReviewResult,
createMockProgress,
createMockChangedFile,
createMockAIComment,
createMockFindings,
createMockScanResult,
PRContextBuilder
} from './mock-factories';
// Mock factory types
export type { MockScanResult } from './mock-factories';
@@ -1,426 +0,0 @@
/**
* Mock Factories for PR Review Tests
* ==================================
*
* Factory functions to create test data objects with sensible defaults.
* Follows the builder pattern for flexible test setup.
*/
import type { Project } from '../../shared/types/project';
import type { GitHubConfig } from '../../main/ipc-handlers/github/types';
import type { PRContext, PRReviewFinding, ChangedFile, AIBotComment } from '../../main/ai/runners/github/pr-review-engine';
import type { PRReviewResult, PRReviewProgress } from '../../preload/api/modules/github-api';
/**
* Create mock Project with GitHub configuration
*
* @param overrides - Optional property overrides
* @returns Mock Project object
*
* @example
* ```ts
* const project = createMockProject({
* name: 'test-project',
* path: '/tmp/test'
* });
* ```
*/
export function createMockProject(overrides: Partial<Project> = {}): Project {
const now = new Date();
return {
id: 'project-test-id',
name: 'Test Project',
path: '/tmp/test-project',
autoBuildPath: '/tmp/test-project/.auto-claude',
settings: {
model: 'claude-3-5-sonnet-20241022',
memoryBackend: 'file',
linearSync: false,
notifications: {
onTaskComplete: true,
onTaskFailed: true,
onReviewNeeded: true,
sound: false
}
},
createdAt: now,
updatedAt: now,
...overrides
};
}
/**
* Create mock GitHub configuration
*
* @param token - GitHub personal access token
* @param repo - Repository in 'owner/repo' format
* @param overrides - Optional property overrides
* @returns Mock GitHubConfig object
*
* @example
* ```ts
* const config = createMockGitHubConfig('ghp_123', 'owner/repo');
* ```
*/
export function createMockGitHubConfig(
token: string = 'ghp_test_token',
repo: string = 'owner/repo',
overrides: Partial<GitHubConfig> = {}
): GitHubConfig {
return {
token,
repo,
...overrides
};
}
/**
* Create mock PR context with defaults
*
* @param overrides - Optional property overrides
* @returns Mock PRContext object
*
* @example
* ```ts
* const context = createMockPRContext({
* prNumber: 42,
* title: 'Fix bug'
* });
* ```
*/
export function createMockPRContext(overrides: Partial<PRContext> = {}): PRContext {
return {
prNumber: 42,
title: 'Test PR',
description: 'Test PR description',
author: 'testuser',
baseBranch: 'develop',
headBranch: 'feature/test',
state: 'open',
changedFiles: [
{
path: 'src/test.ts',
additions: 10,
deletions: 5,
status: 'modified'
}
] as ChangedFile[],
diff: 'diff --git a/src/test.ts b/src/test.ts\n+new line',
diffTruncated: false,
repoStructure: 'src/\n└── test.ts',
relatedFiles: [],
commits: [
{
oid: 'abc123',
messageHeadline: 'Test commit',
committedDate: new Date().toISOString()
}
],
labels: [],
totalAdditions: 10,
totalDeletions: 5,
aiBotComments: [],
...overrides
};
}
/**
* Create mock review finding
*
* @param overrides - Optional property overrides
* @returns Mock PRReviewFinding object
*
* @example
* ```ts
* const finding = createMockFinding({
* severity: 'critical',
* category: 'security'
* });
* ```
*/
export function createMockFinding(overrides: Partial<PRReviewFinding> = {}): PRReviewFinding {
return {
id: 'TEST-001',
severity: 'medium',
category: 'quality' as const,
title: 'Test Finding',
description: 'This is a test finding',
file: 'src/test.ts',
line: 10,
endLine: 15,
suggestedFix: 'Fix the issue',
fixable: true,
evidence: 'const x = 1;',
verificationNote: 'Verified manually',
validationStatus: 'needs_human_review',
validationExplanation: 'Needs review',
sourceAgents: ['security-specialist'],
crossValidated: false,
...overrides
} as PRReviewFinding;
}
/**
* Create mock review result
*
* @param overrides - Optional property overrides
* @returns Mock PRReviewResult object
*
* @example
* ```ts
* const result = createMockReviewResult({
* prNumber: 42,
* overallStatus: 'approve'
* });
* ```
*/
export function createMockReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
// createMockFinding returns a broader ReviewCategory type that includes 'verification_failed'
// but the API type only accepts the narrower set. Since createMockFinding always returns
// 'quality', we cast directly to the narrower type.
const mockFinding = createMockFinding() as PRReviewResult['findings'][number];
return {
prNumber: 42,
repo: 'test/repo',
success: true,
findings: [mockFinding],
summary: 'LGTM, looks good to merge',
overallStatus: 'approve',
reviewedAt: new Date().toISOString(),
error: undefined,
...overrides
};
}
/**
* Create mock review progress
*
* @param overrides - Optional property overrides
* @returns Mock PRReviewProgress object
*
* @example
* ```ts
* const progress = createMockProgress({
* phase: 'analyzing',
* progress: 50
* });
* ```
*/
export function createMockProgress(overrides: Partial<PRReviewProgress> = {}): PRReviewProgress {
return {
phase: 'fetching',
prNumber: 42,
progress: 0,
message: 'Starting review...',
...overrides
};
}
/**
* Create mock changed file
*
* @param path - File path
* @param additions - Number of additions
* @param deletions - Number of deletions
* @param overrides - Optional property overrides
* @returns Mock ChangedFile object
*
* @example
* ```ts
* const file = createMockChangedFile('src/test.ts', 10, 5);
* ```
*/
export function createMockChangedFile(
path: string = 'src/test.ts',
additions: number = 10,
deletions: number = 5,
overrides: Partial<ChangedFile> = {}
): ChangedFile {
return {
path,
additions,
deletions,
status: 'modified',
patch: `@@ -1,1 +1,2 @@
-line 1
+line 1 modified`,
...overrides
};
}
/**
* Create mock AI bot comment
*
* @param toolName - Name of the AI tool
* @param body - Comment body text
* @param overrides - Optional property overrides
* @returns Mock AIBotComment object
*
* @example
* ```ts
* const comment = createMockAIComment('CodeRabbit', 'Add error handling');
* ```
*/
export function createMockAIComment(
toolName: string = 'CodeRabbit',
body: string = 'This looks good but could be improved',
overrides: Partial<AIBotComment> = {}
): AIBotComment {
return {
commentId: 1,
author: 'ai-bot',
toolName,
body,
file: 'src/test.ts',
line: 10,
createdAt: new Date().toISOString(),
...overrides
};
}
/**
* Create multiple mock findings with auto-incrementing IDs
*
* @param count - Number of findings to create
* @param baseOverrides - Overrides to apply to all findings
* @returns Array of mock PRReviewFinding objects
*
* @example
* ```ts
* const findings = createMockFindings(3, {
* severity: 'high',
* category: 'security'
* });
* // Creates TEST-001, TEST-002, TEST-003 all with high severity
* ```
*/
export function createMockFindings(
count: number,
baseOverrides: Partial<PRReviewFinding> = {}
): PRReviewFinding[] {
return Array.from({ length: count }, (_, i) =>
createMockFinding({
...baseOverrides,
id: `TEST-${String(i + 1).padStart(3, '0')}`,
title: `${baseOverrides.title || 'Test Finding'} ${i + 1}`,
line: 10 + i * 5
})
);
}
/**
* Create mock scan result
*
* @param complexity - Complexity level
* @param riskAreas - Array of risk area strings
* @returns Mock scan result
*
* @example
* ```ts
* const scanResult = createMockScanResult('high', ['authentication', 'database']);
* ```
*/
export interface MockScanResult {
complexity: 'low' | 'medium' | 'high';
riskAreas: string[];
verdict?: string;
summary?: string;
}
export function createMockScanResult(
complexity: 'low' | 'medium' | 'high' = 'medium',
riskAreas: string[] = [],
overrides: Partial<MockScanResult> = {}
): MockScanResult {
return {
complexity,
riskAreas,
verdict: 'needs_review',
summary: 'PR requires review',
...overrides
};
}
/**
* Builder for creating complex PR contexts
*
* Provides a fluent interface for building PR contexts.
*
* @example
* ```ts
* const context = new PRContextBuilder()
* .withPrNumber(42)
* .withFiles([
* createMockChangedFile('src/a.ts', 10, 0),
* createMockChangedFile('src/b.ts', 20, 5)
* ])
* .withLabels('bug', 'high-priority')
* .withAIComment(createMockAIComment('CodeRabbit', 'Fix this'))
* .build();
* ```
*/
export class PRContextBuilder {
private context: Partial<PRContext> = {};
withPrNumber(prNumber: number): this {
this.context.prNumber = prNumber;
return this;
}
withTitle(title: string): this {
this.context.title = title;
return this;
}
withDescription(description: string): this {
this.context.description = description;
return this;
}
withAuthor(author: string): this {
this.context.author = author;
return this;
}
withBranches(base: string, head: string): this {
this.context.baseBranch = base;
this.context.headBranch = head;
return this;
}
withFiles(files: ChangedFile[]): this {
this.context.changedFiles = files;
this.context.totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
this.context.totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
return this;
}
withLabels(...labels: string[]): this {
this.context.labels = labels;
return this;
}
withAIComment(comment: AIBotComment): this {
if (!this.context.aiBotComments) {
this.context.aiBotComments = [];
}
this.context.aiBotComments.push(comment);
return this;
}
withDiff(diff: string, truncated: boolean = false): this {
this.context.diff = diff;
this.context.diffTruncated = truncated;
return this;
}
withCommits(commits: Array<{ oid: string; messageHeadline: string; committedDate: string }>): this {
this.context.commits = commits;
return this;
}
build(): PRContext {
return createMockPRContext(this.context);
}
}
@@ -1,329 +0,0 @@
/**
* PR Review Fixtures
* ==================
*
* Pre-built PR context fixtures for testing different scenarios.
* Use these to quickly set up realistic test data.
*/
import type { PRContext, ChangedFile, AIBotComment } from '../../main/ai/runners/github/pr-review-engine';
/**
* Simple PR fixture for basic testing
*
* Small PR with 5 files and 50 additions.
* Good for unit tests and quick sanity checks.
*/
export const SIMPLE_PR_CONTEXT: PRContext = {
prNumber: 42,
title: 'Fix user authentication bug',
description: 'Fixes issue where users cannot log in with special characters in password',
author: 'testuser',
baseBranch: 'develop',
headBranch: 'feature/auth-fix',
state: 'open',
changedFiles: [
{
path: 'src/auth/login.ts',
additions: 15,
deletions: 5,
status: 'modified',
patch: `@@ -10,7 +10,7 @@
export function login(username: string, password: string): boolean {
- if (!username || !password) return false;
+ if (!username || !password || password.length < 8) return false;
const hash = hashPassword(password);`
},
{
path: 'src/auth/password-validator.ts',
additions: 20,
deletions: 0,
status: 'added',
patch: `@@ -0,0 +1,20 @@
+export function validatePassword(password: string): boolean {
+ return password.length >= 8 &&
+ /[A-Z]/.test(password) &&
+ /[0-9]/.test(password);
+}`
},
{
path: 'tests/auth/login.test.ts',
additions: 10,
deletions: 2,
status: 'modified',
patch: ''
},
{
path: 'README.md',
additions: 5,
deletions: 0,
status: 'modified',
patch: ''
}
] as ChangedFile[],
diff: `diff --git a/src/auth/login.ts b/src/auth/login.ts
index 1234567..abcdefg 100644
--- a/src/auth/login.ts
+++ b/src/auth/login.ts
@@ -10,7 +10,7 @@
export function login(username: string, password: string): boolean {
- if (!username || !password) return false;
+ if (!username || !password || password.length < 8) return false;
const hash = hashPassword(password);
`,
diffTruncated: false,
repoStructure: `
src/
├── auth/
│ ├── login.ts
│ ├── password-validator.ts
│ └── session.ts
├── utils/
│ └── hash.ts
└── tests/
└── auth/
└── login.test.ts
`,
relatedFiles: ['src/auth/session.ts', 'src/utils/hash.ts'],
commits: [
{
oid: 'abc123def456',
messageHeadline: 'Fix password validation',
committedDate: '2025-01-15T10:00:00Z'
},
{
oid: '789ghi012jkl',
messageHeadline: 'Add unit tests',
committedDate: '2025-01-15T11:00:00Z'
}
],
labels: ['bug', 'authentication', 'priority-high'],
totalAdditions: 50,
totalDeletions: 7,
aiBotComments: []
};
/**
* Complex PR fixture for comprehensive testing
*
* Large PR with 100 files and 5000 additions.
* Tests truncation, pagination, and performance handling.
*/
export const COMPLEX_PR_CONTEXT: PRContext = {
prNumber: 123,
title: 'Refactor payment processing system',
description: 'Complete overhaul of payment processing with new provider integration',
author: 'senior-dev',
baseBranch: 'main',
headBranch: 'feature/payment-refactor',
state: 'open',
changedFiles: Array.from({ length: 100 }, (_, i) => ({
path: `src/payment/module-${i}.ts`,
additions: 50,
deletions: 10,
status: i < 10 ? 'modified' : i < 50 ? 'added' : 'deleted',
patch: i < 20 ? `@@ -1,5 +1,10 @@
export class PaymentModule${i} {
- constructor() {}
+ constructor(private config: PaymentConfig) {}
+ process(amount: number): Promise<boolean> {
+ return this.provider.charge(amount);
+ }
}` : ''
})) as ChangedFile[],
diff: `[DIFF TRUNCATED - Too large to display]
Total: 5000 additions, 1000 deletions across 100 files
First 20 files shown in patches above...`,
diffTruncated: true,
repoStructure: `
src/
├── payment/
│ ├── modules/ (90 files)
│ ├── providers/
│ ├── validators/
│ └── utils/
├── billing/
│ ├── invoices/
│ └── subscriptions/
└── tests/
├── integration/
└── unit/
`,
relatedFiles: [
'src/payment/providers/stripe.ts',
'src/billing/invoices/generator.ts',
'src/payment/validators/card.ts'
],
commits: Array.from({ length: 25 }, (_, i) => ({
oid: `commit${i}`,
messageHeadline: `Commit ${i + 1}: Payment module updates`,
committedDate: new Date(Date.now() - i * 3600000).toISOString()
})),
labels: ['refactor', 'payment', 'breaking-change', 'requires-testing'],
totalAdditions: 5000,
totalDeletions: 1000,
aiBotComments: []
};
/**
* PR with security vulnerability fixture
*
* Contains intentional security issues for testing detection.
*/
export const PR_WITH_SECURITY_ISSUE: PRContext = {
prNumber: 456,
title: 'Add API key configuration',
description: 'Add environment variable support for API keys',
author: 'junior-dev',
baseBranch: 'develop',
headBranch: 'feature/api-config',
state: 'open',
changedFiles: [
{
path: 'src/config/api.ts',
additions: 8,
deletions: 0,
status: 'added',
patch: `@@ -0,0 +1,8 @@
+export const API_KEY = '<REDACTED_API_KEY>';
+export const API_SECRET = '<REDACTED_SECRET>';
+
+export async function fetchAPI(endpoint: string) {
+ const response = await fetch(endpoint, {
+ headers: { 'Authorization': \`Bearer \${API_KEY}\` }
+ });
+ return response.json();
+}`
},
{
path: 'src/database/query.ts',
additions: 12,
deletions: 0,
status: 'added',
patch: `@@ -0,0 +1,12 @@
+export async function getUserById(id: string) {
+ const query = \`SELECT * FROM users WHERE id = '\${id}'\`;
+ return db.execute(query);
+}
+
+export async function searchUsers(term: string) {
+ const query = \`SELECT * FROM users WHERE name LIKE '%\${term}%'\`;
+ return db.execute(query);
+}`
},
{
path: 'src/auth/session.ts',
additions: 5,
deletions: 0,
status: 'added',
patch: `@@ -0,0 +1,5 @@
+export function createSession(userId: string) {
+ const token = btoa(userId + ':' + Date.now());
+ localStorage.setItem('session', token);
+ return token;
+}`
}
] as ChangedFile[],
diff: `diff --git a/src/config/api.ts b/src/config/api.ts
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/src/config/api.ts
@@ -0,0 +1,8 @@
+export const API_KEY = '<REDACTED_API_KEY>';
+
diff --git a/src/database/query.ts b/src/database/query.ts
new file mode 100644
index 0000000..2222222
--- /dev/null
+++ b/src/database/query.ts
@@ -0,0 +1,12 @@
+export async function getUserById(id: string) {
+ const query = \`SELECT * FROM users WHERE id = '\${id}'\`;
+`,
diffTruncated: false,
repoStructure: `
src/
├── config/
│ └── api.ts
├── database/
│ └── query.ts
└── auth/
└── session.ts
`,
relatedFiles: [],
commits: [
{
oid: 'abc123',
messageHeadline: 'Add API configuration',
committedDate: '2025-01-15T10:00:00Z'
}
],
labels: ['feature', 'api'],
totalAdditions: 25,
totalDeletions: 0,
aiBotComments: []
};
/**
* PR with AI bot comments for triage testing
*
* Contains comments from various AI review tools.
*/
export const PR_WITH_AI_COMMENTS: PRContext = {
...SIMPLE_PR_CONTEXT,
prNumber: 789,
aiBotComments: [
{
commentId: 1,
author: 'coderabbitai',
toolName: 'CodeRabbit',
body: 'Consider adding error handling for the login function. What happens if hashPassword throws?',
file: 'src/auth/login.ts',
line: 12,
createdAt: '2025-01-15T10:30:00Z'
},
{
commentId: 2,
author: 'cursor-agent',
toolName: 'Cursor',
body: 'The password validation logic is good but could be more strict. Consider requiring special characters.',
file: 'src/auth/password-validator.ts',
line: 3,
createdAt: '2025-01-15T10:35:00Z'
},
{
commentId: 3,
author: 'greptile',
toolName: 'Greptile',
body: 'Minor nit: add JSDoc comments for public functions',
file: 'src/auth/login.ts',
createdAt: '2025-01-15T10:40:00Z'
}
] as AIBotComment[]
};
/**
* Empty PR fixture for edge case testing
*
* Minimal PR with no changes (good for error testing).
*/
export const EMPTY_PR_CONTEXT: PRContext = {
prNumber: 999,
title: 'Documentation update',
description: 'Update README with new examples',
author: 'doc-writer',
baseBranch: 'main',
headBranch: 'docs/update-readme',
state: 'open',
changedFiles: [] as ChangedFile[],
diff: '',
diffTruncated: false,
repoStructure: '',
relatedFiles: [],
commits: [],
labels: ['documentation'],
totalAdditions: 0,
totalDeletions: 0,
aiBotComments: []
};
@@ -7,7 +7,7 @@ export type CredentialSource = 'oauth' | 'api-key' | 'env' | 'keychain';
export type BuiltinProvider =
| 'anthropic' | 'openai' | 'google' | 'amazon-bedrock' | 'azure'
| 'mistral' | 'groq' | 'xai' | 'openrouter' | 'zai'
| 'ollama' | 'openai-compatible';
| 'ollama' | 'openai-compatible' | 'mascarade';
export type BillingModel = 'subscription' | 'pay-per-use';