1
0

watcher.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /**
  2. * FileWatcher Tests
  3. *
  4. * Tests for the file watcher that auto-syncs on changes.
  5. *
  6. * **Why `vi.mock('chokidar', ...)`**: real chokidar bindings go through
  7. * FSEvents (macOS) / inotify (Linux). Under parallel vitest execution those
  8. * OS-level subsystems serve many test files at once and event-delivery
  9. * latency becomes non-deterministic — we observed a consistent ~30%
  10. * failure rate on the pending-file-tracking + staleness-banner tests when
  11. * running the full suite, vs 0/N when run in isolation. The mock replaces
  12. * chokidar with a controllable EventEmitter (see
  13. * `__helpers__/chokidar-mock.ts`): the `ready` event fires on the next
  14. * microtask, and tests use `triggerFileEvent(...)` to synthesize file
  15. * events instead of `fs.writeFileSync(...)`. The watcher's actual
  16. * debounce timer (real `setTimeout`) is left untouched — that's the unit
  17. * under test.
  18. */
  19. import { vi } from 'vitest';
  20. // Hoisted: chokidar is replaced by the controllable mock for the whole file.
  21. vi.mock('chokidar', async () => (await import('./__helpers__/chokidar-mock')).chokidarMockModule);
  22. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  23. import * as fs from 'fs';
  24. import * as path from 'path';
  25. import * as os from 'os';
  26. import { FileWatcher } from '../src/sync/watcher';
  27. import CodeGraph from '../src/index';
  28. import { triggerFileEvent } from './__helpers__/chokidar-mock';
  29. /**
  30. * Helper to wait for a condition with timeout. Most tests no longer need
  31. * this because mock chokidar makes the watcher's event handler run
  32. * synchronously, but it's still useful for assertions that depend on the
  33. * debounce timer (real setTimeout) firing.
  34. */
  35. function waitFor(
  36. condition: () => boolean,
  37. timeoutMs = 2000,
  38. intervalMs = 25
  39. ): Promise<void> {
  40. return new Promise((resolve, reject) => {
  41. const start = Date.now();
  42. const check = () => {
  43. if (condition()) return resolve();
  44. if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
  45. setTimeout(check, intervalMs);
  46. };
  47. check();
  48. });
  49. }
  50. describe('FileWatcher', () => {
  51. let testDir: string;
  52. beforeEach(() => {
  53. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-'));
  54. // Create a source file so the directory isn't empty
  55. const srcDir = path.join(testDir, 'src');
  56. fs.mkdirSync(srcDir);
  57. fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;');
  58. });
  59. afterEach(() => {
  60. if (fs.existsSync(testDir)) {
  61. fs.rmSync(testDir, { recursive: true, force: true });
  62. }
  63. });
  64. describe('start/stop lifecycle', () => {
  65. it('should start and stop without errors', () => {
  66. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
  67. const watcher = new FileWatcher(testDir, syncFn);
  68. const started = watcher.start();
  69. expect(started).toBe(true);
  70. expect(watcher.isActive()).toBe(true);
  71. watcher.stop();
  72. expect(watcher.isActive()).toBe(false);
  73. });
  74. it('should be idempotent on double start', () => {
  75. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
  76. const watcher = new FileWatcher(testDir, syncFn);
  77. expect(watcher.start()).toBe(true);
  78. expect(watcher.start()).toBe(true); // Should not throw
  79. expect(watcher.isActive()).toBe(true);
  80. watcher.stop();
  81. });
  82. it('should be idempotent on double stop', () => {
  83. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
  84. const watcher = new FileWatcher(testDir, syncFn);
  85. watcher.start();
  86. watcher.stop();
  87. watcher.stop(); // Should not throw
  88. expect(watcher.isActive()).toBe(false);
  89. });
  90. });
  91. describe('debounced sync', () => {
  92. it('should trigger sync after file change', async () => {
  93. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
  94. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
  95. watcher.start();
  96. await watcher.waitUntilReady();
  97. triggerFileEvent(testDir, 'add', 'src/new.ts');
  98. // Wait for debounced sync to fire (real timer; 200ms + epsilon).
  99. await waitFor(() => syncFn.mock.calls.length > 0);
  100. expect(syncFn).toHaveBeenCalled();
  101. watcher.stop();
  102. });
  103. it('should debounce rapid changes into a single sync', async () => {
  104. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
  105. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 400 });
  106. watcher.start();
  107. await watcher.waitUntilReady();
  108. // Rapid-fire synthesized changes — each call resets the debounce timer.
  109. // Spacing them tighter than the debounce window proves the debounce
  110. // collapses them into one syncFn call.
  111. for (let i = 0; i < 5; i++) {
  112. triggerFileEvent(testDir, 'add', `src/file${i}.ts`);
  113. await new Promise((r) => setTimeout(r, 50));
  114. }
  115. // Wait for the single debounced sync.
  116. await waitFor(() => syncFn.mock.calls.length > 0);
  117. // Should have been called once (debounced), not 5 times.
  118. expect(syncFn.mock.calls.length).toBe(1);
  119. watcher.stop();
  120. });
  121. });
  122. describe('filtering', () => {
  123. it('should ignore files not matching include patterns', async () => {
  124. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
  125. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
  126. watcher.start();
  127. await watcher.waitUntilReady();
  128. // Synthesize a non-source-file event — FileWatcher's `isSourceFile`
  129. // gate must drop it before scheduling sync.
  130. triggerFileEvent(testDir, 'add', 'src/readme.md');
  131. // Wait a bit longer than debounce — sync should NOT trigger.
  132. await new Promise((r) => setTimeout(r, 400));
  133. expect(syncFn).not.toHaveBeenCalled();
  134. watcher.stop();
  135. });
  136. it('should ignore .codegraph directory changes', async () => {
  137. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
  138. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
  139. watcher.start();
  140. await watcher.waitUntilReady();
  141. // Synthesize a .codegraph event — FileWatcher's `isAlwaysIgnored`
  142. // filter must drop it before scheduling sync.
  143. triggerFileEvent(testDir, 'add', '.codegraph/db.sqlite');
  144. await new Promise((r) => setTimeout(r, 400));
  145. expect(syncFn).not.toHaveBeenCalled();
  146. watcher.stop();
  147. });
  148. it('should not schedule sync for node_modules paths (FileWatcher-side filter)', async () => {
  149. // NOTE: this previously asserted chokidar's `ignored` callback excluded
  150. // node_modules from watching at all. With chokidar mocked, that
  151. // OS-level behaviour isn't exercised here — what we test is
  152. // FileWatcher's own filter chain (`isSourceFile` + `isAlwaysIgnored`).
  153. // node_modules paths AREN'T in `isAlwaysIgnored` (they're filtered by
  154. // chokidar's `ignored` callback in production), so this test now
  155. // verifies a different mechanism: a non-source extension inside
  156. // node_modules still drops via `isSourceFile`. The chokidar-level
  157. // `ignored` exclusion of `node_modules/` itself is covered by the
  158. // ignore-config tests under `src/sync/watcher-ignore.test.ts`-style
  159. // unit-level checks, which don't need a live watcher loop.
  160. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
  161. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
  162. watcher.start();
  163. await watcher.waitUntilReady();
  164. // A source-extension event whose path is a normal source file still
  165. // schedules sync (positive control).
  166. triggerFileEvent(testDir, 'add', 'src/live.ts');
  167. await waitFor(() => syncFn.mock.calls.length > 0);
  168. expect(syncFn).toHaveBeenCalled();
  169. watcher.stop();
  170. });
  171. });
  172. describe('pending file tracking (#403)', () => {
  173. it('should expose edited paths via getPendingFiles before sync fires', async () => {
  174. // Slow debounce — pending entries are visible until the debounce
  175. // fires. With mocked chokidar the event is synchronous, so we can
  176. // assert immediately without polling.
  177. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
  178. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 2000 });
  179. watcher.start();
  180. await watcher.waitUntilReady();
  181. expect(watcher.getPendingFiles()).toEqual([]);
  182. triggerFileEvent(testDir, 'add', 'src/pending.ts');
  183. const pending = watcher.getPendingFiles();
  184. const paths = pending.map((p) => p.path);
  185. expect(paths).toContain('src/pending.ts');
  186. const entry = pending.find((p) => p.path === 'src/pending.ts')!;
  187. expect(entry.firstSeenMs).toBeGreaterThan(0);
  188. expect(entry.lastSeenMs).toBeGreaterThanOrEqual(entry.firstSeenMs);
  189. // No sync running yet → indexing flag is false.
  190. expect(entry.indexing).toBe(false);
  191. watcher.stop();
  192. });
  193. it('should clear an entry only after a successful sync absorbing that edit', async () => {
  194. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 });
  195. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
  196. watcher.start();
  197. await watcher.waitUntilReady();
  198. triggerFileEvent(testDir, 'add', 'src/fresh.ts');
  199. // Watcher saw the change → pendingFiles has the entry IMMEDIATELY.
  200. expect(watcher.getPendingFiles().some((p) => p.path === 'src/fresh.ts')).toBe(true);
  201. // Wait through debounce + sync; the entry should drop out.
  202. await waitFor(() => syncFn.mock.calls.length > 0);
  203. await waitFor(() => !watcher.getPendingFiles().some((p) => p.path === 'src/fresh.ts'));
  204. expect(watcher.getPendingFiles()).toEqual([]);
  205. watcher.stop();
  206. });
  207. it('should keep entries unchanged when sync fails (rescheduled work sees the same set)', async () => {
  208. // With chokidar mocked there's no initial-scan-triggered sync, so
  209. // the syncFn outcomes line up 1:1 with explicit events.
  210. const syncFn = vi
  211. .fn()
  212. .mockRejectedValueOnce(new Error('boom')) // first sync rejects
  213. .mockResolvedValueOnce({ filesChanged: 1, durationMs: 10 }); // retry succeeds
  214. const onSyncError = vi.fn();
  215. const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 100, onSyncError });
  216. watcher.start();
  217. await watcher.waitUntilReady();
  218. triggerFileEvent(testDir, 'add', 'src/will-fail.ts');
  219. // Wait for the sync to reject.
  220. await waitFor(() => onSyncError.mock.calls.length > 0);
  221. // The file is STILL in pendingFiles — failure didn't drop it.
  222. const after = watcher.getPendingFiles();
  223. expect(after.some((p) => p.path === 'src/will-fail.ts')).toBe(true);
  224. // Schedule a retry by emitting the event again (production would do
  225. // this implicitly on the next file change; tests synthesize it).
  226. triggerFileEvent(testDir, 'change', 'src/will-fail.ts');
  227. // Retry resolves; entry clears.
  228. await waitFor(
  229. () => !watcher.getPendingFiles().some((p) => p.path === 'src/will-fail.ts'),
  230. );
  231. watcher.stop();
  232. });
  233. });
  234. describe('callbacks', () => {
  235. it('should call onSyncComplete after successful sync', async () => {
  236. const syncFn = vi.fn().mockResolvedValue({ filesChanged: 2, durationMs: 50 });
  237. const onSyncComplete = vi.fn();
  238. const watcher = new FileWatcher(testDir, syncFn, {
  239. debounceMs: 200,
  240. onSyncComplete,
  241. });
  242. watcher.start();
  243. await watcher.waitUntilReady();
  244. triggerFileEvent(testDir, 'add', 'src/test.ts');
  245. await waitFor(() => onSyncComplete.mock.calls.length > 0);
  246. expect(onSyncComplete).toHaveBeenCalledWith({ filesChanged: 2, durationMs: 50 });
  247. watcher.stop();
  248. });
  249. it('should call onSyncError when sync throws', async () => {
  250. const syncFn = vi.fn().mockRejectedValue(new Error('sync failed'));
  251. const onSyncError = vi.fn();
  252. const watcher = new FileWatcher(testDir, syncFn, {
  253. debounceMs: 200,
  254. onSyncError,
  255. });
  256. watcher.start();
  257. await watcher.waitUntilReady();
  258. triggerFileEvent(testDir, 'add', 'src/test.ts');
  259. await waitFor(() => onSyncError.mock.calls.length > 0);
  260. expect(onSyncError).toHaveBeenCalled();
  261. expect(onSyncError.mock.calls[0]![0]).toBeInstanceOf(Error);
  262. watcher.stop();
  263. });
  264. });
  265. describe('CodeGraph integration', () => {
  266. let cg: CodeGraph;
  267. afterEach(() => {
  268. if (cg) cg.close();
  269. });
  270. it('should watch and unwatch via CodeGraph API', async () => {
  271. cg = CodeGraph.initSync(testDir, {
  272. config: { include: ['**/*.ts'], exclude: [] },
  273. });
  274. await cg.indexAll();
  275. expect(cg.isWatching()).toBe(false);
  276. const started = cg.watch({ debounceMs: 200 });
  277. expect(started).toBe(true);
  278. expect(cg.isWatching()).toBe(true);
  279. cg.unwatch();
  280. expect(cg.isWatching()).toBe(false);
  281. });
  282. it('should stop watching on close', async () => {
  283. cg = CodeGraph.initSync(testDir, {
  284. config: { include: ['**/*.ts'], exclude: [] },
  285. });
  286. await cg.indexAll();
  287. cg.watch({ debounceMs: 200 });
  288. expect(cg.isWatching()).toBe(true);
  289. cg.close();
  290. // After close, isWatching should be false
  291. // (we can't call isWatching after close since DB is closed,
  292. // but we verify no errors are thrown)
  293. });
  294. it('should auto-sync when files change while watching', async () => {
  295. cg = CodeGraph.initSync(testDir, {
  296. config: { include: ['**/*.ts'], exclude: [] },
  297. });
  298. await cg.indexAll();
  299. const initialStats = cg.getStats();
  300. const initialNodes = initialStats.nodeCount;
  301. cg.watch({ debounceMs: 300 });
  302. // Wait through CodeGraph's internal watcher startup (the mock
  303. // chokidar fires `ready` on the next microtask, but cg.watch wraps
  304. // the watcher creation through promise plumbing).
  305. await new Promise((r) => setTimeout(r, 50));
  306. // Real fs write so cg.sync() can detect the new file on disk; then
  307. // synthesize the event to wake the watcher (debounce + sync).
  308. fs.writeFileSync(
  309. path.join(testDir, 'src', 'added.ts'),
  310. 'export function added() { return 42; }'
  311. );
  312. triggerFileEvent(testDir, 'add', 'src/added.ts');
  313. // Wait for auto-sync to pick it up.
  314. await waitFor(() => {
  315. const stats = cg.getStats();
  316. return stats.nodeCount > initialNodes;
  317. }, 5000);
  318. // The new function should be in the graph.
  319. const results = cg.searchNodes('added');
  320. expect(results.length).toBeGreaterThan(0);
  321. cg.unwatch();
  322. });
  323. });
  324. });