Skip to content

Commit dd0cca8

Browse files
authored
test: extend file watcher indexing tests (#54)
* test: complete phase 2d watcher auto-refresh validation * fix: address PR feedback for watcher edge cases
1 parent 86a1728 commit dd0cca8

File tree

3 files changed

+206
-1
lines changed

3 files changed

+206
-1
lines changed

src/core/file-watcher.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import chokidar from 'chokidar';
2+
import path from 'path';
3+
import { getSupportedExtensions } from '../utils/language-detection.js';
24

35
export interface FileWatcherOptions {
46
rootPath: string;
@@ -8,6 +10,19 @@ export interface FileWatcherOptions {
810
onChanged: () => void;
911
}
1012

13+
const TRACKED_EXTENSIONS = new Set(
14+
getSupportedExtensions().map((extension) => extension.toLowerCase())
15+
);
16+
17+
const TRACKED_METADATA_FILES = new Set(['.gitignore']);
18+
19+
function isTrackedSourcePath(filePath: string): boolean {
20+
const basename = path.basename(filePath).toLowerCase();
21+
if (TRACKED_METADATA_FILES.has(basename)) return true;
22+
const extension = path.extname(filePath).toLowerCase();
23+
return extension.length > 0 && TRACKED_EXTENSIONS.has(extension);
24+
}
25+
1126
/**
1227
* Watch rootPath for source file changes and call onChanged (debounced).
1328
* Returns a stop() function that cancels the debounce timer and closes the watcher.
@@ -16,7 +31,8 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void {
1631
const { rootPath, debounceMs = 2000, onChanged } = opts;
1732
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
1833

19-
const trigger = () => {
34+
const trigger = (filePath: string) => {
35+
if (!isTrackedSourcePath(filePath)) return;
2036
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
2137
debounceTimer = setTimeout(() => {
2238
debounceTimer = undefined;

tests/auto-refresh-e2e.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { promises as fs } from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { startFileWatcher } from '../src/core/file-watcher.js';
6+
import { createAutoRefreshController } from '../src/core/auto-refresh.js';
7+
import { CodebaseIndexer } from '../src/core/indexer.js';
8+
import {
9+
CODEBASE_CONTEXT_DIRNAME,
10+
KEYWORD_INDEX_FILENAME
11+
} from '../src/constants/codebase-context.js';
12+
13+
type IndexStatus = 'idle' | 'indexing' | 'ready' | 'error';
14+
15+
function sleep(ms: number): Promise<void> {
16+
return new Promise((resolve) => setTimeout(resolve, ms));
17+
}
18+
19+
function isRecord(value: unknown): value is Record<string, unknown> {
20+
return typeof value === 'object' && value !== null;
21+
}
22+
23+
function getKeywordChunks(raw: unknown): Array<Record<string, unknown>> {
24+
if (Array.isArray(raw)) {
25+
return raw.filter(isRecord);
26+
}
27+
if (!isRecord(raw)) return [];
28+
if (!Array.isArray(raw.chunks)) return [];
29+
return raw.chunks.filter(isRecord);
30+
}
31+
32+
async function readIndexedContent(rootPath: string): Promise<string> {
33+
const indexPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME);
34+
const raw = JSON.parse(await fs.readFile(indexPath, 'utf-8')) as unknown;
35+
const chunks = getKeywordChunks(raw);
36+
return chunks
37+
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
38+
.join('\n');
39+
}
40+
41+
async function waitFor(
42+
condition: () => Promise<boolean>,
43+
timeoutMs: number,
44+
intervalMs: number
45+
): Promise<void> {
46+
const startedAt = Date.now();
47+
let lastError: unknown;
48+
while (Date.now() - startedAt < timeoutMs) {
49+
try {
50+
if (await condition()) return;
51+
lastError = undefined;
52+
} catch (error) {
53+
lastError = error;
54+
}
55+
await sleep(intervalMs);
56+
}
57+
const reason =
58+
lastError instanceof Error && lastError.message
59+
? ` Last transient error: ${lastError.message}`
60+
: '';
61+
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${reason}`);
62+
}
63+
64+
describe('Auto-refresh E2E', () => {
65+
let tempDir: string;
66+
67+
beforeEach(async () => {
68+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-refresh-e2e-'));
69+
await fs.mkdir(path.join(tempDir, 'src'), { recursive: true });
70+
await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'watch-test' }));
71+
await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "INITIAL_TOKEN";\n');
72+
});
73+
74+
afterEach(async () => {
75+
await fs.rm(tempDir, { recursive: true, force: true });
76+
});
77+
78+
it('updates index after a file edit without manual refresh_index', async () => {
79+
await new CodebaseIndexer({
80+
rootPath: tempDir,
81+
config: { skipEmbedding: true }
82+
}).index();
83+
84+
const initialContent = await readIndexedContent(tempDir);
85+
expect(initialContent).toContain('INITIAL_TOKEN');
86+
expect(initialContent).not.toContain('UPDATED_TOKEN');
87+
88+
const autoRefresh = createAutoRefreshController();
89+
let indexStatus: IndexStatus = 'ready';
90+
let incrementalRuns = 0;
91+
92+
const runIncrementalIndex = async (): Promise<void> => {
93+
if (indexStatus === 'indexing') return;
94+
indexStatus = 'indexing';
95+
96+
try {
97+
await new CodebaseIndexer({
98+
rootPath: tempDir,
99+
config: { skipEmbedding: true },
100+
incrementalOnly: true
101+
}).index();
102+
indexStatus = 'ready';
103+
} catch (error) {
104+
indexStatus = 'error';
105+
throw error;
106+
}
107+
108+
if (autoRefresh.consumeQueuedRefresh(indexStatus)) {
109+
incrementalRuns++;
110+
void runIncrementalIndex();
111+
}
112+
};
113+
114+
const stopWatcher = startFileWatcher({
115+
rootPath: tempDir,
116+
debounceMs: 200,
117+
onChanged: () => {
118+
const shouldRunNow = autoRefresh.onFileChange(indexStatus === 'indexing');
119+
if (!shouldRunNow) return;
120+
incrementalRuns++;
121+
void runIncrementalIndex();
122+
}
123+
});
124+
125+
try {
126+
await sleep(250);
127+
await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "UPDATED_TOKEN";\n');
128+
129+
await waitFor(
130+
async () => {
131+
const content = await readIndexedContent(tempDir);
132+
return content.includes('UPDATED_TOKEN');
133+
},
134+
15000,
135+
200
136+
);
137+
138+
const updatedContent = await readIndexedContent(tempDir);
139+
expect(updatedContent).toContain('UPDATED_TOKEN');
140+
expect(incrementalRuns).toBeGreaterThan(0);
141+
} finally {
142+
stopWatcher();
143+
}
144+
}, 20000);
145+
});

tests/file-watcher.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,48 @@ describe('FileWatcher', () => {
8585
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
8686
expect(callCount).toBe(0);
8787
}, 5000);
88+
89+
it('ignores changes to non-tracked file extensions', async () => {
90+
const debounceMs = 250;
91+
let callCount = 0;
92+
93+
const stop = startFileWatcher({
94+
rootPath: tempDir,
95+
debounceMs,
96+
onChanged: () => {
97+
callCount++;
98+
}
99+
});
100+
101+
try {
102+
await new Promise((resolve) => setTimeout(resolve, 100));
103+
await fs.writeFile(path.join(tempDir, 'notes.txt'), 'this should be ignored');
104+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 700));
105+
expect(callCount).toBe(0);
106+
} finally {
107+
stop();
108+
}
109+
}, 5000);
110+
111+
it('triggers on .gitignore changes', async () => {
112+
const debounceMs = 250;
113+
let callCount = 0;
114+
115+
const stop = startFileWatcher({
116+
rootPath: tempDir,
117+
debounceMs,
118+
onChanged: () => {
119+
callCount++;
120+
}
121+
});
122+
123+
try {
124+
await new Promise((resolve) => setTimeout(resolve, 100));
125+
await fs.writeFile(path.join(tempDir, '.gitignore'), 'dist/\n');
126+
await new Promise((resolve) => setTimeout(resolve, debounceMs + 700));
127+
expect(callCount).toBe(1);
128+
} finally {
129+
stop();
130+
}
131+
}, 5000);
88132
});

0 commit comments

Comments
 (0)