Creates the errands table (with conflictFiles column), errand-repository port interface, DrizzleErrandRepository adapter, and wires the repository into TRPCContext, the DI container, _helpers.ts requireErrandRepository guard, and the test harness. Also fixes pre-existing TS error in controller.test.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
649 lines
18 KiB
TypeScript
649 lines
18 KiB
TypeScript
/**
|
|
* 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<string, Worktree> = new Map();
|
|
private mergeResults: Map<string, MergeResult> = 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<Worktree> {
|
|
const worktree: Worktree = {
|
|
id,
|
|
branch,
|
|
path: `/tmp/test-worktrees/${id}`,
|
|
isMainWorktree: false,
|
|
};
|
|
this.worktrees.set(id, worktree);
|
|
return worktree;
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
if (!this.worktrees.has(id)) {
|
|
throw new Error(`Worktree not found: ${id}`);
|
|
}
|
|
this.worktrees.delete(id);
|
|
this.mergeResults.delete(id);
|
|
}
|
|
|
|
async list(): Promise<Worktree[]> {
|
|
return Array.from(this.worktrees.values());
|
|
}
|
|
|
|
async get(id: string): Promise<Worktree | null> {
|
|
return this.worktrees.get(id) ?? null;
|
|
}
|
|
|
|
async diff(id: string): Promise<WorktreeDiff> {
|
|
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<MergeResult> {
|
|
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<T extends DomainEvent>(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<typeof createCaller>;
|
|
|
|
// =============================================================================
|
|
// 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<SeededFixture>;
|
|
|
|
/**
|
|
* 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<PendingQuestions | null>;
|
|
|
|
/**
|
|
* Resume an idle agent to answer a conversation (mock: always returns false).
|
|
*/
|
|
resumeForConversation(agentId: string, conversationId: string, question: string, fromAgentId: string): Promise<boolean>;
|
|
|
|
/**
|
|
* 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<void>;
|
|
|
|
/**
|
|
* 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<void>): Promise<void>;
|
|
|
|
// ==========================================================================
|
|
// 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<Initiative | null>;
|
|
|
|
/**
|
|
* Get phases for initiative through tRPC.
|
|
*/
|
|
getPhases(initiativeId: string): Promise<Phase[]>;
|
|
|
|
/**
|
|
* Create initiative through tRPC.
|
|
*/
|
|
createInitiative(name: string): Promise<Initiative>;
|
|
|
|
/**
|
|
* Create phases from plan output through tRPC.
|
|
*/
|
|
createPhasesFromPlan(
|
|
initiativeId: string,
|
|
phases: Array<{ name: string }>
|
|
): Promise<Phase[]>;
|
|
|
|
/**
|
|
* Create a detail task through tRPC (replaces createPlan).
|
|
*/
|
|
createDetailTask(
|
|
phaseId: string,
|
|
name: string,
|
|
description?: string
|
|
): Promise<Task>;
|
|
|
|
/**
|
|
* Get child tasks of a parent task through tRPC.
|
|
*/
|
|
getChildTasks(parentTaskId: string): Promise<Task[]>;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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<void>) => {
|
|
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;
|
|
}
|