|
| 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