Files
Codewalkers/apps/server/test/harness.ts
Lukas May 3a328d2b1c feat: Add errands schema, repository, and wire into tRPC context/container
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>
2026-03-06 15:49:26 +01:00

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;
}