テストのメモリリークとハング問題を修正

- PieceEngineとTaskWatcherのクリーンアップ処理を追加
- vitestをシングルスレッド実行に変更してテストの安定性を向上
This commit is contained in:
nrslib 2026-02-04 16:27:12 +09:00
parent c4ebbdb6a6
commit 9bc60c21b5
4 changed files with 53 additions and 20 deletions

View File

@ -49,10 +49,12 @@ import {
mockDetectMatchedRuleSequence, mockDetectMatchedRuleSequence,
createTestTmpDir, createTestTmpDir,
applyDefaultMocks, applyDefaultMocks,
cleanupPieceEngine,
} from './engine-test-helpers.js'; } from './engine-test-helpers.js';
describe('PieceEngine Integration: Happy Path', () => { describe('PieceEngine Integration: Happy Path', () => {
let tmpDir: string; let tmpDir: string;
let engine: PieceEngine | null = null;
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@ -61,6 +63,10 @@ describe('PieceEngine Integration: Happy Path', () => {
}); });
afterEach(() => { afterEach(() => {
if (engine) {
cleanupPieceEngine(engine);
engine = null;
}
if (existsSync(tmpDir)) { if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
} }
@ -72,7 +78,7 @@ describe('PieceEngine Integration: Happy Path', () => {
describe('Happy path', () => { describe('Happy path', () => {
it('should complete: plan → implement → ai_review → reviewers(all approved) → supervise → COMPLETE', async () => { it('should complete: plan → implement → ai_review → reviewers(all approved) → supervise → COMPLETE', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan complete' }), makeResponse({ agent: 'plan', content: 'Plan complete' }),
@ -111,7 +117,7 @@ describe('PieceEngine Integration: Happy Path', () => {
describe('Review reject and fix loop', () => { describe('Review reject and fix loop', () => {
it('should handle: reviewers(needs_fix) → fix → reviewers(all approved) → supervise → COMPLETE', async () => { it('should handle: reviewers(needs_fix) → fix → reviewers(all approved) → supervise → COMPLETE', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }), 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 () => { it('should inject latest reviewers output as Previous Response for repeated fix steps', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }), 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 () => { it('should use the latest movement output across different steps for Previous Response', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }), makeResponse({ agent: 'plan', content: 'Plan done' }),
@ -279,7 +285,7 @@ describe('PieceEngine Integration: Happy Path', () => {
describe('AI review reject and fix', () => { describe('AI review reject and fix', () => {
it('should handle: ai_review(issues) → ai_fix → reviewers → supervise → COMPLETE', async () => { it('should handle: ai_review(issues) → ai_fix → reviewers → supervise → COMPLETE', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }), makeResponse({ agent: 'plan', content: 'Plan done' }),
@ -316,7 +322,7 @@ describe('PieceEngine Integration: Happy Path', () => {
describe('ABORT transition', () => { describe('ABORT transition', () => {
it('should abort when movement transitions to ABORT', async () => { it('should abort when movement transitions to ABORT', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Requirements unclear' }), makeResponse({ agent: 'plan', content: 'Requirements unclear' }),
@ -343,7 +349,7 @@ describe('PieceEngine Integration: Happy Path', () => {
describe('Event emissions', () => { describe('Event emissions', () => {
it('should emit movement:start and movement:complete for each movement', async () => { it('should emit movement:start and movement:complete for each movement', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan' }), 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([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }), 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 () => { it('should pass empty instruction to movement:start for parallel movements', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan' }), makeResponse({ agent: 'plan', content: 'Plan' }),
@ -451,7 +457,7 @@ describe('PieceEngine Integration: Happy Path', () => {
it('should emit iteration:limit when max iterations reached', async () => { it('should emit iteration:limit when max iterations reached', async () => {
const config = buildDefaultPieceConfig({ maxIterations: 1 }); 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([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan' }), makeResponse({ agent: 'plan', content: 'Plan' }),
@ -475,7 +481,7 @@ describe('PieceEngine Integration: Happy Path', () => {
describe('Movement output tracking', () => { describe('Movement output tracking', () => {
it('should store outputs for all executed movements', async () => { it('should store outputs for all executed movements', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan output' }), 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([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan done' }), 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 () => { it('should emit phase events for all movements in happy path', async () => {
const config = buildDefaultPieceConfig(); const config = buildDefaultPieceConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([ mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan' }), makeResponse({ agent: 'plan', content: 'Plan' }),

View File

@ -173,3 +173,13 @@ export function applyDefaultMocks(): void {
vi.mocked(runStatusJudgmentPhase).mockResolvedValue(''); vi.mocked(runStatusJudgmentPhase).mockResolvedValue('');
vi.mocked(generateReportDir).mockReturnValue('test-report-dir'); 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();
}
}

View File

@ -10,6 +10,7 @@ import type { TaskInfo } from '../infra/task/types.js';
describe('TaskWatcher', () => { describe('TaskWatcher', () => {
const testDir = `/tmp/takt-watcher-test-${Date.now()}`; const testDir = `/tmp/takt-watcher-test-${Date.now()}`;
let watcher: TaskWatcher | null = null;
beforeEach(() => { beforeEach(() => {
mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true }); mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true });
@ -17,6 +18,11 @@ describe('TaskWatcher', () => {
}); });
afterEach(() => { afterEach(() => {
// Ensure watcher is stopped before cleanup
if (watcher) {
watcher.stop();
watcher = null;
}
if (existsSync(testDir)) { if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
} }
@ -24,19 +30,19 @@ describe('TaskWatcher', () => {
describe('constructor', () => { describe('constructor', () => {
it('should create watcher with default options', () => { it('should create watcher with default options', () => {
const watcher = new TaskWatcher(testDir); watcher = new TaskWatcher(testDir);
expect(watcher.isRunning()).toBe(false); expect(watcher.isRunning()).toBe(false);
}); });
it('should accept custom poll interval', () => { it('should accept custom poll interval', () => {
const watcher = new TaskWatcher(testDir, { pollInterval: 500 }); watcher = new TaskWatcher(testDir, { pollInterval: 500 });
expect(watcher.isRunning()).toBe(false); expect(watcher.isRunning()).toBe(false);
}); });
}); });
describe('watch', () => { describe('watch', () => {
it('should detect and process a task file', async () => { 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[] = []; const processed: string[] = [];
// Pre-create a task file // Pre-create a task file
@ -59,7 +65,7 @@ describe('TaskWatcher', () => {
}); });
it('should wait when no tasks are available', async () => { 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; let pollCount = 0;
// Start watching, add a task after a delay // Start watching, add a task after a delay
@ -81,7 +87,7 @@ describe('TaskWatcher', () => {
}); });
it('should process multiple tasks sequentially', async () => { it('should process multiple tasks sequentially', async () => {
const watcher = new TaskWatcher(testDir, { pollInterval: 50 }); watcher = new TaskWatcher(testDir, { pollInterval: 50 });
const processed: string[] = []; const processed: string[] = [];
// Pre-create two task files // Pre-create two task files
@ -111,7 +117,7 @@ describe('TaskWatcher', () => {
describe('stop', () => { describe('stop', () => {
it('should stop the watcher gracefully', async () => { 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 // Start watching, stop after a short delay
const watchPromise = watcher.watch(async () => { const watchPromise = watcher.watch(async () => {
@ -127,7 +133,7 @@ describe('TaskWatcher', () => {
}); });
it('should abort sleep immediately when stopped', async () => { 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 start = Date.now();
const watchPromise = watcher.watch(async () => {}); const watchPromise = watcher.watch(async () => {});

View File

@ -5,6 +5,17 @@ export default defineConfig({
include: ['src/__tests__/**/*.test.ts'], include: ['src/__tests__/**/*.test.ts'],
environment: 'node', environment: 'node',
globals: false, 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: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],