Skip to content

Commit 87e7434

Browse files
committed
fix(watcher-tests): await ready + harden Windows cleanup
1 parent dd0cca8 commit 87e7434

File tree

5 files changed

+91
-17
lines changed

5 files changed

+91
-17
lines changed

src/core/file-watcher.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface FileWatcherOptions {
66
rootPath: string;
77
/** ms after last change before triggering. Default: 2000 */
88
debounceMs?: number;
9+
/** Called once chokidar finishes initial scan and starts emitting change events */
10+
onReady?: () => void;
911
/** Called once the debounce window expires after the last detected change */
1012
onChanged: () => void;
1113
}
@@ -28,7 +30,7 @@ function isTrackedSourcePath(filePath: string): boolean {
2830
* Returns a stop() function that cancels the debounce timer and closes the watcher.
2931
*/
3032
export function startFileWatcher(opts: FileWatcherOptions): () => void {
31-
const { rootPath, debounceMs = 2000, onChanged } = opts;
33+
const { rootPath, debounceMs = 2000, onReady, onChanged } = opts;
3234
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
3335

3436
const trigger = (filePath: string) => {
@@ -59,6 +61,7 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void {
5961
});
6062

6163
watcher
64+
.on('ready', () => onReady?.())
6265
.on('add', trigger)
6366
.on('change', trigger)
6467
.on('unlink', trigger)

tests/auto-refresh-e2e.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,15 @@ describe('Auto-refresh E2E', () => {
111111
}
112112
};
113113

114+
let resolveReady!: () => void;
115+
const watcherReady = new Promise<void>((resolve) => {
116+
resolveReady = resolve;
117+
});
118+
114119
const stopWatcher = startFileWatcher({
115120
rootPath: tempDir,
116121
debounceMs: 200,
122+
onReady: () => resolveReady(),
117123
onChanged: () => {
118124
const shouldRunNow = autoRefresh.onFileChange(indexStatus === 'indexing');
119125
if (!shouldRunNow) return;
@@ -123,7 +129,7 @@ describe('Auto-refresh E2E', () => {
123129
});
124130

125131
try {
126-
await sleep(250);
132+
await watcherReady;
127133
await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "UPDATED_TOKEN";\n');
128134

129135
await waitFor(

tests/file-watcher.test.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,20 @@ describe('FileWatcher', () => {
1919
const debounceMs = 400;
2020
let callCount = 0;
2121

22+
let resolveReady!: () => void;
23+
const ready = new Promise<void>((resolve) => {
24+
resolveReady = resolve;
25+
});
26+
2227
const stop = startFileWatcher({
2328
rootPath: tempDir,
2429
debounceMs,
30+
onReady: () => resolveReady(),
2531
onChanged: () => { callCount++; },
2632
});
2733

2834
try {
29-
// Give chokidar a moment to finish initializing before the first write
30-
await new Promise((resolve) => setTimeout(resolve, 100));
35+
await ready;
3136
await fs.writeFile(path.join(tempDir, 'test.ts'), 'export const x = 1;');
3237
// Wait for chokidar to pick up the event (including awaitWriteFinish stabilityThreshold)
3338
// + debounce window + OS scheduling slack
@@ -39,25 +44,31 @@ describe('FileWatcher', () => {
3944
}, 8000);
4045

4146
it('debounces rapid changes into a single callback', async () => {
42-
const debounceMs = 300;
47+
const debounceMs = 800;
4348
let callCount = 0;
4449

50+
let resolveReady!: () => void;
51+
const ready = new Promise<void>((resolve) => {
52+
resolveReady = resolve;
53+
});
54+
4555
const stop = startFileWatcher({
4656
rootPath: tempDir,
4757
debounceMs,
58+
onReady: () => resolveReady(),
4859
onChanged: () => { callCount++; },
4960
});
5061

5162
try {
5263
// Give chokidar a moment to finish initializing before the first write
53-
await new Promise((resolve) => setTimeout(resolve, 100));
64+
await ready;
5465
// Write 5 files in quick succession — all within the debounce window
5566
for (let i = 0; i < 5; i++) {
5667
await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`);
57-
await new Promise((resolve) => setTimeout(resolve, 50));
68+
await new Promise((resolve) => setTimeout(resolve, 20));
5869
}
5970
// Wait for debounce to settle
60-
await new Promise((resolve) => setTimeout(resolve, debounceMs + 400));
71+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 1200));
6172
expect(callCount).toBe(1);
6273
} finally {
6374
stop();
@@ -68,14 +79,20 @@ describe('FileWatcher', () => {
6879
const debounceMs = 500;
6980
let callCount = 0;
7081

82+
let resolveReady!: () => void;
83+
const ready = new Promise<void>((resolve) => {
84+
resolveReady = resolve;
85+
});
86+
7187
const stop = startFileWatcher({
7288
rootPath: tempDir,
7389
debounceMs,
90+
onReady: () => resolveReady(),
7491
onChanged: () => { callCount++; },
7592
});
7693

7794
// Give chokidar a moment to finish initializing before the first write
78-
await new Promise((resolve) => setTimeout(resolve, 100));
95+
await ready;
7996
await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;');
8097
// Let chokidar detect the event (including awaitWriteFinish stabilityThreshold)
8198
// but stop before the debounce window expires.
@@ -90,16 +107,22 @@ describe('FileWatcher', () => {
90107
const debounceMs = 250;
91108
let callCount = 0;
92109

110+
let resolveReady!: () => void;
111+
const ready = new Promise<void>((resolve) => {
112+
resolveReady = resolve;
113+
});
114+
93115
const stop = startFileWatcher({
94116
rootPath: tempDir,
95117
debounceMs,
118+
onReady: () => resolveReady(),
96119
onChanged: () => {
97120
callCount++;
98121
}
99122
});
100123

101124
try {
102-
await new Promise((resolve) => setTimeout(resolve, 100));
125+
await ready;
103126
await fs.writeFile(path.join(tempDir, 'notes.txt'), 'this should be ignored');
104127
await new Promise((resolve) => setTimeout(resolve, debounceMs + 700));
105128
expect(callCount).toBe(0);
@@ -112,16 +135,22 @@ describe('FileWatcher', () => {
112135
const debounceMs = 250;
113136
let callCount = 0;
114137

138+
let resolveReady!: () => void;
139+
const ready = new Promise<void>((resolve) => {
140+
resolveReady = resolve;
141+
});
142+
115143
const stop = startFileWatcher({
116144
rootPath: tempDir,
117145
debounceMs,
146+
onReady: () => resolveReady(),
118147
onChanged: () => {
119148
callCount++;
120149
}
121150
});
122151

123152
try {
124-
await new Promise((resolve) => setTimeout(resolve, 100));
153+
await ready;
125154
await fs.writeFile(path.join(tempDir, '.gitignore'), 'dist/\n');
126155
await new Promise((resolve) => setTimeout(resolve, debounceMs + 700));
127156
expect(callCount).toBe(1);

tests/impact-2hop.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import { promises as fs } from 'fs';
3+
import os from 'os';
34
import path from 'path';
45
import { CodebaseIndexer } from '../src/core/indexer.js';
56
import { dispatchTool } from '../src/tools/index.js';
@@ -17,9 +18,26 @@ describe('Impact candidates (2-hop)', () => {
1718
let tempRoot: string | null = null;
1819
const token = 'UNIQUETOKEN123';
1920

21+
async function rmWithRetries(targetPath: string): Promise<void> {
22+
const maxAttempts = 8;
23+
let delayMs = 25;
24+
25+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
26+
try {
27+
await fs.rm(targetPath, { recursive: true, force: true });
28+
return;
29+
} catch (error) {
30+
const code = (error as { code?: string }).code;
31+
const retryable = code === 'ENOTEMPTY' || code === 'EPERM' || code === 'EBUSY';
32+
if (!retryable || attempt === maxAttempts) throw error;
33+
await new Promise((r) => setTimeout(r, delayMs));
34+
delayMs *= 2;
35+
}
36+
}
37+
}
38+
2039
beforeEach(async () => {
21-
// Keep test artifacts under CWD (mirrors other indexer tests and avoids OS tmp quirks)
22-
tempRoot = await fs.mkdtemp(path.join(process.cwd(), '.tmp-impact-2hop-'));
40+
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'impact-2hop-'));
2341
const srcDir = path.join(tempRoot, 'src');
2442
await fs.mkdir(srcDir, { recursive: true });
2543
await fs.writeFile(path.join(tempRoot, 'package.json'), JSON.stringify({ name: 'impact-2hop' }));
@@ -40,7 +58,7 @@ describe('Impact candidates (2-hop)', () => {
4058

4159
afterEach(async () => {
4260
if (tempRoot) {
43-
await fs.rm(tempRoot, { recursive: true, force: true });
61+
await rmWithRetries(tempRoot);
4462
tempRoot = null;
4563
}
4664
});

tests/search-snippets.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ import { CodebaseIndexer } from '../src/core/indexer.js';
77
describe('Search Snippets with Scope Headers', () => {
88
let tempRoot: string | null = null;
99

10+
async function rmWithRetries(targetPath: string): Promise<void> {
11+
const maxAttempts = 8;
12+
let delayMs = 25;
13+
14+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
15+
try {
16+
await fs.rm(targetPath, { recursive: true, force: true });
17+
return;
18+
} catch (error) {
19+
const code = (error as { code?: string }).code;
20+
const retryable = code === 'ENOTEMPTY' || code === 'EPERM' || code === 'EBUSY';
21+
if (!retryable || attempt === maxAttempts) throw error;
22+
await new Promise((r) => setTimeout(r, delayMs));
23+
delayMs *= 2;
24+
}
25+
}
26+
}
27+
1028
beforeEach(async () => {
1129
vi.resetModules();
1230
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-snippets-test-'));
@@ -91,15 +109,15 @@ export const VERSION = '1.0.0';
91109
config: { skipEmbedding: true }
92110
});
93111
await indexer.index();
94-
});
112+
}, 30000);
95113

96114
afterEach(async () => {
97115
if (tempRoot) {
98-
await fs.rm(tempRoot, { recursive: true, force: true });
116+
await rmWithRetries(tempRoot);
99117
tempRoot = null;
100118
}
101119
delete process.env.CODEBASE_ROOT;
102-
});
120+
}, 30000);
103121

104122
it('returns snippets when includeSnippets=true', async () => {
105123
if (!tempRoot) throw new Error('tempRoot not initialized');

0 commit comments

Comments
 (0)