diff --git a/src/test/e2e/happy-path.test.ts b/src/test/e2e/happy-path.test.ts new file mode 100644 index 0000000..385e360 --- /dev/null +++ b/src/test/e2e/happy-path.test.ts @@ -0,0 +1,269 @@ +/** + * E2E Happy Path Tests + * + * Tests proving core dispatch/coordination flow works end-to-end + * using the TestHarness with mocked agents and worktrees. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createTestHarness, + SIMPLE_FIXTURE, + PARALLEL_FIXTURE, + COMPLEX_FIXTURE, + type TestHarness, +} from '../index.js'; + +describe('E2E Happy Path', () => { + let harness: TestHarness; + + beforeEach(() => { + harness = createTestHarness(); + }); + + afterEach(() => { + harness.cleanup(); + vi.useRealTimers(); + }); + + // =========================================================================== + // Scenario 1: Single Task Flow + // =========================================================================== + + describe('Single task flow', () => { + it('completes a single task from queue to completion', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Pre-seed idle agent (required by DispatchManager before spawning new ones) + 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); + + // Verify task:queued event + const queuedEvents = harness.getEventsByType('task:queued'); + expect(queuedEvents.length).toBe(1); + expect((queuedEvents[0].payload as { taskId: string }).taskId).toBe(taskAId); + + // Step 2: Dispatch task + const dispatchResult = await harness.dispatchManager.dispatchNext(); + expect(dispatchResult.success).toBe(true); + expect(dispatchResult.taskId).toBe(taskAId); + expect(dispatchResult.agentId).toBeDefined(); + + // Verify task:dispatched event + const dispatchedEvents = harness.getEventsByType('task:dispatched'); + expect(dispatchedEvents.length).toBe(1); + expect((dispatchedEvents[0].payload as { taskId: string }).taskId).toBe(taskAId); + + // Verify agent:spawned event + const spawnedEvents = harness.getEventsByType('agent:spawned'); + expect(spawnedEvents.length).toBe(1); + + // Step 3: Wait for agent completion + await vi.runAllTimersAsync(); + + // Verify agent:stopped event + const stoppedEvents = harness.getEventsByType('agent:stopped'); + expect(stoppedEvents.length).toBe(1); + + // Step 4: Mark task complete + await harness.dispatchManager.completeTask(taskAId); + + // Verify task status in database + const task = await harness.taskRepository.findById(taskAId); + expect(task?.status).toBe('completed'); + }); + }); + + // =========================================================================== + // Scenario 2: Sequential Dependencies + // =========================================================================== + + describe('Sequential dependencies', () => { + it('dispatches tasks in priority order (dependency ordering via task status)', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + const taskBId = seeded.tasks.get('Task B')!; + const taskCId = seeded.tasks.get('Task C')!; + + // Pre-seed idle agent + await harness.agentManager.spawn({ + name: 'pool-agent', + taskId: 'placeholder', + prompt: 'placeholder', + }); + await vi.runAllTimersAsync(); + harness.clearEvents(); + + // Queue all three tasks + await harness.dispatchManager.queue(taskAId); + await harness.dispatchManager.queue(taskBId); + await harness.dispatchManager.queue(taskCId); + harness.clearEvents(); + + // All three tasks are queued + const queueState = await harness.dispatchManager.getQueueState(); + expect(queueState.queued.length).toBe(3); + + // First dispatchNext: Task A (high priority) dispatches first + const nextTask = await harness.dispatchManager.getNextDispatchable(); + expect(nextTask).not.toBeNull(); + expect(nextTask!.taskId).toBe(taskAId); // High priority first + + // All tasks are "ready" in current implementation (dependency loading TBD) + const readyTaskIds = queueState.ready.map((t) => t.taskId); + expect(readyTaskIds).toContain(taskAId); + + // Dispatch Task A + const dispatchResult = await harness.dispatchManager.dispatchNext(); + expect(dispatchResult.success).toBe(true); + expect(dispatchResult.taskId).toBe(taskAId); + + // Wait for agent completion + await vi.runAllTimersAsync(); + + // Complete Task A + await harness.dispatchManager.completeTask(taskAId); + + // Verify Task A removed from queue, B and C remain + const queueStateAfter = await harness.dispatchManager.getQueueState(); + const remainingTaskIds = queueStateAfter.queued.map((t) => t.taskId); + expect(remainingTaskIds).not.toContain(taskAId); + expect(remainingTaskIds).toContain(taskBId); + expect(remainingTaskIds).toContain(taskCId); + + // Task A marked completed in database + const taskA = await harness.taskRepository.findById(taskAId); + expect(taskA?.status).toBe('completed'); + }); + }); + + // =========================================================================== + // Scenario 3: Parallel Dispatch + // =========================================================================== + + describe('Parallel dispatch', () => { + it('dispatches multiple independent tasks to multiple agents', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(PARALLEL_FIXTURE); + const taskXId = seeded.tasks.get('Task X')!; + const taskYId = seeded.tasks.get('Task Y')!; + const taskPId = seeded.tasks.get('Task P')!; + const taskQId = seeded.tasks.get('Task Q')!; + + // Pre-seed 2 idle agents + await harness.agentManager.spawn({ + name: 'pool-agent-1', + taskId: 'placeholder-1', + prompt: 'placeholder', + }); + await harness.agentManager.spawn({ + name: 'pool-agent-2', + taskId: 'placeholder-2', + prompt: 'placeholder', + }); + await vi.runAllTimersAsync(); + harness.clearEvents(); + + // Queue all 4 tasks + await harness.dispatchManager.queue(taskXId); + await harness.dispatchManager.queue(taskYId); + await harness.dispatchManager.queue(taskPId); + await harness.dispatchManager.queue(taskQId); + harness.clearEvents(); + + // All 4 tasks should be dispatchable (no dependencies) + const queueState = await harness.dispatchManager.getQueueState(); + expect(queueState.ready.length).toBe(4); + + // Dispatch first task + const result1 = await harness.dispatchManager.dispatchNext(); + expect(result1.success).toBe(true); + + // Dispatch second task (parallel) + const result2 = await harness.dispatchManager.dispatchNext(); + expect(result2.success).toBe(true); + + // Verify both agents assigned different tasks + expect(result1.taskId).not.toBe(result2.taskId); + expect(result1.agentId).not.toBe(result2.agentId); + + // Both dispatches succeeded + const dispatchedEvents = harness.getEventsByType('task:dispatched'); + expect(dispatchedEvents.length).toBe(2); + }); + }); + + // =========================================================================== + // Scenario 4: Full Merge Flow + // =========================================================================== + + describe('Full merge flow', () => { + it('queues and processes merge after task completion', async () => { + vi.useFakeTimers(); + const seeded = await harness.seedFixture(SIMPLE_FIXTURE); + const taskAId = seeded.tasks.get('Task A')!; + + // Pre-seed idle agent in MockAgentManager + await harness.agentManager.spawn({ + name: 'pool-agent', + taskId: 'placeholder', + prompt: 'placeholder', + }); + await vi.runAllTimersAsync(); + harness.clearEvents(); + + // Queue and dispatch task + await harness.dispatchManager.queue(taskAId); + const dispatchResult = await harness.dispatchManager.dispatchNext(); + expect(dispatchResult.success).toBe(true); + + // Wait for agent completion + await vi.runAllTimersAsync(); + + // Complete task + await harness.dispatchManager.completeTask(taskAId); + harness.clearEvents(); + + // Create agent in database (CoordinationManager.queueMerge requires it) + // This bridges the gap between MockAgentManager (in-memory) and AgentRepository (database) + const worktreeId = `worktree-${taskAId.slice(0, 8)}`; + const agent = await harness.agentRepository.create({ + name: `agent-${taskAId.slice(0, 6)}`, + taskId: taskAId, + worktreeId, + status: 'idle', + }); + + // Create worktree for merge + await harness.worktreeManager.create(worktreeId, `feature-${taskAId.slice(0, 6)}`); + + // Queue merge + await harness.coordinationManager.queueMerge(taskAId); + + // Verify merge:queued event + const mergeQueuedEvents = harness.getEventsByType('merge:queued'); + expect(mergeQueuedEvents.length).toBe(1); + + // Process merges + const mergeResults = await harness.coordinationManager.processMerges('main'); + expect(mergeResults.length).toBe(1); + expect(mergeResults[0].taskId).toBe(taskAId); + expect(mergeResults[0].success).toBe(true); + + // Verify merge:completed event + const mergeCompletedEvents = harness.getEventsByType('merge:completed'); + expect(mergeCompletedEvents.length).toBe(1); + }); + }); +});