forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp-staleness-banner.test.ts
More file actions
212 lines (184 loc) · 8.88 KB
/
Copy pathmcp-staleness-banner.test.ts
File metadata and controls
212 lines (184 loc) · 8.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
/**
* Per-file staleness banner on MCP tool responses (issue #403).
*
* The watcher tracks every file event since the last successful sync; the
* tool dispatcher intersects "files referenced in this response" with that
* pending set and prepends a banner ("⚠️ Some files referenced below were
* edited since the last index sync…") plus an optional footer ("(Note: N
* file(s) elsewhere in this project are pending index sync…)").
*
* No auto-flush, no static wait — the response is instant and the agent
* decides whether to Read the specific stale file. These tests exercise
* the full real path: real CodeGraph index + real ToolHandler.execute().
*
* **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the
* real native fs.watch (FSEvents/inotify) delivery is non-deterministic under
* parallel vitest execution and produced a consistent ~30% failure rate on
* these tests when run inside the full suite. The seam drives the watcher's
* pending-set pipeline directly so the tests synthesize file events
* deterministically. The watcher's actual debounce timer (real setTimeout) is
* left untouched.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { __emitWatchEventForTests, __setFsWatchForTests } from '../src/sync/watcher';
function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const tick = () => {
if (condition()) return resolve();
if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out'));
setTimeout(tick, intervalMs);
};
tick();
});
}
describe('MCP staleness banner', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-banner-'));
fs.mkdirSync(path.join(testDir, 'src'));
// Three isolated files with no cross-references — keeps each test's
// "which path does the response mention?" assertion unambiguous. If the
// files shared imports/calls, codegraph_search responses would surface
// multiple file paths and the banner-vs-footer split would be racy.
fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 1; }\n',
);
fs.writeFileSync(
path.join(testDir, 'src', 'bravo-only.ts'),
'export function bravoOnly() { return 2; }\n',
);
fs.writeFileSync(
path.join(testDir, 'src', 'charlie-only.ts'),
'export function charlieOnly() { return 3; }\n',
);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
__setFsWatchForTests(null); // reset the injected fs.watch seam
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
// Force watch-resource exhaustion at startup so the real watcher degrades
// deterministically on any platform (recursive or per-directory strategy).
const degradeWatcher = () => {
__setFsWatchForTests(() => {
const err = new Error('too many open files') as NodeJS.ErrnoException;
err.code = 'EMFILE';
throw err;
});
const started = cg.watch({ debounceMs: 1000 }); // real (non-inert) watcher
expect(started).toBe(false);
expect(cg.isWatcherDegraded()).toBe(true);
};
it('prepends a stale banner when the response references a pending file', async () => {
// Long debounce so the edit lingers in pendingFiles while we query.
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
// Real disk write so a later sync (if it fires) sees the new content,
// plus a synthesized chokidar event so the watcher's pendingFiles set
// updates immediately without waiting on OS-level event delivery.
fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 99; }\n',
);
__emitWatchEventForTests(testDir, 'src/alpha-only.ts');
// With mocked chokidar this is synchronous — keep the wait just to
// exercise the realistic shape (the watcher's `chokidarReady` gate
// and the small window before the pending-file Map is populated).
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/alpha-only.ts'));
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
expect(res.isError).toBeFalsy();
const text = res.content[0].text;
// Banner shape: warning glyph + filename + actionable instruction.
expect(text.startsWith('⚠️')).toBe(true);
expect(text).toContain('src/alpha-only.ts');
expect(text).toMatch(/edited \d+ms ago/);
expect(text).toMatch(/Read them directly/);
// The actual result must still follow the banner.
expect(text).toMatch(/alphaOnly/);
});
it('uses the footer (not the banner) when pending files are not referenced', async () => {
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
// Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
// only in alpha-only.ts. The two files share no imports/calls so the
// response text won't mention bravo-only.ts.
fs.writeFileSync(
path.join(testDir, 'src', 'bravo-only.ts'),
'export function bravoOnly() { return 22; }\n',
);
__emitWatchEventForTests(testDir, 'src/bravo-only.ts');
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'));
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
const text = res.content[0].text;
expect(text.startsWith('⚠️')).toBe(false);
expect(text).toMatch(/elsewhere in this project are pending index sync/);
expect(text).toContain('src/bravo-only.ts');
});
it('drops the banner once the sync completes and clears the pending entry', async () => {
cg.watch({ debounceMs: 200, inertForTests: true });
await cg.waitUntilWatcherReady();
fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 7; }\n',
);
__emitWatchEventForTests(testDir, 'src/alpha-only.ts');
// Wait through debounce (200ms) + sync; pendingFiles drains back to empty.
await waitFor(() => cg.getPendingFiles().length === 0, 3000);
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
const text = res.content[0].text;
expect(text.startsWith('⚠️')).toBe(false);
expect(text).not.toMatch(/elsewhere in this project are pending index sync/);
});
it('lists pending files under "Pending sync" in codegraph_status', async () => {
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
fs.writeFileSync(
path.join(testDir, 'src', 'charlie-only.ts'),
'export function charlieOnly() { return 33; }\n',
);
__emitWatchEventForTests(testDir, 'src/charlie-only.ts');
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'));
const res = await handler.execute('codegraph_status', {});
const text = res.content[0].text;
expect(text).toContain('### Pending sync:');
expect(text).toContain('src/charlie-only.ts');
// Status embeds the info first-class, so the auto-banner is suppressed.
expect(text.startsWith('⚠️')).toBe(false);
});
it('returns zero pending files when no watcher is active', () => {
expect(cg.getPendingFiles()).toEqual([]);
});
it('prepends a whole-index degraded banner once live watching has permanently stopped (#876)', async () => {
degradeWatcher();
const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
expect(res.isError).toBeFalsy();
const text = res.content[0].text;
expect(text.startsWith('⚠️')).toBe(true);
expect(text).toMatch(/auto-sync is DISABLED/i);
expect(text).toMatch(/Read files directly/i);
expect(text).toContain('OS watch/file limit exhausted'); // the degrade reason
expect(text).toMatch(/alphaOnly/); // the real result still follows the banner
});
it('surfaces the degraded state as its own section in codegraph_status (#876)', async () => {
degradeWatcher();
const res = await handler.execute('codegraph_status', {});
const text = res.content[0].text;
expect(text).toContain('### Auto-sync disabled:');
expect(text).toContain('OS watch/file limit exhausted');
// status renders the notice inline, so the auto-banner is not also prepended.
expect(text.startsWith('⚠️')).toBe(false);
});
});