diff --git a/src/test/e2e/phase-dispatch.test.ts b/src/test/e2e/phase-dispatch.test.ts new file mode 100644 index 0000000..a1e69cc --- /dev/null +++ b/src/test/e2e/phase-dispatch.test.ts @@ -0,0 +1,435 @@ +/** + * E2E Tests for Phase Parallel Execution + * + * Tests proving phase dispatch/coordination flow works end-to-end + * using the TestHarness with phaseDispatchManager. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTestHarness, type TestHarness } from '../index.js'; +import type { + PhaseQueuedEvent, + PhaseStartedEvent, + PhaseCompletedEvent, + PhaseBlockedEvent, +} from '../../events/types.js'; + +describe('Phase Parallel Execution', () => { + let harness: TestHarness; + + beforeEach(() => { + harness = createTestHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + // =========================================================================== + // Test 1: Independent phases dispatch in parallel + // =========================================================================== + + describe('Independent phases dispatch in parallel', () => { + it('dispatches multiple independent phases when no dependencies exist', async () => { + // Create initiative with 2 independent phases (no dependencies) + const initiative = await harness.initiativeRepository.create({ + name: 'Independent Phases Test', + description: 'Test initiative with independent phases', + status: 'active', + }); + + const phaseA = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 1, + name: 'Phase A', + description: 'Independent phase A', + status: 'pending', + }); + + const phaseB = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 2, + name: 'Phase B', + description: 'Independent phase B', + status: 'pending', + }); + + // Queue both phases + await harness.phaseDispatchManager.queuePhase(phaseA.id); + await harness.phaseDispatchManager.queuePhase(phaseB.id); + + // Verify phase:queued events + const queuedEvents = harness.getEventsByType('phase:queued'); + expect(queuedEvents.length).toBe(2); + + // Get queue state - both should be ready (no dependencies) + const queueState = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState.queued.length).toBe(2); + expect(queueState.ready.length).toBe(2); + expect(queueState.blocked.length).toBe(0); + + // Both phases should be dispatchable immediately + const readyPhaseIds = queueState.ready.map((p) => p.phaseId); + expect(readyPhaseIds).toContain(phaseA.id); + expect(readyPhaseIds).toContain(phaseB.id); + + harness.clearEvents(); + + // Dispatch first phase + const result1 = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result1.success).toBe(true); + + // Dispatch second phase (parallel) + const result2 = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result2.success).toBe(true); + + // Verify both dispatched to different phases + expect(result1.phaseId).not.toBe(result2.phaseId); + + // Verify phase:started events + const startedEvents = harness.getEventsByType('phase:started'); + expect(startedEvents.length).toBe(2); + + // Verify both phases are now in_progress + const updatedPhaseA = await harness.phaseRepository.findById(phaseA.id); + const updatedPhaseB = await harness.phaseRepository.findById(phaseB.id); + expect(updatedPhaseA?.status).toBe('in_progress'); + expect(updatedPhaseB?.status).toBe('in_progress'); + }); + }); + + // =========================================================================== + // Test 2: Dependent phase waits for prerequisite + // =========================================================================== + + describe('Dependent phase waits for prerequisite', () => { + it('only dispatches phase A first, then B after A completes', async () => { + // Create phases: A, B (depends on A) + const initiative = await harness.initiativeRepository.create({ + name: 'Sequential Phases Test', + description: 'Test initiative with sequential phases', + status: 'active', + }); + + const phaseA = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 1, + name: 'Phase A', + description: 'First phase', + status: 'pending', + }); + + const phaseB = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 2, + name: 'Phase B', + description: 'Second phase, depends on A', + status: 'pending', + }); + + // Create dependency: B depends on A + await harness.phaseRepository.createDependency(phaseB.id, phaseA.id); + + // Queue both phases + await harness.phaseDispatchManager.queuePhase(phaseA.id); + await harness.phaseDispatchManager.queuePhase(phaseB.id); + + // Check queue state - only A should be ready + const queueState1 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState1.queued.length).toBe(2); + expect(queueState1.ready.length).toBe(1); + expect(queueState1.ready[0].phaseId).toBe(phaseA.id); + + harness.clearEvents(); + + // Dispatch - should get phase A + const result1 = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result1.success).toBe(true); + expect(result1.phaseId).toBe(phaseA.id); + + // Try to dispatch again - should fail (B is blocked by A) + const result2 = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result2.success).toBe(false); + expect(result2.reason).toBe('No dispatchable phases'); + + // Verify phase B still in queue but not ready + const queueState2 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState2.queued.length).toBe(1); + expect(queueState2.ready.length).toBe(0); + + // Complete phase A + await harness.phaseDispatchManager.completePhase(phaseA.id); + + // Verify phase:completed event for A + const completedEvents = harness.getEventsByType('phase:completed'); + expect(completedEvents.length).toBe(1); + expect((completedEvents[0] as PhaseCompletedEvent).payload.phaseId).toBe(phaseA.id); + + // Now B should be ready + const queueState3 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState3.ready.length).toBe(1); + expect(queueState3.ready[0].phaseId).toBe(phaseB.id); + + harness.clearEvents(); + + // Dispatch - should get phase B + const result3 = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result3.success).toBe(true); + expect(result3.phaseId).toBe(phaseB.id); + + // Verify phase B is now in_progress + const updatedPhaseB = await harness.phaseRepository.findById(phaseB.id); + expect(updatedPhaseB?.status).toBe('in_progress'); + }); + }); + + // =========================================================================== + // Test 3: Diamond dependency pattern + // =========================================================================== + + describe('Diamond dependency pattern', () => { + it('handles diamond: A -> B,C -> D correctly', async () => { + // Create phases: A, B (depends on A), C (depends on A), D (depends on B, C) + const initiative = await harness.initiativeRepository.create({ + name: 'Diamond Pattern Test', + description: 'Test initiative with diamond dependency pattern', + status: 'active', + }); + + const phaseA = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 1, + name: 'Phase A', + description: 'Root phase', + status: 'pending', + }); + + const phaseB = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 2, + name: 'Phase B', + description: 'Depends on A', + status: 'pending', + }); + + const phaseC = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 3, + name: 'Phase C', + description: 'Depends on A', + status: 'pending', + }); + + const phaseD = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 4, + name: 'Phase D', + description: 'Depends on B and C', + status: 'pending', + }); + + // Create dependencies + await harness.phaseRepository.createDependency(phaseB.id, phaseA.id); + await harness.phaseRepository.createDependency(phaseC.id, phaseA.id); + await harness.phaseRepository.createDependency(phaseD.id, phaseB.id); + await harness.phaseRepository.createDependency(phaseD.id, phaseC.id); + + // Queue all phases + await harness.phaseDispatchManager.queuePhase(phaseA.id); + await harness.phaseDispatchManager.queuePhase(phaseB.id); + await harness.phaseDispatchManager.queuePhase(phaseC.id); + await harness.phaseDispatchManager.queuePhase(phaseD.id); + + // Step 1: Only A should be ready + const state1 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(state1.queued.length).toBe(4); + expect(state1.ready.length).toBe(1); + expect(state1.ready[0].phaseId).toBe(phaseA.id); + + // Dispatch A + const resultA = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(resultA.success).toBe(true); + expect(resultA.phaseId).toBe(phaseA.id); + + // Step 2: After A completes, B and C should be ready (parallel) + await harness.phaseDispatchManager.completePhase(phaseA.id); + + const state2 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(state2.queued.length).toBe(3); // B, C, D still queued + expect(state2.ready.length).toBe(2); // B and C ready + + const readyIds = state2.ready.map((p) => p.phaseId); + expect(readyIds).toContain(phaseB.id); + expect(readyIds).toContain(phaseC.id); + expect(readyIds).not.toContain(phaseD.id); + + // Dispatch B and C in parallel + const resultB = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(resultB.success).toBe(true); + + const resultC = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(resultC.success).toBe(true); + + // Verify D is still not ready (needs both B and C complete) + const state3 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(state3.ready.length).toBe(0); + expect(state3.queued.length).toBe(1); + expect(state3.queued[0].phaseId).toBe(phaseD.id); + + // Step 3: Complete B only - D still not ready + await harness.phaseDispatchManager.completePhase(resultB.phaseId); + + const state4 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(state4.ready.length).toBe(0); // D still blocked by C + + // Step 4: Complete C - now D should be ready + await harness.phaseDispatchManager.completePhase(resultC.phaseId); + + const state5 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(state5.ready.length).toBe(1); + expect(state5.ready[0].phaseId).toBe(phaseD.id); + + // Dispatch D + const resultD = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(resultD.success).toBe(true); + expect(resultD.phaseId).toBe(phaseD.id); + + // Verify D is now in_progress + const updatedPhaseD = await harness.phaseRepository.findById(phaseD.id); + expect(updatedPhaseD?.status).toBe('in_progress'); + }); + }); + + // =========================================================================== + // Test 4: Blocked phase doesn't dispatch + // =========================================================================== + + describe('Blocked phase does not dispatch', () => { + it('prevents dispatch of blocked phase even if dependencies complete', async () => { + // Create phases: A, B (depends on A) + const initiative = await harness.initiativeRepository.create({ + name: 'Blocked Phase Test', + description: 'Test initiative with blocked phase', + status: 'active', + }); + + const phaseA = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 1, + name: 'Phase A', + description: 'First phase that will be blocked', + status: 'pending', + }); + + const phaseB = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 2, + name: 'Phase B', + description: 'Second phase, depends on A', + status: 'pending', + }); + + // Create dependency: B depends on A + await harness.phaseRepository.createDependency(phaseB.id, phaseA.id); + + // Queue phase A + await harness.phaseDispatchManager.queuePhase(phaseA.id); + + // Block phase A + await harness.phaseDispatchManager.blockPhase(phaseA.id, 'External dependency unavailable'); + + // Verify phase:blocked event + const blockedEvents = harness.getEventsByType('phase:blocked'); + expect(blockedEvents.length).toBe(1); + expect((blockedEvents[0] as PhaseBlockedEvent).payload.phaseId).toBe(phaseA.id); + expect((blockedEvents[0] as PhaseBlockedEvent).payload.reason).toBe( + 'External dependency unavailable' + ); + + // Try to dispatch - should fail + const result = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result.success).toBe(false); + expect(result.reason).toBe('No dispatchable phases'); + + // Verify queue state shows A as blocked + const queueState = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState.blocked.length).toBe(1); + expect(queueState.blocked[0].phaseId).toBe(phaseA.id); + expect(queueState.blocked[0].reason).toBe('External dependency unavailable'); + + // Queue phase B + await harness.phaseDispatchManager.queuePhase(phaseB.id); + + // B should never become ready because A is blocked (not completed) + const queueState2 = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState2.ready.length).toBe(0); + expect(queueState2.queued.length).toBe(1); // Only B is queued (A is blocked, not queued) + expect(queueState2.queued[0].phaseId).toBe(phaseB.id); + + // Try to dispatch B - should fail + const resultB = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(resultB.success).toBe(false); + expect(resultB.reason).toBe('No dispatchable phases'); + + // Verify phase A status is blocked in database + const updatedPhaseA = await harness.phaseRepository.findById(phaseA.id); + expect(updatedPhaseA?.status).toBe('blocked'); + }); + + it('blocked phase prevents all downstream phases from dispatching', async () => { + // Create chain: A -> B -> C, then block A + const initiative = await harness.initiativeRepository.create({ + name: 'Chain Block Test', + description: 'Test blocking propagates down chain', + status: 'active', + }); + + const phaseA = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 1, + name: 'Phase A', + description: 'Root phase', + status: 'pending', + }); + + const phaseB = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 2, + name: 'Phase B', + description: 'Depends on A', + status: 'pending', + }); + + const phaseC = await harness.phaseRepository.create({ + initiativeId: initiative.id, + number: 3, + name: 'Phase C', + description: 'Depends on B', + status: 'pending', + }); + + // Create dependency chain: A -> B -> C + await harness.phaseRepository.createDependency(phaseB.id, phaseA.id); + await harness.phaseRepository.createDependency(phaseC.id, phaseB.id); + + // Queue all phases + await harness.phaseDispatchManager.queuePhase(phaseA.id); + await harness.phaseDispatchManager.queuePhase(phaseB.id); + await harness.phaseDispatchManager.queuePhase(phaseC.id); + + // Block phase A + await harness.phaseDispatchManager.blockPhase(phaseA.id, 'Resource unavailable'); + + // Verify only B and C are in queue (A is blocked) + const queueState = await harness.phaseDispatchManager.getPhaseQueueState(); + expect(queueState.queued.length).toBe(2); + expect(queueState.ready.length).toBe(0); // Neither B nor C can dispatch + expect(queueState.blocked.length).toBe(1); + + // Try to dispatch any phase - should fail for all + const result = await harness.phaseDispatchManager.dispatchNextPhase(); + expect(result.success).toBe(false); + expect(result.reason).toBe('No dispatchable phases'); + }); + }); +});