diff --git a/src/__tests__/engine-happy-path.test.ts b/src/__tests__/engine-happy-path.test.ts index 6290d5f..8fa0a8f 100644 --- a/src/__tests__/engine-happy-path.test.ts +++ b/src/__tests__/engine-happy-path.test.ts @@ -49,10 +49,12 @@ import { mockDetectMatchedRuleSequence, createTestTmpDir, applyDefaultMocks, + cleanupPieceEngine, } from './engine-test-helpers.js'; describe('PieceEngine Integration: Happy Path', () => { let tmpDir: string; + let engine: PieceEngine | null = null; beforeEach(() => { vi.resetAllMocks(); @@ -61,6 +63,10 @@ describe('PieceEngine Integration: Happy Path', () => { }); afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = null; + } if (existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } @@ -72,7 +78,7 @@ describe('PieceEngine Integration: Happy Path', () => { describe('Happy path', () => { it('should complete: plan → implement → ai_review → reviewers(all approved) → supervise → COMPLETE', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan complete' }), @@ -111,7 +117,7 @@ describe('PieceEngine Integration: Happy Path', () => { describe('Review reject and fix loop', () => { it('should handle: reviewers(needs_fix) → fix → reviewers(all approved) → supervise → COMPLETE', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan done' }), @@ -152,7 +158,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should inject latest reviewers output as Previous Response for repeated fix steps', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan done' }), @@ -221,7 +227,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should use the latest movement output across different steps for Previous Response', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan done' }), @@ -279,7 +285,7 @@ describe('PieceEngine Integration: Happy Path', () => { describe('AI review reject and fix', () => { it('should handle: ai_review(issues) → ai_fix → reviewers → supervise → COMPLETE', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan done' }), @@ -316,7 +322,7 @@ describe('PieceEngine Integration: Happy Path', () => { describe('ABORT transition', () => { it('should abort when movement transitions to ABORT', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Requirements unclear' }), @@ -343,7 +349,7 @@ describe('PieceEngine Integration: Happy Path', () => { describe('Event emissions', () => { it('should emit movement:start and movement:complete for each movement', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan' }), @@ -390,7 +396,7 @@ describe('PieceEngine Integration: Happy Path', () => { }), ], }; - const engine = new PieceEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan done' }), @@ -413,7 +419,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should pass empty instruction to movement:start for parallel movements', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan' }), @@ -451,7 +457,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should emit iteration:limit when max iterations reached', async () => { const config = buildDefaultPieceConfig({ maxIterations: 1 }); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan' }), @@ -475,7 +481,7 @@ describe('PieceEngine Integration: Happy Path', () => { describe('Movement output tracking', () => { it('should store outputs for all executed movements', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan output' }), @@ -520,7 +526,7 @@ describe('PieceEngine Integration: Happy Path', () => { }), ], }; - const engine = new PieceEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(simpleConfig, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan done' }), @@ -548,7 +554,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should emit phase events for all movements in happy path', async () => { const config = buildDefaultPieceConfig(); - const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ makeResponse({ agent: 'plan', content: 'Plan' }), diff --git a/src/__tests__/engine-test-helpers.ts b/src/__tests__/engine-test-helpers.ts index 5e3b7c5..4abd871 100644 --- a/src/__tests__/engine-test-helpers.ts +++ b/src/__tests__/engine-test-helpers.ts @@ -173,3 +173,13 @@ export function applyDefaultMocks(): void { vi.mocked(runStatusJudgmentPhase).mockResolvedValue(''); vi.mocked(generateReportDir).mockReturnValue('test-report-dir'); } + +/** + * Clean up PieceEngine instances to prevent EventEmitter memory leaks. + * Call this in afterEach to ensure all event listeners are removed. + */ +export function cleanupPieceEngine(engine: any): void { + if (engine && typeof engine.removeAllListeners === 'function') { + engine.removeAllListeners(); + } +} diff --git a/src/__tests__/watcher.test.ts b/src/__tests__/watcher.test.ts index 4b48e19..601e33c 100644 --- a/src/__tests__/watcher.test.ts +++ b/src/__tests__/watcher.test.ts @@ -10,6 +10,7 @@ import type { TaskInfo } from '../infra/task/types.js'; describe('TaskWatcher', () => { const testDir = `/tmp/takt-watcher-test-${Date.now()}`; + let watcher: TaskWatcher | null = null; beforeEach(() => { mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true }); @@ -17,6 +18,11 @@ describe('TaskWatcher', () => { }); afterEach(() => { + // Ensure watcher is stopped before cleanup + if (watcher) { + watcher.stop(); + watcher = null; + } if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } @@ -24,19 +30,19 @@ describe('TaskWatcher', () => { describe('constructor', () => { it('should create watcher with default options', () => { - const watcher = new TaskWatcher(testDir); + watcher = new TaskWatcher(testDir); expect(watcher.isRunning()).toBe(false); }); it('should accept custom poll interval', () => { - const watcher = new TaskWatcher(testDir, { pollInterval: 500 }); + watcher = new TaskWatcher(testDir, { pollInterval: 500 }); expect(watcher.isRunning()).toBe(false); }); }); describe('watch', () => { it('should detect and process a task file', async () => { - const watcher = new TaskWatcher(testDir, { pollInterval: 50 }); + watcher = new TaskWatcher(testDir, { pollInterval: 50 }); const processed: string[] = []; // Pre-create a task file @@ -59,7 +65,7 @@ describe('TaskWatcher', () => { }); it('should wait when no tasks are available', async () => { - const watcher = new TaskWatcher(testDir, { pollInterval: 50 }); + watcher = new TaskWatcher(testDir, { pollInterval: 50 }); let pollCount = 0; // Start watching, add a task after a delay @@ -81,7 +87,7 @@ describe('TaskWatcher', () => { }); it('should process multiple tasks sequentially', async () => { - const watcher = new TaskWatcher(testDir, { pollInterval: 50 }); + watcher = new TaskWatcher(testDir, { pollInterval: 50 }); const processed: string[] = []; // Pre-create two task files @@ -111,7 +117,7 @@ describe('TaskWatcher', () => { describe('stop', () => { it('should stop the watcher gracefully', async () => { - const watcher = new TaskWatcher(testDir, { pollInterval: 50 }); + watcher = new TaskWatcher(testDir, { pollInterval: 50 }); // Start watching, stop after a short delay const watchPromise = watcher.watch(async () => { @@ -127,7 +133,7 @@ describe('TaskWatcher', () => { }); it('should abort sleep immediately when stopped', async () => { - const watcher = new TaskWatcher(testDir, { pollInterval: 10000 }); + watcher = new TaskWatcher(testDir, { pollInterval: 10000 }); const start = Date.now(); const watchPromise = watcher.watch(async () => {}); diff --git a/vitest.config.ts b/vitest.config.ts index a05b87a..c100b89 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,17 @@ export default defineConfig({ include: ['src/__tests__/**/*.test.ts'], environment: 'node', globals: false, + // Ensure proper cleanup by forcing sequential execution and graceful shutdown + pool: 'threads', + poolOptions: { + threads: { + singleThread: true, + }, + }, + // Increase timeout for tests with async cleanup + testTimeout: 15000, + // Force exit after tests complete to prevent hanging + teardownTimeout: 5000, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],