Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
/**
|
|
* 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 harness.advanceTimers();
|
|
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 harness.advanceTimers();
|
|
|
|
// 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 harness.advanceTimers();
|
|
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 harness.advanceTimers();
|
|
|
|
// 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 harness.advanceTimers();
|
|
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 harness.advanceTimers();
|
|
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 harness.advanceTimers();
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Scenario 5: Complex Dependency Flow
|
|
// ===========================================================================
|
|
|
|
describe('Complex dependency flow', () => {
|
|
it('handles multi-level dependency graph with COMPLEX_FIXTURE', async () => {
|
|
vi.useFakeTimers();
|
|
const seeded = await harness.seedFixture(COMPLEX_FIXTURE);
|
|
|
|
// Get all task IDs
|
|
const task1AId = seeded.tasks.get('Task 1A')!;
|
|
const task1BId = seeded.tasks.get('Task 1B')!;
|
|
const task2AId = seeded.tasks.get('Task 2A')!;
|
|
const task3AId = seeded.tasks.get('Task 3A')!;
|
|
const task4AId = seeded.tasks.get('Task 4A')!;
|
|
|
|
// Pre-seed idle agent
|
|
await harness.agentManager.spawn({
|
|
name: 'pool-agent',
|
|
taskId: 'placeholder',
|
|
prompt: 'placeholder',
|
|
});
|
|
await harness.advanceTimers();
|
|
harness.clearEvents();
|
|
|
|
// Queue all 5 tasks
|
|
await harness.dispatchManager.queue(task1AId);
|
|
await harness.dispatchManager.queue(task1BId);
|
|
await harness.dispatchManager.queue(task2AId);
|
|
await harness.dispatchManager.queue(task3AId);
|
|
await harness.dispatchManager.queue(task4AId);
|
|
harness.clearEvents();
|
|
|
|
// Verify all 5 tasks are queued
|
|
const initialState = await harness.dispatchManager.getQueueState();
|
|
expect(initialState.queued.length).toBe(5);
|
|
|
|
// Only tasks with no dependencies are ready:
|
|
// - Task 1A: no deps -> READY
|
|
// - Task 1B: no deps -> READY
|
|
// - Task 2A: depends on 1A -> NOT READY
|
|
// - Task 3A: depends on 1B -> NOT READY
|
|
// - Task 4A: depends on 2A, 3A -> NOT READY
|
|
expect(initialState.ready.length).toBe(2);
|
|
|
|
// First dispatch: Task 1A (high priority, first queued)
|
|
const result1 = await harness.dispatchManager.dispatchNext();
|
|
expect(result1.success).toBe(true);
|
|
expect(result1.taskId).toBe(task1AId);
|
|
|
|
// Wait for agent completion
|
|
await harness.advanceTimers();
|
|
|
|
// Complete Task 1A
|
|
await harness.dispatchManager.completeTask(task1AId);
|
|
|
|
// Verify Task 1A completed in database
|
|
const task1A = await harness.taskRepository.findById(task1AId);
|
|
expect(task1A?.status).toBe('completed');
|
|
|
|
// 4 tasks remain in queue
|
|
const afterFirstState = await harness.dispatchManager.getQueueState();
|
|
expect(afterFirstState.queued.length).toBe(4);
|
|
|
|
// Dispatch and complete remaining tasks one by one
|
|
// Task 1B (high priority among remaining)
|
|
const result2 = await harness.dispatchManager.dispatchNext();
|
|
expect(result2.success).toBe(true);
|
|
await harness.advanceTimers();
|
|
await harness.dispatchManager.completeTask(result2.taskId!);
|
|
|
|
// 3 tasks remain
|
|
const midState = await harness.dispatchManager.getQueueState();
|
|
expect(midState.queued.length).toBe(3);
|
|
|
|
// Continue dispatching remaining tasks
|
|
const result3 = await harness.dispatchManager.dispatchNext();
|
|
expect(result3.success).toBe(true);
|
|
await harness.advanceTimers();
|
|
await harness.dispatchManager.completeTask(result3.taskId!);
|
|
|
|
const result4 = await harness.dispatchManager.dispatchNext();
|
|
expect(result4.success).toBe(true);
|
|
await harness.advanceTimers();
|
|
await harness.dispatchManager.completeTask(result4.taskId!);
|
|
|
|
const result5 = await harness.dispatchManager.dispatchNext();
|
|
expect(result5.success).toBe(true);
|
|
await harness.advanceTimers();
|
|
await harness.dispatchManager.completeTask(result5.taskId!);
|
|
|
|
// All tasks completed
|
|
const finalState = await harness.dispatchManager.getQueueState();
|
|
expect(finalState.queued.length).toBe(0);
|
|
|
|
// Verify all 5 tasks completed in database
|
|
const allTasks = await Promise.all([
|
|
harness.taskRepository.findById(task1AId),
|
|
harness.taskRepository.findById(task1BId),
|
|
harness.taskRepository.findById(task2AId),
|
|
harness.taskRepository.findById(task3AId),
|
|
harness.taskRepository.findById(task4AId),
|
|
]);
|
|
expect(allTasks.every((t) => t?.status === 'completed')).toBe(true);
|
|
|
|
// Verify event sequence: 5 task:dispatched, 5 task:completed
|
|
const dispatchedEvents = harness.getEventsByType('task:dispatched');
|
|
expect(dispatchedEvents.length).toBe(5);
|
|
|
|
const completedEvents = harness.getEventsByType('task:completed');
|
|
expect(completedEvents.length).toBe(5);
|
|
});
|
|
|
|
it('fixture dependencies are stored correctly in database', async () => {
|
|
const seeded = await harness.seedFixture(COMPLEX_FIXTURE);
|
|
|
|
// Get task IDs
|
|
const task1AId = seeded.tasks.get('Task 1A')!;
|
|
const task1BId = seeded.tasks.get('Task 1B')!;
|
|
const task2AId = seeded.tasks.get('Task 2A')!;
|
|
const task3AId = seeded.tasks.get('Task 3A')!;
|
|
const task4AId = seeded.tasks.get('Task 4A')!;
|
|
|
|
// Query task_dependencies directly to verify fixture setup
|
|
const { taskDependencies } = await import('../../db/schema.js');
|
|
const { eq } = await import('drizzle-orm');
|
|
|
|
// Task 2A should depend on Task 1A
|
|
const task2ADeps = await harness.db
|
|
.select()
|
|
.from(taskDependencies)
|
|
.where(eq(taskDependencies.taskId, task2AId));
|
|
expect(task2ADeps.length).toBe(1);
|
|
expect(task2ADeps[0].dependsOnTaskId).toBe(task1AId);
|
|
|
|
// Task 3A should depend on Task 1B
|
|
const task3ADeps = await harness.db
|
|
.select()
|
|
.from(taskDependencies)
|
|
.where(eq(taskDependencies.taskId, task3AId));
|
|
expect(task3ADeps.length).toBe(1);
|
|
expect(task3ADeps[0].dependsOnTaskId).toBe(task1BId);
|
|
|
|
// Task 4A should depend on both Task 2A and Task 3A
|
|
const task4ADeps = await harness.db
|
|
.select()
|
|
.from(taskDependencies)
|
|
.where(eq(taskDependencies.taskId, task4AId));
|
|
expect(task4ADeps.length).toBe(2);
|
|
const depIds = task4ADeps.map((d) => d.dependsOnTaskId);
|
|
expect(depIds).toContain(task2AId);
|
|
expect(depIds).toContain(task3AId);
|
|
|
|
// Tasks 1A and 1B should have no dependencies
|
|
const task1ADeps = await harness.db
|
|
.select()
|
|
.from(taskDependencies)
|
|
.where(eq(taskDependencies.taskId, task1AId));
|
|
expect(task1ADeps.length).toBe(0);
|
|
|
|
const task1BDeps = await harness.db
|
|
.select()
|
|
.from(taskDependencies)
|
|
.where(eq(taskDependencies.taskId, task1BId));
|
|
expect(task1BDeps.length).toBe(0);
|
|
});
|
|
});
|
|
});
|