Skip to content

Commit 83faa07

Browse files
committed
test(integration): add explain_file, list_modules and strategy health tests
- mcp-v14.test.js (new): 13 tests for v1.4 MCP tools - tools/list count updated to 7 - explain_file: signatures, imports, callers, error cases - list_modules: table structure, token counts, error case - multi-call session test - observability.test.js: 12 new scorer tests - strategy field present in all results - hot-cold / per-module: no reduction penalty - full: reduction penalty still applied - strategyFreshnessDays: null / populated correctly - grade A for fresh untracked project; score stays 0-100 - mcp-server.test.js: updated tools/list count assertion 5 → 7
1 parent 85e9135 commit 83faa07

3 files changed

Lines changed: 477 additions & 3 deletions

File tree

test/integration/mcp-server.test.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,22 @@ test('initialize returns serverInfo', () => {
9999
});
100100

101101
// ─────────────────────────────────────────────────────────────
102-
// Gate 2: tools/list returns 5 tools
102+
// Gate 2: tools/list returns 7 tools (v1.4+)
103103
// ─────────────────────────────────────────────────────────────
104-
test('tools/list returns exactly 5 tools', () => {
104+
test('tools/list returns exactly 7 tools', () => {
105105
withTempProject((dir) => {
106106
const [res] = mcpCall({ jsonrpc: '2.0', method: 'tools/list', id: 2 }, dir);
107107
assert.ok(res.result, 'Should have result');
108108
assert.ok(Array.isArray(res.result.tools), 'tools should be array');
109-
assert.strictEqual(res.result.tools.length, 5);
109+
assert.strictEqual(res.result.tools.length, 7);
110110
const names = res.result.tools.map((t) => t.name);
111111
assert.ok(names.includes('read_context'), 'Should have read_context');
112112
assert.ok(names.includes('search_signatures'), 'Should have search_signatures');
113113
assert.ok(names.includes('get_map'), 'Should have get_map');
114114
assert.ok(names.includes('create_checkpoint'), 'Should have create_checkpoint');
115115
assert.ok(names.includes('get_routing'), 'Should have get_routing');
116+
assert.ok(names.includes('explain_file'), 'Should have explain_file');
117+
assert.ok(names.includes('list_modules'), 'Should have list_modules');
116118
});
117119
});
118120

test/integration/mcp-v14.test.js

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
'use strict';
2+
3+
/**
4+
* Integration tests for v1.4 MCP tools: explain_file and list_modules.
5+
*/
6+
7+
const assert = require('assert');
8+
const fs = require('fs');
9+
const path = require('path');
10+
const os = require('os');
11+
const { execSync } = require('child_process');
12+
13+
const GEN_CONTEXT = path.resolve(__dirname, '../../gen-context.js');
14+
15+
let passed = 0;
16+
let failed = 0;
17+
18+
function test(name, fn) {
19+
try {
20+
fn();
21+
console.log(` PASS ${name}`);
22+
passed++;
23+
} catch (err) {
24+
console.log(` FAIL ${name}: ${err.message}`);
25+
failed++;
26+
}
27+
}
28+
29+
function mcpCall(messages, cwd) {
30+
const input = (Array.isArray(messages) ? messages : [messages])
31+
.map((m) => JSON.stringify(m)).join('\n') + '\n';
32+
const stdout = execSync(`node "${GEN_CONTEXT}" --mcp`, {
33+
input, cwd, encoding: 'utf8', timeout: 10000,
34+
});
35+
return stdout.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
36+
}
37+
38+
function withTempProject(fn) {
39+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-mcp14-'));
40+
try {
41+
fn(dir);
42+
} finally {
43+
fs.rmSync(dir, { recursive: true, force: true });
44+
}
45+
}
46+
47+
function seedContextFile(dir) {
48+
const ghDir = path.join(dir, '.github');
49+
fs.mkdirSync(ghDir, { recursive: true });
50+
fs.writeFileSync(
51+
path.join(ghDir, 'copilot-instructions.md'),
52+
[
53+
'<!-- Generated by ContextForge -->',
54+
'# Code signatures',
55+
'',
56+
'## src',
57+
'',
58+
'### src/auth/login.js',
59+
'```',
60+
'function login(user, pass)',
61+
'function logout(session)',
62+
'```',
63+
'',
64+
'### src/api/routes.js',
65+
'```',
66+
'function getUser(req, res)',
67+
'function createUser(req, res)',
68+
'```',
69+
'',
70+
'### src/utils/hash.js',
71+
'```',
72+
'function hashPassword(plain)',
73+
'function compareHash(plain, hash)',
74+
'```',
75+
'',
76+
].join('\n')
77+
);
78+
}
79+
80+
// ─────────────────────────────────────────────────────────────
81+
// Gate: tools/list now returns 7 tools including v1.4 additions
82+
// ─────────────────────────────────────────────────────────────
83+
84+
console.log('\nMCP v1.4 — tools/list\n');
85+
86+
test('tools/list returns exactly 7 tools', () => {
87+
withTempProject((dir) => {
88+
const [res] = mcpCall({ jsonrpc: '2.0', method: 'tools/list', id: 1 }, dir);
89+
assert.ok(res.result, 'Should have result');
90+
assert.ok(Array.isArray(res.result.tools), 'tools should be array');
91+
assert.strictEqual(res.result.tools.length, 7, `Expected 7 tools, got ${res.result.tools.length}`);
92+
const names = res.result.tools.map((t) => t.name);
93+
assert.ok(names.includes('explain_file'), 'Should have explain_file');
94+
assert.ok(names.includes('list_modules'), 'Should have list_modules');
95+
});
96+
});
97+
98+
// ─────────────────────────────────────────────────────────────
99+
// explain_file — happy path
100+
// ─────────────────────────────────────────────────────────────
101+
102+
console.log('\nMCP v1.4 — explain_file\n');
103+
104+
test('explain_file returns signatures for a known file', () => {
105+
withTempProject((dir) => {
106+
seedContextFile(dir);
107+
const [res] = mcpCall(
108+
{ jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'explain_file', arguments: { path: 'src/auth/login.js' } } },
109+
dir
110+
);
111+
assert.ok(res.result, 'Should have result');
112+
const text = res.result.content[0].text;
113+
assert.ok(text.includes('login'), 'Should include login signature');
114+
assert.ok(text.includes('logout'), 'Should include logout signature');
115+
});
116+
});
117+
118+
test('explain_file includes Signatures section header', () => {
119+
withTempProject((dir) => {
120+
seedContextFile(dir);
121+
const [res] = mcpCall(
122+
{ jsonrpc: '2.0', method: 'tools/call', id: 3, params: { name: 'explain_file', arguments: { path: 'src/api/routes.js' } } },
123+
dir
124+
);
125+
const text = res.result.content[0].text;
126+
assert.ok(text.includes('Signatures') || text.includes('signatures') || text.includes('getUser'), 'Should include signature content');
127+
});
128+
});
129+
130+
test('explain_file includes Imports section when file exists on disk', () => {
131+
withTempProject((dir) => {
132+
seedContextFile(dir);
133+
// Write the actual file so explain_file can read its imports
134+
const authDir = path.join(dir, 'src', 'auth');
135+
fs.mkdirSync(authDir, { recursive: true });
136+
fs.writeFileSync(path.join(authDir, 'login.js'), 'function login(user, pass) {}\nfunction logout(session) {}\nmodule.exports = { login, logout };\n', 'utf8');
137+
const [res] = mcpCall(
138+
{ jsonrpc: '2.0', method: 'tools/call', id: 4, params: { name: 'explain_file', arguments: { path: 'src/auth/login.js' } } },
139+
dir
140+
);
141+
const text = res.result.content[0].text;
142+
// File is on disk — should include the Imports section
143+
assert.ok(text.toLowerCase().includes('import'), 'Should include imports section');
144+
});
145+
});
146+
147+
test('explain_file includes Callers section when file exists on disk', () => {
148+
withTempProject((dir) => {
149+
seedContextFile(dir);
150+
const authDir = path.join(dir, 'src', 'auth');
151+
fs.mkdirSync(authDir, { recursive: true });
152+
fs.writeFileSync(path.join(authDir, 'login.js'), 'function login(user, pass) {}\nfunction logout(session) {}\nmodule.exports = { login, logout };\n', 'utf8');
153+
const [res] = mcpCall(
154+
{ jsonrpc: '2.0', method: 'tools/call', id: 5, params: { name: 'explain_file', arguments: { path: 'src/auth/login.js' } } },
155+
dir
156+
);
157+
const text = res.result.content[0].text;
158+
assert.ok(text.toLowerCase().includes('caller'), 'Should include callers section');
159+
});
160+
});
161+
162+
// ─────────────────────────────────────────────────────────────
163+
// explain_file — error cases
164+
// ─────────────────────────────────────────────────────────────
165+
166+
test('explain_file returns graceful error for unknown path', () => {
167+
withTempProject((dir) => {
168+
seedContextFile(dir);
169+
const [res] = mcpCall(
170+
{ jsonrpc: '2.0', method: 'tools/call', id: 6, params: { name: 'explain_file', arguments: { path: 'src/nonexistent/file.js' } } },
171+
dir
172+
);
173+
assert.ok(res.result, 'Should have result (not a JSON-RPC error)');
174+
const text = res.result.content[0].text;
175+
assert.ok(text.length > 0, 'Should return non-empty message');
176+
// Should not throw or return JSON-RPC error
177+
assert.ok(!res.error, 'Should not be a protocol-level error');
178+
});
179+
});
180+
181+
test('explain_file returns error when path arg missing', () => {
182+
withTempProject((dir) => {
183+
seedContextFile(dir);
184+
const [res] = mcpCall(
185+
{ jsonrpc: '2.0', method: 'tools/call', id: 7, params: { name: 'explain_file', arguments: {} } },
186+
dir
187+
);
188+
assert.ok(res.result, 'Should have result');
189+
const text = res.result.content[0].text;
190+
assert.ok(text.length > 0, 'Should return non-empty message');
191+
});
192+
});
193+
194+
test('explain_file returns error when no context file exists', () => {
195+
withTempProject((dir) => {
196+
// No seedContextFile
197+
const [res] = mcpCall(
198+
{ jsonrpc: '2.0', method: 'tools/call', id: 8, params: { name: 'explain_file', arguments: { path: 'src/auth.js' } } },
199+
dir
200+
);
201+
assert.ok(res.result, 'Should have result');
202+
const text = res.result.content[0].text;
203+
assert.ok(text.length > 0, 'Should return non-empty message');
204+
assert.ok(!res.error, 'Should not be a protocol-level error');
205+
});
206+
});
207+
208+
// ─────────────────────────────────────────────────────────────
209+
// list_modules — happy path
210+
// ─────────────────────────────────────────────────────────────
211+
212+
console.log('\nMCP v1.4 — list_modules\n');
213+
214+
test('list_modules returns a module listing table', () => {
215+
withTempProject((dir) => {
216+
seedContextFile(dir);
217+
const [res] = mcpCall(
218+
{ jsonrpc: '2.0', method: 'tools/call', id: 9, params: { name: 'list_modules', arguments: {} } },
219+
dir
220+
);
221+
assert.ok(res.result, 'Should have result');
222+
const text = res.result.content[0].text;
223+
assert.ok(text.length > 0, 'Should return non-empty content');
224+
// The table should list the top-level "src" directory
225+
assert.ok(text.includes('src'), 'Should list src as a module');
226+
});
227+
});
228+
229+
test('list_modules includes token count info', () => {
230+
withTempProject((dir) => {
231+
seedContextFile(dir);
232+
const [res] = mcpCall(
233+
{ jsonrpc: '2.0', method: 'tools/call', id: 10, params: { name: 'list_modules', arguments: {} } },
234+
dir
235+
);
236+
const text = res.result.content[0].text;
237+
// Should include token column or count
238+
assert.ok(text.match(/\d+/) , 'Should include numeric token counts');
239+
});
240+
});
241+
242+
test('list_modules includes file count column', () => {
243+
withTempProject((dir) => {
244+
seedContextFile(dir);
245+
const [res] = mcpCall(
246+
{ jsonrpc: '2.0', method: 'tools/call', id: 11, params: { name: 'list_modules', arguments: {} } },
247+
dir
248+
);
249+
const text = res.result.content[0].text;
250+
// Should mention files
251+
assert.ok(text.toLowerCase().includes('file') || text.includes('|'), 'Should have table structure with file info');
252+
});
253+
});
254+
255+
// ─────────────────────────────────────────────────────────────
256+
// list_modules — error case
257+
// ─────────────────────────────────────────────────────────────
258+
259+
test('list_modules returns error when no context file exists', () => {
260+
withTempProject((dir) => {
261+
// No seedContextFile
262+
const [res] = mcpCall(
263+
{ jsonrpc: '2.0', method: 'tools/call', id: 12, params: { name: 'list_modules', arguments: {} } },
264+
dir
265+
);
266+
assert.ok(res.result, 'Should have result');
267+
const text = res.result.content[0].text;
268+
assert.ok(text.length > 0, 'Should return non-empty message');
269+
assert.ok(!res.error, 'Should not be a protocol-level error');
270+
});
271+
});
272+
273+
// ─────────────────────────────────────────────────────────────
274+
// Multiple v1.4 tools in one session
275+
// ─────────────────────────────────────────────────────────────
276+
277+
console.log('\nMCP v1.4 — multi-call session\n');
278+
279+
test('explain_file and list_modules work in the same session', () => {
280+
withTempProject((dir) => {
281+
seedContextFile(dir);
282+
const responses = mcpCall(
283+
[
284+
{ jsonrpc: '2.0', method: 'tools/call', id: 1, params: { name: 'list_modules', arguments: {} } },
285+
{ jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'explain_file', arguments: { path: 'src/api/routes.js' } } },
286+
],
287+
dir
288+
);
289+
assert.strictEqual(responses.length, 2, 'Should get 2 responses');
290+
assert.strictEqual(responses[0].id, 1);
291+
assert.strictEqual(responses[1].id, 2);
292+
const modulesText = responses[0].result.content[0].text;
293+
const explainText = responses[1].result.content[0].text;
294+
assert.ok(modulesText.includes('src'), 'list_modules should list src');
295+
assert.ok(explainText.includes('getUser') || explainText.includes('routes'), 'explain_file should include routes signatures');
296+
});
297+
});
298+
299+
// ─────────────────────────────────────────────────────────────
300+
// Summary
301+
// ─────────────────────────────────────────────────────────────
302+
303+
console.log(`\n${'─'.repeat(50)}`);
304+
console.log(`mcp-v14: ${passed} passed, ${failed} failed`);
305+
if (failed > 0) process.exit(1);

0 commit comments

Comments
 (0)