/** * Unit tests for runWithWorkerPool */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; vi.mock('../shared/ui/index.js', () => ({ header: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), status: vi.fn(), blankLine: vi.fn(), })); vi.mock('../shared/exitCodes.js', () => ({ EXIT_SIGINT: 130, })); vi.mock('../shared/i18n/index.js', () => ({ getLabel: vi.fn((key: string) => key), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ info: vi.fn(), debug: vi.fn(), error: vi.fn(), }), })); const mockExecuteAndCompleteTask = vi.fn(); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args), })); import { runWithWorkerPool } from '../features/tasks/execute/parallelExecution.js'; import { info } from '../shared/ui/index.js'; const mockInfo = vi.mocked(info); const TEST_POLL_INTERVAL_MS = 50; function createTask(name: string): TaskInfo { return { name, content: `Task: ${name}`, filePath: `/tasks/${name}.yaml`, }; } function createMockTaskRunner(taskBatches: TaskInfo[][]) { let batchIndex = 0; return { getNextTask: vi.fn(() => null), claimNextTasks: vi.fn(() => { const batch = taskBatches[batchIndex] ?? []; batchIndex++; return batch; }), completeTask: vi.fn(), failTask: vi.fn(), }; } beforeEach(() => { vi.clearAllMocks(); mockExecuteAndCompleteTask.mockResolvedValue(true); }); describe('runWithWorkerPool', () => { it('should return correct counts for all successful tasks', async () => { // Given const tasks = [createTask('a'), createTask('b')]; const runner = createMockTaskRunner([]); // When const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 2, fail: 0 }); }); it('should return correct counts when some tasks fail', async () => { // Given const tasks = [createTask('pass'), createTask('fail'), createTask('pass2')]; let callIdx = 0; mockExecuteAndCompleteTask.mockImplementation(() => { callIdx++; return Promise.resolve(callIdx !== 2); }); const runner = createMockTaskRunner([]); // When const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 2, fail: 1 }); }); it('should display task name for each task via prefix writer in parallel mode', async () => { // Given const tasks = [createTask('alpha'), createTask('beta')]; const runner = createMockTaskRunner([]); const stdoutChunks: string[] = []; const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { stdoutChunks.push(String(chunk)); return true; }); // When await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: Task names appear in prefixed stdout output writeSpy.mockRestore(); const allOutput = stdoutChunks.join(''); expect(allOutput).toContain('[alph]'); expect(allOutput).toContain('=== Task: alpha ==='); expect(allOutput).toContain('[beta]'); expect(allOutput).toContain('=== Task: beta ==='); }); it('should pass taskPrefix for parallel execution (concurrency > 1)', async () => { // Given const tasks = [createTask('my-task')]; const runner = createMockTaskRunner([]); // When await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; expect(parallelOpts).toEqual({ abortSignal: expect.any(AbortSignal), taskPrefix: 'my-task', taskColorIndex: 0, }); }); it('should pass abortSignal but not taskPrefix for sequential execution (concurrency = 1)', async () => { // Given const tasks = [createTask('seq-task')]; const runner = createMockTaskRunner([]); // When await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; expect(parallelOpts).toEqual({ abortSignal: expect.any(AbortSignal), taskPrefix: undefined, taskColorIndex: undefined, }); }); it('should fetch more tasks when slots become available', async () => { // Given: 1 initial task, runner provides 1 more after const task1 = createTask('first'); const task2 = createTask('second'); const runner = createMockTaskRunner([[task2]]); // When await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2); expect(runner.claimNextTasks).toHaveBeenCalled(); }); it('should respect concurrency limit', async () => { // Given: 4 tasks, concurrency=2 const tasks = Array.from({ length: 4 }, (_, i) => createTask(`task-${i}`)); let activeCount = 0; let maxActive = 0; mockExecuteAndCompleteTask.mockImplementation(() => { activeCount++; maxActive = Math.max(maxActive, activeCount); return new Promise((resolve) => { setTimeout(() => { activeCount--; resolve(true); }, 20); }); }); const runner = createMockTaskRunner([]); // When await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: Never exceeded concurrency of 2 expect(maxActive).toBeLessThanOrEqual(2); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(4); }); it('should pass abortSignal to all parallel tasks', async () => { // Given: Multiple tasks in parallel mode const tasks = [createTask('task-1'), createTask('task-2'), createTask('task-3')]; const runner = createMockTaskRunner([]); const receivedSignals: (AbortSignal | undefined)[] = []; mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { receivedSignals.push(parallelOpts?.abortSignal); return Promise.resolve(true); }); // When await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: All tasks received the same AbortSignal expect(receivedSignals).toHaveLength(3); const firstSignal = receivedSignals[0]; expect(firstSignal).toBeInstanceOf(AbortSignal); for (const signal of receivedSignals) { expect(signal).toBe(firstSignal); } }); it('should handle empty initial tasks', async () => { // Given: No tasks const runner = createMockTaskRunner([]); // When const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 0, fail: 0 }); expect(mockExecuteAndCompleteTask).not.toHaveBeenCalled(); }); it('should handle task promise rejection gracefully', async () => { // Given: Task that throws const tasks = [createTask('throws')]; mockExecuteAndCompleteTask.mockRejectedValue(new Error('boom')); const runner = createMockTaskRunner([]); // When const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: Treated as failure expect(result).toEqual({ success: 0, fail: 1 }); }); it('should wait for in-flight tasks to settle after SIGINT before returning', async () => { // Given: Two running tasks that resolve after abort is triggered. const tasks = [createTask('t1'), createTask('t2')]; const runner = createMockTaskRunner([]); const deferred: Array<() => void> = []; const startedSignals: AbortSignal[] = []; mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { const signal = parallelOpts?.abortSignal; if (signal) startedSignals.push(signal); return new Promise((resolve) => { if (signal) { signal.addEventListener('abort', () => deferred.push(() => resolve(false)), { once: true }); } else { deferred.push(() => resolve(true)); } }); }); const resultPromise = runWithWorkerPool( runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS, ); await new Promise((resolve) => setTimeout(resolve, 10)); const sigintListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; const handler = sigintListeners[sigintListeners.length - 1]; expect(handler).toBeDefined(); handler!(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(startedSignals).toHaveLength(2); for (const signal of startedSignals) { expect(signal.aborted).toBe(true); } for (const resolveTask of deferred) { resolveTask(); } // Then: pool returns after in-flight tasks settle, counting them as failures. const result = await resultPromise; expect(result).toEqual({ success: 0, fail: 2 }); }); describe('polling', () => { it('should pick up tasks added during execution via polling', async () => { // Given: 1 initial task running with concurrency=2, a second task appears via poll const task1 = createTask('initial'); const task2 = createTask('added-later'); const executionOrder: string[] = []; mockExecuteAndCompleteTask.mockImplementation((task: TaskInfo) => { executionOrder.push(`start:${task.name}`); return new Promise((resolve) => { setTimeout(() => { executionOrder.push(`end:${task.name}`); resolve(true); }, 80); }); }); let claimCallCount = 0; const runner = { getNextTask: vi.fn(() => null), claimNextTasks: vi.fn(() => { claimCallCount++; // Return the new task on the second call (triggered by polling) if (claimCallCount === 2) return [task2]; return []; }), completeTask: vi.fn(), failTask: vi.fn(), }; // When: pollIntervalMs=30 so polling fires before task1 completes (80ms) const result = await runWithWorkerPool( runner as never, [task1], 2, '/cwd', 'default', undefined, 30, ); // Then: Both tasks were executed expect(result).toEqual({ success: 2, fail: 0 }); expect(executionOrder).toContain('start:initial'); expect(executionOrder).toContain('start:added-later'); // task2 started before task1 ended (picked up by polling, not by task completion) const task2Start = executionOrder.indexOf('start:added-later'); const task1End = executionOrder.indexOf('end:initial'); expect(task2Start).toBeLessThan(task1End); }); it('should work correctly with concurrency=1 (sequential behavior preserved)', async () => { // Given: concurrency=1, tasks claimed sequentially const task1 = createTask('seq-1'); const task2 = createTask('seq-2'); const executionOrder: string[] = []; mockExecuteAndCompleteTask.mockImplementation((task: TaskInfo) => { executionOrder.push(`start:${task.name}`); return new Promise((resolve) => { setTimeout(() => { executionOrder.push(`end:${task.name}`); resolve(true); }, 20); }); }); const runner = createMockTaskRunner([[task2]]); // When const result = await runWithWorkerPool( runner as never, [task1], 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS, ); // Then: Tasks executed sequentially — task2 starts after task1 ends expect(result).toEqual({ success: 2, fail: 0 }); const task2Start = executionOrder.indexOf('start:seq-2'); const task1End = executionOrder.indexOf('end:seq-1'); expect(task2Start).toBeGreaterThan(task1End); }); it('should not leak poll timer when task completes before poll fires', async () => { // Given: A task that completes in 200ms, poll interval is 5000ms const task1 = createTask('fast-task'); mockExecuteAndCompleteTask.mockImplementation(() => { return new Promise((resolve) => { setTimeout(() => resolve(true), 200); }); }); const runner = createMockTaskRunner([]); // When: Task completes before poll timer fires; cancel() cleans up timer const result = await runWithWorkerPool( runner as never, [task1], 1, '/cwd', 'default', undefined, 5000, ); // Then: Result is returned without hanging (timer was cleaned up by cancel()) expect(result).toEqual({ success: 1, fail: 0 }); }); }); });