Skip to content

Commit 31640f3

Browse files
committed
test(mcp): add test suite for select:repo — schemas, transform, and tool
15 tests across 4 suites using node:test + tsx (no extra test framework): repoResultSchema (3) - valid parse, with optional repositoryInfo, missing required field searchResponseSchema with repoResults (3) - backward compat (no repoResults), with repoResults, invalid entry search_code hasModifiers transform (5) - detects select:, lang:, repo:; no false positives on plain text or partial words like selector: search_repos tool end-to-end via InMemoryTransport (4) - returns repo list, empty-results message, lang: filter appended, maxResults respected with total count in output Run: yarn workspace @sourcebot/mcp test
1 parent ab58387 commit 31640f3

File tree

1 file changed

+182
-0
lines changed

1 file changed

+182
-0
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Tests for the select:repo feature in the MCP server.
3+
*
4+
* Covers:
5+
* 1. repoResultSchema / searchResponseSchema validation
6+
* 2. The hasModifiers transform fix in search_code
7+
* 3. The search_repos tool end-to-end via InMemoryTransport
8+
*
9+
* Run with:
10+
* node --import tsx/esm --test src/__tests__/select-repo.test.ts
11+
*/
12+
13+
import { describe, it, before, after } from 'node:test';
14+
import assert from 'node:assert/strict';
15+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
16+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
17+
import { repoResultSchema, searchResponseSchema } from '../schemas.js';
18+
19+
// ---- helpers ----------------------------------------------------------------
20+
21+
function makeStats() {
22+
return {
23+
actualMatchCount: 0, totalMatchCount: 0, duration: 0, fileCount: 0,
24+
filesSkipped: 0, contentBytesLoaded: 0, indexBytesLoaded: 0, crashes: 0,
25+
shardFilesConsidered: 0, filesConsidered: 0, filesLoaded: 0,
26+
shardsScanned: 0, shardsSkipped: 0, shardsSkippedFilter: 0,
27+
ngramMatches: 0, ngramLookups: 0, wait: 0,
28+
matchTreeConstruction: 0, matchTreeSearch: 0,
29+
regexpsConsidered: 0, flushReason: 'none',
30+
};
31+
}
32+
33+
function makeSearchResponse(extra: Record<string, unknown> = {}) {
34+
return { stats: makeStats(), files: [], repositoryInfo: [], isSearchExhaustive: true, ...extra };
35+
}
36+
37+
function mockFetch(payload: unknown) {
38+
globalThis.fetch = async (_input: RequestInfo | URL, _init?: RequestInit) =>
39+
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
40+
}
41+
42+
function captureFetch(payload: unknown, onCall: (body: Record<string, unknown>) => void) {
43+
globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
44+
onCall(JSON.parse((init?.body as string) ?? '{}'));
45+
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
46+
};
47+
}
48+
49+
function getText(result: unknown): string {
50+
return (result as { content: Array<{ type: string; text: string }> }).content
51+
.map((c) => c.text).join('\n');
52+
}
53+
54+
// ---- 1. Schema validation ---------------------------------------------------
55+
56+
describe('repoResultSchema', () => {
57+
it('parses a valid RepoResult', () => {
58+
const r = repoResultSchema.safeParse({ repositoryId: 1, repository: 'github.com/acme/frontend', matchCount: 42 });
59+
assert.ok(r.success);
60+
assert.equal(r.data.matchCount, 42);
61+
});
62+
63+
it('parses a RepoResult with optional repositoryInfo', () => {
64+
const r = repoResultSchema.safeParse({
65+
repositoryId: 2, repository: 'github.com/acme/backend', matchCount: 7,
66+
repositoryInfo: { id: 2, codeHostType: 'github', name: 'acme/backend', webUrl: 'https://github.com/acme/backend' },
67+
});
68+
assert.ok(r.success);
69+
assert.equal(r.data.repositoryInfo?.webUrl, 'https://github.com/acme/backend');
70+
});
71+
72+
it('rejects a RepoResult missing matchCount', () => {
73+
const r = repoResultSchema.safeParse({ repositoryId: 1, repository: 'github.com/acme/x' });
74+
assert.ok(!r.success, 'should have failed');
75+
});
76+
});
77+
78+
describe('searchResponseSchema with repoResults', () => {
79+
it('accepts a response without repoResults (backward compat)', () => {
80+
const r = searchResponseSchema.safeParse(makeSearchResponse());
81+
assert.ok(r.success);
82+
assert.equal(r.data.repoResults, undefined);
83+
});
84+
85+
it('accepts a response with repoResults', () => {
86+
const r = searchResponseSchema.safeParse(makeSearchResponse({
87+
repoResults: [
88+
{ repositoryId: 1, repository: 'github.com/acme/a', matchCount: 10 },
89+
{ repositoryId: 2, repository: 'github.com/acme/b', matchCount: 3 },
90+
],
91+
}));
92+
assert.ok(r.success);
93+
assert.equal(r.data.repoResults?.length, 2);
94+
});
95+
96+
it('rejects repoResults with a missing required field', () => {
97+
const r = searchResponseSchema.safeParse(makeSearchResponse({
98+
repoResults: [{ repositoryId: 1, repository: 'github.com/x' }],
99+
}));
100+
assert.ok(!r.success, 'should have failed');
101+
});
102+
});
103+
104+
// ---- 2. hasModifiers transform logic ----------------------------------------
105+
106+
describe('search_code query transform — hasModifiers regex', () => {
107+
const RE = /(?:^|\s)(?:select|repo|lang|file|case|rev|branch|sym|content):/;
108+
109+
it('detects select:repo modifier', () => assert.ok(RE.test('useState select:repo')));
110+
it('detects lang: modifier', () => assert.ok(RE.test('function lang:TypeScript')));
111+
it('detects repo: at start', () => assert.ok(RE.test('repo:acme/frontend useState')));
112+
it('does not false-positive on plain text', () => {
113+
assert.ok(!RE.test('useState hook'));
114+
assert.ok(!RE.test('async function fetch'));
115+
});
116+
it('does not match partial words (selector:hover)', () => assert.ok(!RE.test('selector:hover')));
117+
});
118+
119+
// ---- 3. search_repos tool (end-to-end) --------------------------------------
120+
121+
describe('search_repos tool', () => {
122+
let client: Client;
123+
let savedFetch: typeof globalThis.fetch;
124+
125+
before(async () => {
126+
savedFetch = globalThis.fetch;
127+
process.env.SOURCEBOT_HOST = 'http://localhost:3000';
128+
process.env.SOURCEBOT_API_KEY = 'test-key';
129+
130+
// Dynamic import so env vars are set first
131+
const { server } = await import('../index.js');
132+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
133+
await server.connect(serverTransport);
134+
client = new Client({ name: 'test-client', version: '0.0.1' });
135+
await client.connect(clientTransport);
136+
});
137+
138+
after(async () => {
139+
await client?.close();
140+
globalThis.fetch = savedFetch;
141+
});
142+
143+
it('returns repo list from API repoResults', async () => {
144+
mockFetch(makeSearchResponse({
145+
repoResults: [
146+
{ repositoryId: 1, repository: 'github.com/acme/frontend', matchCount: 20 },
147+
{ repositoryId: 2, repository: 'github.com/acme/backend', matchCount: 5 },
148+
],
149+
}));
150+
const text = getText(await client.callTool({ name: 'search_repos', arguments: { query: 'useState' } }));
151+
assert.ok(text.includes('github.com/acme/frontend'));
152+
assert.ok(text.includes('github.com/acme/backend'));
153+
assert.ok(text.includes('matches: 20'));
154+
});
155+
156+
it('returns no-results message when repoResults is empty', async () => {
157+
mockFetch(makeSearchResponse({ repoResults: [] }));
158+
const text = getText(await client.callTool({ name: 'search_repos', arguments: { query: 'nonExistentSymbol' } }));
159+
assert.ok(text.toLowerCase().includes('no repositories'));
160+
});
161+
162+
it('appends select:repo and lang: filters to the query', async () => {
163+
let captured = '';
164+
captureFetch(makeSearchResponse({ repoResults: [] }), (body) => { captured = body.query as string; });
165+
await client.callTool({ name: 'search_repos', arguments: { query: 'useState', filterByLanguages: ['TypeScript', 'JavaScript'] } });
166+
assert.ok(captured.includes('lang:TypeScript'), `query: ${captured}`);
167+
assert.ok(captured.includes('lang:JavaScript'), `query: ${captured}`);
168+
assert.ok(captured.includes('select:repo'), `query: ${captured}`);
169+
});
170+
171+
it('respects maxResults limit', async () => {
172+
const repos = Array.from({ length: 10 }, (_, i) => ({
173+
repositoryId: i, repository: `github.com/acme/repo-${i}`, matchCount: 10 - i,
174+
}));
175+
mockFetch(makeSearchResponse({ repoResults: repos }));
176+
const text = getText(await client.callTool({ name: 'search_repos', arguments: { query: 'test', maxResults: 3 } }));
177+
assert.ok(text.includes('10 repositor'), `missing total: ${text}`);
178+
assert.ok(text.includes('top 3'), `missing limit notice: ${text}`);
179+
const lines = text.split('\n').filter((l: string) => l.startsWith('repo:'));
180+
assert.equal(lines.length, 3);
181+
});
182+
});

0 commit comments

Comments
 (0)