From 00c23206f925adb4d2a2f80dde6a28d1300bd7f7 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 23:01:24 +0100 Subject: [PATCH 1/2] test: complete phase 2d watcher auto-refresh validation --- src/core/file-watcher.ts | 14 +++- tests/auto-refresh-e2e.test.ts | 135 +++++++++++++++++++++++++++++++++ tests/file-watcher.test.ts | 22 ++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/auto-refresh-e2e.test.ts diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index 6abc3ba..5e15edd 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -1,4 +1,6 @@ import chokidar from 'chokidar'; +import path from 'path'; +import { getSupportedExtensions } from '../utils/language-detection.js'; export interface FileWatcherOptions { rootPath: string; @@ -8,6 +10,15 @@ export interface FileWatcherOptions { onChanged: () => void; } +const TRACKED_EXTENSIONS = new Set( + getSupportedExtensions().map((extension) => extension.toLowerCase()) +); + +function isTrackedSourcePath(filePath: string): boolean { + const extension = path.extname(filePath).toLowerCase(); + return extension.length > 0 && TRACKED_EXTENSIONS.has(extension); +} + /** * Watch rootPath for source file changes and call onChanged (debounced). * Returns a stop() function that cancels the debounce timer and closes the watcher. @@ -16,7 +27,8 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void { const { rootPath, debounceMs = 2000, onChanged } = opts; let debounceTimer: ReturnType | undefined; - const trigger = () => { + const trigger = (filePath: string) => { + if (!isTrackedSourcePath(filePath)) return; if (debounceTimer !== undefined) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = undefined; diff --git a/tests/auto-refresh-e2e.test.ts b/tests/auto-refresh-e2e.test.ts new file mode 100644 index 0000000..2f9ed69 --- /dev/null +++ b/tests/auto-refresh-e2e.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +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 { + CODEBASE_CONTEXT_DIRNAME, + KEYWORD_INDEX_FILENAME +} from '../src/constants/codebase-context.js'; + +type IndexStatus = 'idle' | 'indexing' | 'ready' | 'error'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getKeywordChunks(raw: unknown): Array> { + if (Array.isArray(raw)) { + return raw.filter(isRecord); + } + if (!isRecord(raw)) return []; + if (!Array.isArray(raw.chunks)) return []; + return raw.chunks.filter(isRecord); +} + +async function readIndexedContent(rootPath: string): Promise { + const indexPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME); + const raw = JSON.parse(await fs.readFile(indexPath, 'utf-8')) as unknown; + const chunks = getKeywordChunks(raw); + return chunks + .map((chunk) => (typeof chunk.content === 'string' ? chunk.content : '')) + .join('\n'); +} + +async function waitFor( + condition: () => Promise, + timeoutMs: number, + intervalMs: number +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await condition()) return; + await sleep(intervalMs); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +} + +describe('Auto-refresh E2E', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-refresh-e2e-')); + await fs.mkdir(path.join(tempDir, 'src'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'watch-test' })); + await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "INITIAL_TOKEN";\n'); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('updates index after a file edit without manual refresh_index', async () => { + await new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true } + }).index(); + + const initialContent = await readIndexedContent(tempDir); + expect(initialContent).toContain('INITIAL_TOKEN'); + expect(initialContent).not.toContain('UPDATED_TOKEN'); + + const autoRefresh = createAutoRefreshController(); + let indexStatus: IndexStatus = 'ready'; + let incrementalRuns = 0; + + const runIncrementalIndex = async (): Promise => { + if (indexStatus === 'indexing') return; + indexStatus = 'indexing'; + + try { + await new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true }, + incrementalOnly: true + }).index(); + indexStatus = 'ready'; + } catch (error) { + indexStatus = 'error'; + throw error; + } + + if (autoRefresh.consumeQueuedRefresh(indexStatus)) { + incrementalRuns++; + void runIncrementalIndex(); + } + }; + + const stopWatcher = startFileWatcher({ + rootPath: tempDir, + debounceMs: 200, + onChanged: () => { + const shouldRunNow = autoRefresh.onFileChange(indexStatus === 'indexing'); + if (!shouldRunNow) return; + incrementalRuns++; + void runIncrementalIndex(); + } + }); + + try { + await sleep(250); + await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "UPDATED_TOKEN";\n'); + + await waitFor( + async () => { + const content = await readIndexedContent(tempDir); + return content.includes('UPDATED_TOKEN'); + }, + 15000, + 200 + ); + + const updatedContent = await readIndexedContent(tempDir); + expect(updatedContent).toContain('UPDATED_TOKEN'); + expect(incrementalRuns).toBeGreaterThan(0); + } finally { + stopWatcher(); + } + }, 20000); +}); diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts index a1a6bc3..a51d301 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -85,4 +85,26 @@ describe('FileWatcher', () => { await new Promise((resolve) => setTimeout(resolve, debounceMs + 200)); expect(callCount).toBe(0); }, 5000); + + it('ignores changes to non-tracked file extensions', async () => { + const debounceMs = 250; + let callCount = 0; + + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs, + onChanged: () => { + callCount++; + } + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + await fs.writeFile(path.join(tempDir, 'notes.txt'), 'this should be ignored'); + await new Promise((resolve) => setTimeout(resolve, debounceMs + 700)); + expect(callCount).toBe(0); + } finally { + stop(); + } + }, 5000); }); From 256244a0d77b167981a648439ad6cecf354c3793 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sun, 1 Mar 2026 14:42:27 +0100 Subject: [PATCH 2/2] fix: address PR feedback for watcher edge cases --- src/core/file-watcher.ts | 4 ++++ tests/auto-refresh-e2e.test.ts | 14 ++++++++++++-- tests/file-watcher.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index 5e15edd..4687466 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -14,7 +14,11 @@ const TRACKED_EXTENSIONS = new Set( getSupportedExtensions().map((extension) => extension.toLowerCase()) ); +const TRACKED_METADATA_FILES = new Set(['.gitignore']); + function isTrackedSourcePath(filePath: string): boolean { + const basename = path.basename(filePath).toLowerCase(); + if (TRACKED_METADATA_FILES.has(basename)) return true; const extension = path.extname(filePath).toLowerCase(); return extension.length > 0 && TRACKED_EXTENSIONS.has(extension); } diff --git a/tests/auto-refresh-e2e.test.ts b/tests/auto-refresh-e2e.test.ts index 2f9ed69..a7e0509 100644 --- a/tests/auto-refresh-e2e.test.ts +++ b/tests/auto-refresh-e2e.test.ts @@ -44,11 +44,21 @@ async function waitFor( intervalMs: number ): Promise { const startedAt = Date.now(); + let lastError: unknown; while (Date.now() - startedAt < timeoutMs) { - if (await condition()) return; + try { + if (await condition()) return; + lastError = undefined; + } catch (error) { + lastError = error; + } await sleep(intervalMs); } - throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); + const reason = + lastError instanceof Error && lastError.message + ? ` Last transient error: ${lastError.message}` + : ''; + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${reason}`); } describe('Auto-refresh E2E', () => { diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts index a51d301..be36ba3 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -107,4 +107,26 @@ describe('FileWatcher', () => { stop(); } }, 5000); + + it('triggers on .gitignore changes', async () => { + const debounceMs = 250; + let callCount = 0; + + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs, + onChanged: () => { + callCount++; + } + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + await fs.writeFile(path.join(tempDir, '.gitignore'), 'dist/\n'); + await new Promise((resolve) => setTimeout(resolve, debounceMs + 700)); + expect(callCount).toBe(1); + } finally { + stop(); + } + }, 5000); });