diff --git a/src/test/harness.test.ts b/src/test/harness.test.ts new file mode 100644 index 0000000..77e4653 --- /dev/null +++ b/src/test/harness.test.ts @@ -0,0 +1,363 @@ +/** + * Tests for Test Harness + * + * Proves that the test harness enables E2E testing scenarios. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createTestHarness, + SIMPLE_FIXTURE, + PARALLEL_FIXTURE, + COMPLEX_FIXTURE, + type TestHarness, +} from './index.js'; +import { taskDependencies } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; + +describe('TestHarness', () => { + let harness: TestHarness; + + beforeEach(() => { + harness = createTestHarness(); + }); + + afterEach(() => { + harness.cleanup(); + vi.useRealTimers(); + }); + + describe('createTestHarness', () => { + it('returns all components', () => { + expect(harness.db).toBeDefined(); + expect(harness.eventBus).toBeDefined(); + expect(harness.agentManager).toBeDefined(); + expect(harness.worktreeManager).toBeDefined(); + expect(harness.dispatchManager).toBeDefined(); + expect(harness.coordinationManager).toBeDefined(); + expect(harness.taskRepository).toBeDefined(); + expect(harness.messageRepository).toBeDefined(); + expect(harness.agentRepository).toBeDefined(); + }); + + it('provides helper methods', () => { + expect(typeof harness.seedFixture).toBe('function'); + expect(typeof harness.setAgentScenario).toBe('function'); + expect(typeof harness.getEventsByType).toBe('function'); + expect(typeof harness.clearEvents).toBe('function'); + expect(typeof harness.cleanup).toBe('function'); + }); + }); + + describe('seedFixture', () => { + it('creates task hierarchy from SIMPLE_FIXTURE', async () => { + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + + // Check initiative created + expect(seeded.initiativeId).toBeDefined(); + + // Check phases created + expect(seeded.phases.size).toBe(1); + expect(seeded.phases.has('Phase 1')).toBe(true); + + // Check plans created + expect(seeded.plans.size).toBe(1); + expect(seeded.plans.has('Plan 1')).toBe(true); + + // Check tasks created + expect(seeded.tasks.size).toBe(3); + expect(seeded.tasks.has('Task A')).toBe(true); + expect(seeded.tasks.has('Task B')).toBe(true); + expect(seeded.tasks.has('Task C')).toBe(true); + }); + + it('returns correct IDs that exist in database', async () => { + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + + // Verify task exists in database + const taskAId = seeded.tasks.get('Task A')!; + const taskA = await harness.taskRepository.findById(taskAId); + expect(taskA).not.toBeNull(); + expect(taskA?.name).toBe('Task A'); + }); + + it('creates PARALLEL_FIXTURE correctly', async () => { + const seeded = await harness.seedFixture(PARALLEL_FIXTURE); + + expect(seeded.phases.size).toBe(1); + expect(seeded.plans.size).toBe(2); + expect(seeded.tasks.size).toBe(4); + expect(seeded.tasks.has('Task X')).toBe(true); + expect(seeded.tasks.has('Task Q')).toBe(true); + }); + + it('creates COMPLEX_FIXTURE correctly', async () => { + const seeded = await harness.seedFixture(COMPLEX_FIXTURE); + + expect(seeded.phases.size).toBe(2); + expect(seeded.plans.size).toBe(4); + expect(seeded.tasks.size).toBe(5); + }); + }); + + describe('task dependencies', () => { + it('resolves dependencies correctly (dependsOn contains actual task IDs)', async () => { + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + + const taskAId = seeded.tasks.get('Task A')!; + const taskBId = seeded.tasks.get('Task B')!; + + // Query task_dependencies table directly + const deps = await harness.db + .select() + .from(taskDependencies) + .where(eq(taskDependencies.taskId, taskBId)); + + expect(deps.length).toBe(1); + expect(deps[0].dependsOnTaskId).toBe(taskAId); + }); + + it('creates multiple dependencies for a task', async () => { + const seeded = await harness.seedFixture(COMPLEX_FIXTURE); + + // Task 4A depends on both Task 2A and Task 3A + const task4AId = seeded.tasks.get('Task 4A')!; + const task2AId = seeded.tasks.get('Task 2A')!; + const task3AId = seeded.tasks.get('Task 3A')!; + + const deps = await harness.db + .select() + .from(taskDependencies) + .where(eq(taskDependencies.taskId, task4AId)); + + expect(deps.length).toBe(2); + const depIds = deps.map((d) => d.dependsOnTaskId); + expect(depIds).toContain(task2AId); + expect(depIds).toContain(task3AId); + }); + }); + + describe('event capture', () => { + it('captures events via getEventsByType', async () => { + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Queue a task (emits task:queued event) + await harness.dispatchManager.queue(taskAId); + + const events = harness.getEventsByType('task:queued'); + expect(events.length).toBe(1); + expect((events[0].payload as { taskId: string }).taskId).toBe(taskAId); + }); + + it('clears events via clearEvents', async () => { + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + await harness.dispatchManager.queue(taskAId); + expect(harness.getEventsByType('task:queued').length).toBe(1); + + harness.clearEvents(); + expect(harness.getEventsByType('task:queued').length).toBe(0); + }); + }); + + describe('dispatch flow', () => { + it('dispatchManager.queue() + dispatchNext() uses MockAgentManager', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Note: DispatchManager.dispatchNext() requires an idle agent in the pool + // before it will spawn a new agent. Pre-seed an idle agent. + await harness.agentManager.spawn({ + name: 'pool-agent', + taskId: 'placeholder', + prompt: 'placeholder', + }); + // Wait for agent to complete and become idle + await vi.runAllTimersAsync(); + + // Queue the task + await harness.dispatchManager.queue(taskAId); + + // Clear events from queue and agent spawn + harness.clearEvents(); + + // Dispatch the task + const result = await harness.dispatchManager.dispatchNext(); + + // Advance timers to trigger mock agent completion + await vi.runAllTimersAsync(); + + expect(result.success).toBe(true); + expect(result.taskId).toBe(taskAId); + expect(result.agentId).toBeDefined(); + + // Should have emitted task:dispatched + const dispatchedEvents = harness.getEventsByType('task:dispatched'); + expect(dispatchedEvents.length).toBe(1); + }); + + it('returns failure when no tasks are queued', async () => { + const result = await harness.dispatchManager.dispatchNext(); + + expect(result.success).toBe(false); + expect(result.reason).toBe('No dispatchable tasks'); + }); + + it('returns failure when no idle agents available', async () => { + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Queue the task but don't pre-seed any agents + await harness.dispatchManager.queue(taskAId); + + // Dispatch without any agents in pool + const result = await harness.dispatchManager.dispatchNext(); + + expect(result.success).toBe(false); + expect(result.reason).toBe('No available agents'); + }); + }); + + describe('agent completion triggers events', () => { + it('agent completion emits agent:stopped event', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Pre-seed an idle agent (required by DispatchManager) + await harness.agentManager.spawn({ + name: 'pool-agent', + taskId: 'placeholder', + prompt: 'placeholder', + }); + await vi.runAllTimersAsync(); + harness.clearEvents(); + + // Queue and dispatch + await harness.dispatchManager.queue(taskAId); + harness.clearEvents(); + await harness.dispatchManager.dispatchNext(); + + // Should have agent:spawned + const spawnedEvents = harness.getEventsByType('agent:spawned'); + expect(spawnedEvents.length).toBe(1); + + // Advance timers to trigger completion + await vi.runAllTimersAsync(); + + // Should have agent:stopped + const stoppedEvents = harness.getEventsByType('agent:stopped'); + expect(stoppedEvents.length).toBe(1); + }); + + it('custom scenario affects agent behavior', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Pre-seed an idle agent (required by DispatchManager) + await harness.agentManager.spawn({ + name: 'pool-agent', + taskId: 'placeholder', + prompt: 'placeholder', + }); + await vi.runAllTimersAsync(); + harness.clearEvents(); + + // Set crash scenario for the agent that will be spawned + harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, { + outcome: 'crash', + delay: 0, + message: 'Test crash', + }); + + // Queue and dispatch + await harness.dispatchManager.queue(taskAId); + harness.clearEvents(); + await harness.dispatchManager.dispatchNext(); + + // Advance timers + await vi.runAllTimersAsync(); + + // Should have agent:crashed + const crashedEvents = harness.getEventsByType('agent:crashed'); + expect(crashedEvents.length).toBe(1); + }); + }); + + describe('full dispatch -> complete -> merge flow', () => { + it('works end-to-end', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Pre-seed an idle agent (required by DispatchManager) + await harness.agentManager.spawn({ + name: 'pool-agent', + taskId: 'placeholder', + prompt: 'placeholder', + }); + await vi.runAllTimersAsync(); + harness.clearEvents(); + + // Step 1: Queue task + await harness.dispatchManager.queue(taskAId); + + // Step 2: Dispatch task + const dispatchResult = await harness.dispatchManager.dispatchNext(); + expect(dispatchResult.success).toBe(true); + + // Advance timers for agent completion + await vi.runAllTimersAsync(); + + // Clear events for cleaner verification + harness.clearEvents(); + + // Step 3: Complete task + await harness.dispatchManager.completeTask(taskAId); + + // Verify task:completed event + const completedEvents = harness.getEventsByType('task:completed'); + expect(completedEvents.length).toBe(1); + expect((completedEvents[0].payload as { taskId: string }).taskId).toBe(taskAId); + + // Step 4: Verify task status in database + const task = await harness.taskRepository.findById(taskAId); + expect(task?.status).toBe('completed'); + }); + }); + + describe('MockWorktreeManager', () => { + it('creates fake worktrees', async () => { + const worktree = await harness.worktreeManager.create('wt-1', 'feature-1'); + + expect(worktree.id).toBe('wt-1'); + expect(worktree.branch).toBe('feature-1'); + expect(worktree.path).toContain('wt-1'); + }); + + it('merge returns success by default', async () => { + await harness.worktreeManager.create('wt-1', 'feature-1'); + const result = await harness.worktreeManager.merge('wt-1', 'main'); + + expect(result.success).toBe(true); + }); + + it('allows setting custom merge results', async () => { + await harness.worktreeManager.create('wt-1', 'feature-1'); + harness.worktreeManager.setMergeResult('wt-1', { + success: false, + conflicts: ['file1.ts', 'file2.ts'], + message: 'Merge conflict', + }); + + const result = await harness.worktreeManager.merge('wt-1', 'main'); + + expect(result.success).toBe(false); + expect(result.conflicts).toEqual(['file1.ts', 'file2.ts']); + }); + }); +});