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
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
/**
|
|
* 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.setAgentQuestion).toBe('function');
|
|
expect(typeof harness.setAgentQuestions).toBe('function');
|
|
expect(typeof harness.getEventsByType).toBe('function');
|
|
expect(typeof harness.clearEvents).toBe('function');
|
|
expect(typeof harness.cleanup).toBe('function');
|
|
});
|
|
});
|
|
|
|
describe('setAgentQuestion convenience helper', () => {
|
|
it('wraps single question in array format', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
// Set single question using convenience method
|
|
harness.setAgentQuestion('test-agent', 'q1', 'Which option?', [
|
|
{ label: 'Option A', description: 'First option' },
|
|
{ label: 'Option B', description: 'Second option' },
|
|
]);
|
|
|
|
// Spawn agent with that scenario
|
|
const agent = await harness.agentManager.spawn({
|
|
name: 'test-agent',
|
|
taskId: 'task-1',
|
|
prompt: 'test',
|
|
});
|
|
|
|
await harness.advanceTimers();
|
|
|
|
// Verify questions array format
|
|
const pending = await harness.getPendingQuestions(agent.id);
|
|
expect(pending).not.toBeNull();
|
|
expect(pending?.questions).toHaveLength(1);
|
|
expect(pending?.questions[0].id).toBe('q1');
|
|
expect(pending?.questions[0].question).toBe('Which option?');
|
|
expect(pending?.questions[0].options).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
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 task groups created
|
|
expect(seeded.taskGroups.size).toBe(1);
|
|
expect(seeded.taskGroups.has('Task Group 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.taskGroups.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.taskGroups.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 harness.advanceTimers();
|
|
|
|
// 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 harness.advanceTimers();
|
|
|
|
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 harness.advanceTimers();
|
|
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 harness.advanceTimers();
|
|
|
|
// 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 harness.advanceTimers();
|
|
harness.clearEvents();
|
|
|
|
// Set error scenario for the agent that will be spawned
|
|
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
|
|
status: 'error',
|
|
delay: 0,
|
|
error: 'Test crash',
|
|
});
|
|
|
|
// Queue and dispatch
|
|
await harness.dispatchManager.queue(taskAId);
|
|
harness.clearEvents();
|
|
await harness.dispatchManager.dispatchNext();
|
|
|
|
// Advance timers
|
|
await harness.advanceTimers();
|
|
|
|
// 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 harness.advanceTimers();
|
|
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 harness.advanceTimers();
|
|
|
|
// 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']);
|
|
});
|
|
});
|
|
});
|