diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index 4687466..58efb81 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -6,6 +6,8 @@ export interface FileWatcherOptions { rootPath: string; /** ms after last change before triggering. Default: 2000 */ debounceMs?: number; + /** Called once chokidar finishes initial scan and starts emitting change events */ + onReady?: () => void; /** Called once the debounce window expires after the last detected change */ onChanged: () => void; } @@ -28,7 +30,7 @@ function isTrackedSourcePath(filePath: string): boolean { * Returns a stop() function that cancels the debounce timer and closes the watcher. */ export function startFileWatcher(opts: FileWatcherOptions): () => void { - const { rootPath, debounceMs = 2000, onChanged } = opts; + const { rootPath, debounceMs = 2000, onReady, onChanged } = opts; let debounceTimer: ReturnType | undefined; const trigger = (filePath: string) => { @@ -59,6 +61,7 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void { }); watcher + .on('ready', () => onReady?.()) .on('add', trigger) .on('change', trigger) .on('unlink', trigger) diff --git a/tests/auto-refresh-e2e.test.ts b/tests/auto-refresh-e2e.test.ts index a7e0509..6e6a7bc 100644 --- a/tests/auto-refresh-e2e.test.ts +++ b/tests/auto-refresh-e2e.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { startFileWatcher } from '../src/core/file-watcher.js'; import { createAutoRefreshController } from '../src/core/auto-refresh.js'; import { CodebaseIndexer } from '../src/core/indexer.js'; +import { rmWithRetries } from './test-helpers.js'; import { CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME @@ -72,7 +73,7 @@ describe('Auto-refresh E2E', () => { }); afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); + await rmWithRetries(tempDir); }); it('updates index after a file edit without manual refresh_index', async () => { @@ -111,9 +112,15 @@ describe('Auto-refresh E2E', () => { } }; + let resolveReady!: () => void; + const watcherReady = new Promise((resolve) => { + resolveReady = resolve; + }); + const stopWatcher = startFileWatcher({ rootPath: tempDir, debounceMs: 200, + onReady: () => resolveReady(), onChanged: () => { const shouldRunNow = autoRefresh.onFileChange(indexStatus === 'indexing'); if (!shouldRunNow) return; @@ -123,7 +130,7 @@ describe('Auto-refresh E2E', () => { }); try { - await sleep(250); + await watcherReady; await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "UPDATED_TOKEN";\n'); await waitFor( diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts index be36ba3..be3e38e 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { startFileWatcher } from '../src/core/file-watcher.js'; +import { rmWithRetries } from './test-helpers.js'; describe('FileWatcher', () => { let tempDir: string; @@ -12,22 +13,27 @@ describe('FileWatcher', () => { }); afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); + await rmWithRetries(tempDir); }); it('triggers onChanged after debounce window', async () => { const debounceMs = 400; let callCount = 0; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const stop = startFileWatcher({ rootPath: tempDir, debounceMs, + onReady: () => resolveReady(), onChanged: () => { callCount++; }, }); try { - // Give chokidar a moment to finish initializing before the first write - await new Promise((resolve) => setTimeout(resolve, 100)); + await ready; await fs.writeFile(path.join(tempDir, 'test.ts'), 'export const x = 1;'); // Wait for chokidar to pick up the event (including awaitWriteFinish stabilityThreshold) // + debounce window + OS scheduling slack @@ -39,25 +45,31 @@ describe('FileWatcher', () => { }, 8000); it('debounces rapid changes into a single callback', async () => { - const debounceMs = 300; + const debounceMs = 800; let callCount = 0; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const stop = startFileWatcher({ rootPath: tempDir, debounceMs, + onReady: () => resolveReady(), onChanged: () => { callCount++; }, }); try { // Give chokidar a moment to finish initializing before the first write - await new Promise((resolve) => setTimeout(resolve, 100)); + await ready; // Write 5 files in quick succession — all within the debounce window for (let i = 0; i < 5; i++) { await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 20)); } // Wait for debounce to settle - await new Promise((resolve) => setTimeout(resolve, debounceMs + 400)); + await new Promise((resolve) => setTimeout(resolve, debounceMs + 1200)); expect(callCount).toBe(1); } finally { stop(); @@ -68,14 +80,20 @@ describe('FileWatcher', () => { const debounceMs = 500; let callCount = 0; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const stop = startFileWatcher({ rootPath: tempDir, debounceMs, + onReady: () => resolveReady(), onChanged: () => { callCount++; }, }); // Give chokidar a moment to finish initializing before the first write - await new Promise((resolve) => setTimeout(resolve, 100)); + await ready; await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;'); // Let chokidar detect the event (including awaitWriteFinish stabilityThreshold) // but stop before the debounce window expires. @@ -90,16 +108,22 @@ describe('FileWatcher', () => { const debounceMs = 250; let callCount = 0; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const stop = startFileWatcher({ rootPath: tempDir, debounceMs, + onReady: () => resolveReady(), onChanged: () => { callCount++; } }); try { - await new Promise((resolve) => setTimeout(resolve, 100)); + await ready; await fs.writeFile(path.join(tempDir, 'notes.txt'), 'this should be ignored'); await new Promise((resolve) => setTimeout(resolve, debounceMs + 700)); expect(callCount).toBe(0); @@ -112,16 +136,22 @@ describe('FileWatcher', () => { const debounceMs = 250; let callCount = 0; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const stop = startFileWatcher({ rootPath: tempDir, debounceMs, + onReady: () => resolveReady(), onChanged: () => { callCount++; } }); try { - await new Promise((resolve) => setTimeout(resolve, 100)); + await ready; await fs.writeFile(path.join(tempDir, '.gitignore'), 'dist/\n'); await new Promise((resolve) => setTimeout(resolve, debounceMs + 700)); expect(callCount).toBe(1); diff --git a/tests/impact-2hop.test.ts b/tests/impact-2hop.test.ts index 40e22d9..f284b64 100644 --- a/tests/impact-2hop.test.ts +++ b/tests/impact-2hop.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; +import os from 'os'; import path from 'path'; import { CodebaseIndexer } from '../src/core/indexer.js'; import { dispatchTool } from '../src/tools/index.js'; import type { ToolContext } from '../src/tools/types.js'; +import { rmWithRetries } from './test-helpers.js'; import { CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME, @@ -18,8 +20,7 @@ describe('Impact candidates (2-hop)', () => { const token = 'UNIQUETOKEN123'; beforeEach(async () => { - // Keep test artifacts under CWD (mirrors other indexer tests and avoids OS tmp quirks) - tempRoot = await fs.mkdtemp(path.join(process.cwd(), '.tmp-impact-2hop-')); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'impact-2hop-')); const srcDir = path.join(tempRoot, 'src'); await fs.mkdir(srcDir, { recursive: true }); await fs.writeFile(path.join(tempRoot, 'package.json'), JSON.stringify({ name: 'impact-2hop' })); @@ -40,7 +41,7 @@ describe('Impact candidates (2-hop)', () => { afterEach(async () => { if (tempRoot) { - await fs.rm(tempRoot, { recursive: true, force: true }); + await rmWithRetries(tempRoot); tempRoot = null; } }); diff --git a/tests/search-snippets.test.ts b/tests/search-snippets.test.ts index afa8974..5f90ba0 100644 --- a/tests/search-snippets.test.ts +++ b/tests/search-snippets.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { CodebaseIndexer } from '../src/core/indexer.js'; +import { rmWithRetries } from './test-helpers.js'; describe('Search Snippets with Scope Headers', () => { let tempRoot: string | null = null; @@ -91,15 +92,15 @@ export const VERSION = '1.0.0'; config: { skipEmbedding: true } }); await indexer.index(); - }); + }, 30000); afterEach(async () => { if (tempRoot) { - await fs.rm(tempRoot, { recursive: true, force: true }); + await rmWithRetries(tempRoot); tempRoot = null; } delete process.env.CODEBASE_ROOT; - }); + }, 30000); it('returns snippets when includeSnippets=true', async () => { if (!tempRoot) throw new Error('tempRoot not initialized'); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts new file mode 100644 index 0000000..4993a73 --- /dev/null +++ b/tests/test-helpers.ts @@ -0,0 +1,19 @@ +import { promises as fs } from 'fs'; + +export async function rmWithRetries(targetPath: string): Promise { + const maxAttempts = 8; + let delayMs = 25; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as { code?: string }).code; + const retryable = code === 'ENOTEMPTY' || code === 'EPERM' || code === 'EBUSY'; + if (!retryable || attempt === maxAttempts) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs *= 2; + } + } +}