1
0

mcp-staleness-banner.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /**
  2. * Per-file staleness banner on MCP tool responses (issue #403).
  3. *
  4. * The watcher tracks every file event since the last successful sync; the
  5. * tool dispatcher intersects "files referenced in this response" with that
  6. * pending set and prepends a banner ("⚠️ Some files referenced below were
  7. * edited since the last index sync…") plus an optional footer ("(Note: N
  8. * file(s) elsewhere in this project are pending index sync…)").
  9. *
  10. * No auto-flush, no static wait — the response is instant and the agent
  11. * decides whether to Read the specific stale file. These tests exercise
  12. * the full real path: real CodeGraph index + real ToolHandler.execute().
  13. *
  14. * **chokidar is mocked** (see __helpers__/chokidar-mock.ts): the real
  15. * FSEvents/inotify event delivery is non-deterministic under parallel
  16. * vitest execution and produced a consistent ~30% failure rate on these
  17. * tests when run inside the full suite. The mock replaces chokidar with
  18. * a controllable EventEmitter so the tests synthesize file events
  19. * deterministically via `triggerFileEvent(...)` instead of waiting on
  20. * the OS-level watcher to deliver. The watcher's actual debounce timer
  21. * (real setTimeout) is left untouched.
  22. */
  23. import { vi } from 'vitest';
  24. // Hoisted: chokidar is replaced by the controllable mock for this file.
  25. vi.mock('chokidar', async () => (await import('./__helpers__/chokidar-mock')).chokidarMockModule);
  26. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  27. import * as fs from 'fs';
  28. import * as path from 'path';
  29. import * as os from 'os';
  30. import CodeGraph from '../src/index';
  31. import { ToolHandler } from '../src/mcp/tools';
  32. import { triggerFileEvent } from './__helpers__/chokidar-mock';
  33. function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise<void> {
  34. return new Promise((resolve, reject) => {
  35. const start = Date.now();
  36. const tick = () => {
  37. if (condition()) return resolve();
  38. if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
  39. setTimeout(tick, intervalMs);
  40. };
  41. tick();
  42. });
  43. }
  44. describe('MCP staleness banner', () => {
  45. let testDir: string;
  46. let cg: CodeGraph;
  47. let handler: ToolHandler;
  48. beforeEach(async () => {
  49. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-'));
  50. fs.mkdirSync(path.join(testDir, 'src'));
  51. // Three isolated files with no cross-references — keeps each test's
  52. // "which path does the response mention?" assertion unambiguous. If the
  53. // files shared imports/calls, codegraph_search responses would surface
  54. // multiple file paths and the banner-vs-footer split would be racy.
  55. fs.writeFileSync(
  56. path.join(testDir, 'src', 'alpha-only.ts'),
  57. 'export function alphaOnly() { return 1; }\n',
  58. );
  59. fs.writeFileSync(
  60. path.join(testDir, 'src', 'bravo-only.ts'),
  61. 'export function bravoOnly() { return 2; }\n',
  62. );
  63. fs.writeFileSync(
  64. path.join(testDir, 'src', 'charlie-only.ts'),
  65. 'export function charlieOnly() { return 3; }\n',
  66. );
  67. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  68. await cg.indexAll();
  69. handler = new ToolHandler(cg);
  70. });
  71. afterEach(() => {
  72. try { cg.unwatch(); } catch { /* ignore */ }
  73. try { cg.close(); } catch { /* ignore */ }
  74. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  75. });
  76. it('prepends a stale banner when the response references a pending file', async () => {
  77. // Long debounce so the edit lingers in pendingFiles while we query.
  78. cg.watch({ debounceMs: 4000 });
  79. await cg.waitUntilWatcherReady();
  80. // Real disk write so a later sync (if it fires) sees the new content,
  81. // plus a synthesized chokidar event so the watcher's pendingFiles set
  82. // updates immediately without waiting on OS-level event delivery.
  83. fs.writeFileSync(
  84. path.join(testDir, 'src', 'alpha-only.ts'),
  85. 'export function alphaOnly() { return 99; }\n',
  86. );
  87. triggerFileEvent(testDir, 'change', 'src/alpha-only.ts');
  88. // With mocked chokidar this is synchronous — keep the wait just to
  89. // exercise the realistic shape (the watcher's `chokidarReady` gate
  90. // and the small window before the pending-file Map is populated).
  91. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts'));
  92. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  93. expect(res.isError).toBeFalsy();
  94. const text = res.content[0].text;
  95. // Banner shape: warning glyph + filename + actionable instruction.
  96. expect(text.startsWith('⚠️')).toBe(true);
  97. expect(text).toContain('src/alpha-only.ts');
  98. expect(text).toMatch(/edited \d+ms ago/);
  99. expect(text).toMatch(/Read them directly/);
  100. // The actual result must still follow the banner.
  101. expect(text).toMatch(/alphaOnly/);
  102. });
  103. it('uses the footer (not the banner) when pending files are not referenced', async () => {
  104. cg.watch({ debounceMs: 4000 });
  105. await cg.waitUntilWatcherReady();
  106. // Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
  107. // only in alpha-only.ts. The two files share no imports/calls so the
  108. // response text won't mention bravo-only.ts.
  109. fs.writeFileSync(
  110. path.join(testDir, 'src', 'bravo-only.ts'),
  111. 'export function bravoOnly() { return 22; }\n',
  112. );
  113. triggerFileEvent(testDir, 'change', 'src/bravo-only.ts');
  114. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'));
  115. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  116. const text = res.content[0].text;
  117. expect(text.startsWith('⚠️')).toBe(false);
  118. expect(text).toMatch(/elsewhere in this project are pending index sync/);
  119. expect(text).toContain('src/bravo-only.ts');
  120. });
  121. it('drops the banner once the sync completes and clears the pending entry', async () => {
  122. cg.watch({ debounceMs: 200 });
  123. await cg.waitUntilWatcherReady();
  124. fs.writeFileSync(
  125. path.join(testDir, 'src', 'alpha-only.ts'),
  126. 'export function alphaOnly() { return 7; }\n',
  127. );
  128. triggerFileEvent(testDir, 'change', 'src/alpha-only.ts');
  129. // Wait through debounce (200ms) + sync; pendingFiles drains back to empty.
  130. await waitFor(() => cg.getPendingFiles().length === 0, 3000);
  131. const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
  132. const text = res.content[0].text;
  133. expect(text.startsWith('⚠️')).toBe(false);
  134. expect(text).not.toMatch(/elsewhere in this project are pending index sync/);
  135. });
  136. it('lists pending files under "Pending sync" in codegraph_status', async () => {
  137. cg.watch({ debounceMs: 4000 });
  138. await cg.waitUntilWatcherReady();
  139. fs.writeFileSync(
  140. path.join(testDir, 'src', 'charlie-only.ts'),
  141. 'export function charlieOnly() { return 33; }\n',
  142. );
  143. triggerFileEvent(testDir, 'change', 'src/charlie-only.ts');
  144. await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'));
  145. const res = await handler.execute('codegraph_status', {});
  146. const text = res.content[0].text;
  147. expect(text).toContain('### Pending sync:');
  148. expect(text).toContain('src/charlie-only.ts');
  149. // Status embeds the info first-class, so the auto-banner is suppressed.
  150. expect(text.startsWith('⚠️')).toBe(false);
  151. });
  152. it('returns zero pending files when no watcher is active', () => {
  153. expect(cg.getPendingFiles()).toEqual([]);
  154. });
  155. });