/** * Test Harness for E2E Testing * * Wires up the full system with mocks for E2E testing. * Uses real managers (DispatchManager, CoordinationManager) with * MockAgentManager and MockWorktreeManager for isolation. */ import { randomUUID } from 'crypto'; import { vi } from 'vitest'; import type { DrizzleDatabase } from '../db/index.js'; import type { EventBus, DomainEvent } from '../events/types.js'; import { EventEmitterBus } from '../events/bus.js'; import type { AgentManager } from '../agent/types.js'; import { MockAgentManager, type MockAgentScenario } from '../agent/mock-manager.js'; import type { PendingQuestions, QuestionItem } from '../agent/types.js'; import type { WorktreeManager, Worktree, WorktreeDiff, MergeResult } from '../git/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import { DefaultDispatchManager } from '../dispatch/manager.js'; import { DefaultPhaseDispatchManager } from '../dispatch/phase-manager.js'; import type { CoordinationManager } from '../coordination/types.js'; import { DefaultCoordinationManager } from '../coordination/manager.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { MessageRepository } from '../db/repositories/message-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { ErrandRepository } from '../db/repositories/errand-repository.js'; import type { Initiative, Phase, Task } from '../db/schema.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import { createRepositories } from '../container.js'; import { seedFixture, type InitiativeFixture, type SeededFixture, } from './fixtures.js'; import { appRouter, createCallerFactory } from '../trpc/router.js'; import { createContext, type TRPCContext } from '../trpc/context.js'; // ============================================================================= // MockWorktreeManager // ============================================================================= /** * Simple in-memory WorktreeManager for testing. * Creates fake worktrees without actual git operations. */ export class MockWorktreeManager implements WorktreeManager { private worktrees: Map = new Map(); private mergeResults: Map = new Map(); /** * Set a custom merge result for a specific worktree. * Used to test conflict scenarios. */ setMergeResult(worktreeId: string, result: MergeResult): void { this.mergeResults.set(worktreeId, result); } async create(id: string, branch: string, baseBranch?: string): Promise { const worktree: Worktree = { id, branch, path: `/tmp/test-worktrees/${id}`, isMainWorktree: false, }; this.worktrees.set(id, worktree); return worktree; } async remove(id: string): Promise { if (!this.worktrees.has(id)) { throw new Error(`Worktree not found: ${id}`); } this.worktrees.delete(id); this.mergeResults.delete(id); } async list(): Promise { return Array.from(this.worktrees.values()); } async get(id: string): Promise { return this.worktrees.get(id) ?? null; } async diff(id: string): Promise { if (!this.worktrees.has(id)) { throw new Error(`Worktree not found: ${id}`); } return { files: [], summary: 'No changes (mock)', }; } async merge(id: string, targetBranch: string): Promise { if (!this.worktrees.has(id)) { throw new Error(`Worktree not found: ${id}`); } // Return custom result if set, otherwise success const customResult = this.mergeResults.get(id); if (customResult) { return customResult; } return { success: true, message: `Merged ${id} into ${targetBranch} (mock)`, }; } /** * Clear all worktrees. * Useful for test cleanup. */ clear(): void { this.worktrees.clear(); this.mergeResults.clear(); } } // ============================================================================= // CapturingEventBus // ============================================================================= /** * EventBus wrapper that captures all emitted events. * Extends EventEmitterBus with event capture functionality. */ export class CapturingEventBus extends EventEmitterBus { /** All emitted events */ emittedEvents: DomainEvent[] = []; emit(event: T): void { this.emittedEvents.push(event); super.emit(event); } /** * Get events by type. */ getEventsByType(type: string): DomainEvent[] { return this.emittedEvents.filter((e) => e.type === type); } /** * Clear captured events. */ clearEvents(): void { this.emittedEvents = []; } } // ============================================================================= // tRPC Caller Type // ============================================================================= /** * Create caller factory for the app router. */ const createCaller = createCallerFactory(appRouter); /** * Type for the tRPC caller. */ export type TRPCCaller = ReturnType; // ============================================================================= // TestHarness Interface // ============================================================================= /** * Test harness for E2E testing. * Provides access to all system components and helper methods. */ export interface TestHarness { // Core components /** In-memory SQLite database */ db: DrizzleDatabase; /** Event bus with event capture */ eventBus: CapturingEventBus; /** Mock agent manager */ agentManager: MockAgentManager; /** Alias for agentManager - used in tests for clarity */ mockAgentManager: MockAgentManager; /** Mock worktree manager */ worktreeManager: MockWorktreeManager; /** Real dispatch manager wired to mocks */ dispatchManager: DispatchManager; /** Real phase dispatch manager wired to phaseRepository */ phaseDispatchManager: PhaseDispatchManager; /** Real coordination manager wired to mocks */ coordinationManager: CoordinationManager; // Repositories /** Task repository */ taskRepository: TaskRepository; /** Message repository */ messageRepository: MessageRepository; /** Agent repository */ agentRepository: AgentRepository; /** Initiative repository */ initiativeRepository: InitiativeRepository; /** Phase repository */ phaseRepository: PhaseRepository; /** Errand repository */ errandRepository: ErrandRepository; // tRPC Caller /** tRPC caller for direct procedure calls */ caller: TRPCCaller; // Helpers /** * Seed a fixture into the database. */ seedFixture(fixture: InitiativeFixture): Promise; /** * Set scenario for a specific agent name. */ setAgentScenario(agentName: string, scenario: MockAgentScenario): void; /** * Convenience: Set agent to complete with done status. */ setAgentDone(agentName: string, result?: string): void; /** * Convenience: Set agent to ask questions (array form). */ setAgentQuestions( agentName: string, questions: QuestionItem[] ): void; /** * Convenience: Set agent to ask a single question. * Wraps the question in an array internally. */ setAgentQuestion( agentName: string, questionId: string, question: string, options?: Array<{ label: string; description?: string }> ): void; /** * Convenience: Set agent to fail with unrecoverable error. */ setAgentError(agentName: string, error: string): void; /** * Get pending questions for an agent. */ getPendingQuestions(agentId: string): Promise; /** * Resume an idle agent to answer a conversation (mock: always returns false). */ resumeForConversation(agentId: string, conversationId: string, question: string, fromAgentId: string): Promise; /** * Get events by type. */ getEventsByType(type: string): DomainEvent[]; /** * Get emitted events by type (alias for getEventsByType). */ getEmittedEvents(type: string): DomainEvent[]; /** * Clear all captured events. */ clearEvents(): void; /** * Clean up all resources. */ cleanup(): void; /** * Advance fake timers (wrapper for vi.runAllTimersAsync). * Only works when vi.useFakeTimers() is active. */ advanceTimers(): Promise; /** * Run a test body with fake timers enabled. * Activates fake timers before the callback and restores real timers after, * even if the callback throws. */ withFakeTimers(fn: () => Promise): Promise; // ========================================================================== // Architect Mode Helpers // ========================================================================== /** * Set up scenario where architect completes discussion. */ setArchitectDiscussComplete( agentName: string, _decisions: unknown[], summary: string ): void; /** * Set up scenario where architect needs more questions in discuss mode. */ setArchitectDiscussQuestions( agentName: string, questions: QuestionItem[] ): void; /** * Set up scenario where architect completes plan. */ setArchitectPlanComplete( agentName: string, _phases: unknown[] ): void; /** * Set up scenario where architect completes detail. */ setArchitectDetailComplete( agentName: string, _tasks: unknown[] ): void; /** * Set up scenario where architect needs questions in detail mode. */ setArchitectDetailQuestions( agentName: string, questions: QuestionItem[] ): void; // ========================================================================== // Initiative/Phase/Plan Convenience Helpers // ========================================================================== /** * Get initiative by ID through tRPC. */ getInitiative(id: string): Promise; /** * Get phases for initiative through tRPC. */ getPhases(initiativeId: string): Promise; /** * Create initiative through tRPC. */ createInitiative(name: string): Promise; /** * Create phases from plan output through tRPC. */ createPhasesFromPlan( initiativeId: string, phases: Array<{ name: string }> ): Promise; /** * Create a detail task through tRPC (replaces createPlan). */ createDetailTask( phaseId: string, name: string, description?: string ): Promise; /** * Get child tasks of a parent task through tRPC. */ getChildTasks(parentTaskId: string): Promise; } // ============================================================================= // createTestHarness Factory // ============================================================================= /** * Create a fully wired test harness for E2E testing. * * Wires: * - In-memory SQLite database * - CapturingEventBus (captures all events) * - MockAgentManager (simulates agent behavior) * - MockWorktreeManager (fake worktrees) * - Real DefaultDispatchManager (with mock agent manager) * - Real DefaultCoordinationManager (with mock worktree manager) * - All repositories (Drizzle implementations) * - tRPC caller with full context */ export function createTestHarness(): TestHarness { // Create database const db = createTestDatabase(); // Create event bus with capture const eventBus = new CapturingEventBus(); // Create mock managers const agentManager = new MockAgentManager({ eventBus }); const worktreeManager = new MockWorktreeManager(); // Create repositories const repos = createRepositories(db); const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository, errandRepository } = repos; // Create real managers wired to mocks const dispatchManager = new DefaultDispatchManager( taskRepository, messageRepository, agentManager, eventBus ); const phaseDispatchManager = new DefaultPhaseDispatchManager( phaseRepository, taskRepository, dispatchManager, eventBus ); const coordinationManager = new DefaultCoordinationManager( worktreeManager, taskRepository, agentRepository, messageRepository, eventBus ); // Create tRPC context with all dependencies const ctx: TRPCContext = createContext({ eventBus, serverStartedAt: new Date(), processCount: 0, agentManager, taskRepository, messageRepository, dispatchManager, phaseDispatchManager, coordinationManager, initiativeRepository, phaseRepository, errandRepository, }); // Create tRPC caller const caller = createCaller(ctx); // Build harness const harness: TestHarness = { // Core components db, eventBus, agentManager, mockAgentManager: agentManager, // Alias for clarity in tests worktreeManager, dispatchManager, phaseDispatchManager, coordinationManager, // Repositories taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository, errandRepository, // tRPC Caller caller, // Helpers seedFixture: (fixture: InitiativeFixture) => seedFixture(db, fixture), setAgentScenario: (agentName: string, scenario: MockAgentScenario) => { agentManager.setScenario(agentName, scenario); }, setAgentDone: (agentName: string, result?: string) => { agentManager.setScenario(agentName, { status: 'done', result }); }, setAgentQuestions: ( agentName: string, questions: QuestionItem[] ) => { agentManager.setScenario(agentName, { status: 'questions', questions }); }, setAgentQuestion: ( agentName: string, questionId: string, question: string, options?: Array<{ label: string; description?: string }> ) => { agentManager.setScenario(agentName, { status: 'questions', questions: [{ id: questionId, question, options }], }); }, setAgentError: (agentName: string, error: string) => { agentManager.setScenario(agentName, { status: 'error', error }); }, getPendingQuestions: (agentId: string) => agentManager.getPendingQuestions(agentId), resumeForConversation: (agentId: string, conversationId: string, question: string, fromAgentId: string) => agentManager.resumeForConversation(agentId, conversationId, question, fromAgentId), getEventsByType: (type: string) => eventBus.getEventsByType(type), getEmittedEvents: (type: string) => eventBus.getEventsByType(type), clearEvents: () => eventBus.clearEvents(), cleanup: () => { agentManager.clear(); worktreeManager.clear(); eventBus.clearEvents(); }, // Timer helper - requires vi.useFakeTimers() to be active advanceTimers: async () => { await vi.runAllTimersAsync(); }, withFakeTimers: async (fn: () => Promise) => { vi.useFakeTimers(); try { await fn(); } finally { vi.useRealTimers(); } }, // ======================================================================== // Architect Mode Helpers // ======================================================================== setArchitectDiscussComplete: ( agentName: string, _decisions: unknown[], summary: string ) => { agentManager.setScenario(agentName, { status: 'done', result: summary, delay: 0, }); }, setArchitectDiscussQuestions: ( agentName: string, questions: QuestionItem[] ) => { agentManager.setScenario(agentName, { status: 'questions', questions, delay: 0, }); }, setArchitectPlanComplete: ( agentName: string, _phases: unknown[] ) => { agentManager.setScenario(agentName, { status: 'done', result: 'Plan complete', delay: 0, }); }, setArchitectDetailComplete: ( agentName: string, _tasks: unknown[] ) => { agentManager.setScenario(agentName, { status: 'done', result: 'Detail complete', delay: 0, }); }, setArchitectDetailQuestions: ( agentName: string, questions: QuestionItem[] ) => { agentManager.setScenario(agentName, { status: 'questions', questions, delay: 0, }); }, // ======================================================================== // Initiative/Phase/Plan Convenience Helpers // ======================================================================== getInitiative: async (id: string) => { try { return await caller.getInitiative({ id }); } catch { return null; } }, getPhases: (initiativeId: string) => { return caller.listPhases({ initiativeId }); }, createInitiative: (name: string) => { return caller.createInitiative({ name }); }, createPhasesFromPlan: ( initiativeId: string, phases: Array<{ name: string }> ) => { return caller.createPhasesFromPlan({ initiativeId, phases }); }, createDetailTask: async (phaseId: string, name: string, description?: string) => { return caller.createPhaseTask({ phaseId, name, description, category: 'detail', type: 'auto', }); }, getChildTasks: (parentTaskId: string) => { return caller.listTasks({ parentTaskId }); }, }; return harness; }