diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index 6abc3ba..4687466 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,19 @@ export interface FileWatcherOptions { onChanged: () => void; } +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); +} + /** * 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 +31,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..a7e0509 --- /dev/null +++ b/tests/auto-refresh-e2e.test.ts @@ -0,0 +1,145 @@ +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(); + let lastError: unknown; + while (Date.now() - startedAt < timeoutMs) { + try { + if (await condition()) return; + lastError = undefined; + } catch (error) { + lastError = error; + } + await sleep(intervalMs); + } + 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', () => { + 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..be36ba3 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -85,4 +85,48 @@ 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); + + 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); });