v1.12 track B.2: codecontext tool wrappers + tests

This commit is contained in:
2026-05-21 13:35:44 +00:00
parent 4fae77e526
commit 136e9538aa
16 changed files with 1073 additions and 1 deletions

View File

@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { callCodecontext } from '../codecontext_client.js';
// ---- fixtures ---------------------------------------------------------------
let workDir: string;
let projectDir: string;
let outsideDir: string;
beforeEach(async () => {
// Shared workspace so projectDir and outsideDir are siblings but the
// realpath escape check still treats outsideDir as outside the project.
workDir = await mkdtemp(join(tmpdir(), 'codecontext-test-'));
projectDir = join(workDir, 'project');
outsideDir = join(workDir, 'outside');
await mkdir(projectDir);
await mkdir(outsideDir);
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
function mockJSONResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
// ---- tests ------------------------------------------------------------------
describe('callCodecontext — target_dir validation', () => {
it('rejects when target_dir does not exist', async () => {
const fetcher = vi.fn();
await expect(
callCodecontext(
{
toolName: 'get_codebase_overview',
args: { target_dir: '/nonexistent/path/deliberately/missing' },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/target_dir does not exist/);
expect(fetcher).not.toHaveBeenCalled();
});
it('rejects when target_dir is outside the project root', async () => {
const fetcher = vi.fn();
await expect(
callCodecontext(
{
toolName: 'get_codebase_overview',
args: { target_dir: outsideDir },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/escapes project root/);
expect(fetcher).not.toHaveBeenCalled();
});
it('injects projectPath as target_dir when args.target_dir is undefined', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'overview text', error: null }),
);
await callCodecontext(
{
toolName: 'get_codebase_overview',
args: { include_stats: true },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(fetcher).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
expect(body.target_dir).toBe(projectDir);
expect(body.include_stats).toBe(true);
});
});
describe('callCodecontext — HTTP request shape', () => {
it('POSTs to /v1/<toolName> with JSON content-type', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'ok', error: null }),
);
await callCodecontext(
{
toolName: 'search_symbols',
args: { query: 'User', limit: 5 },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(fetcher).toHaveBeenCalledTimes(1);
const [url, init] = fetcher.mock.calls[0]!;
expect(url).toMatch(/\/v1\/search_symbols$/);
expect(init.method).toBe('POST');
expect(init.headers['Content-Type']).toBe('application/json');
const body = JSON.parse(init.body);
expect(body).toMatchObject({ query: 'User', limit: 5, target_dir: projectDir });
});
});
describe('callCodecontext — result handling', () => {
it('returns { result, truncated: false } when codecontext result is under the 32 kB limit', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'a short markdown report', error: null }),
);
const out = await callCodecontext(
{
toolName: 'get_codebase_overview',
args: {},
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(out.truncated).toBe(false);
expect(out.result).toBe('a short markdown report');
});
it('truncates and marks truncated: true when result exceeds 32 kB', async () => {
const bigResult = 'x'.repeat(40_000);
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: bigResult, error: null }),
);
const out = await callCodecontext(
{
toolName: 'get_codebase_overview',
args: {},
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(out.truncated).toBe(true);
expect(out.result).toMatch(/\[truncated, 8000 chars omitted; narrow with file_path/);
expect(out.result.length).toBeLessThan(bigResult.length);
});
});
describe('callCodecontext — error paths', () => {
it('throws an actionable error when codecontext reports an empty-file parser failure', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({
result: null,
error:
'failed to refresh analysis: failed to analyze directory: ' +
'failed to parse file /opt/boolab/.opencode/node_modules/foo/index.js: content is empty',
}),
);
await expect(
callCodecontext(
{ toolName: 'get_codebase_overview', args: {}, projectPath: projectDir },
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/codecontext parse failure.*\.codecontextignore/);
});
it('throws a generic error when codecontext reports other errors', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: null, error: 'symbol_name is required' }),
);
await expect(
callCodecontext(
{ toolName: 'get_symbol_info', args: {}, projectPath: projectDir },
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/codecontext error: symbol_name is required/);
});
it('throws on HTTP non-2xx response', async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response('upstream gateway boom', { status: 502 }),
);
await expect(
callCodecontext(
{ toolName: 'get_codebase_overview', args: {}, projectPath: projectDir },
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/codecontext HTTP 502/);
});
it('translates a fetcher AbortError to a "timed out" error', async () => {
// The catch branch in callCodecontext maps any AbortError (whether it
// came from our internal 30s setTimeout or from the fetcher itself) to a
// "timed out" message. Exercising the catch directly is cleaner than
// wrangling vi.useFakeTimers with realpath's microtask scheduling.
const abortingFetcher = vi.fn().mockImplementation(() => {
const err = new Error('The user aborted a request.');
err.name = 'AbortError';
return Promise.reject(err);
});
await expect(
callCodecontext(
{ toolName: 'get_codebase_overview', args: {}, projectPath: projectDir },
abortingFetcher as unknown as typeof fetch,
),
).rejects.toThrow(/timed out after 30000ms/);
});
});

View File

@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { executeGetCodebaseOverview } from '../tools/codecontext/get_codebase_overview.js';
import { executeGetFileAnalysis } from '../tools/codecontext/get_file_analysis.js';
import { executeGetSymbolInfo } from '../tools/codecontext/get_symbol_info.js';
import { executeSearchSymbols } from '../tools/codecontext/search_symbols.js';
import { executeGetDependencies } from '../tools/codecontext/get_dependencies.js';
import { executeWatchChanges } from '../tools/codecontext/watch_changes.js';
import { executeGetSemanticNeighborhoods } from '../tools/codecontext/get_semantic_neighborhoods.js';
import { executeGetFrameworkAnalysis } from '../tools/codecontext/get_framework_analysis.js';
// ---- fixtures ---------------------------------------------------------------
let projectDir: string;
beforeEach(async () => {
projectDir = await mkdtemp(join(tmpdir(), 'codecontext-tools-test-'));
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
function mockJSONResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
// Stub fetcher that records every call and returns a canned successful body.
// Each test inspects fetcher.mock.calls[0] to assert URL + body shape.
function makeStub() {
return vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'wrapped ok', error: null }),
);
}
function parsePOST(fetcher: ReturnType<typeof makeStub>): {
url: string;
body: Record<string, unknown>;
} {
expect(fetcher).toHaveBeenCalledTimes(1);
const [url, init] = fetcher.mock.calls[0]! as [string, { body: string }];
return { url, body: JSON.parse(init.body) };
}
// ---- per-wrapper smoke tests -----------------------------------------------
describe('codecontext wrappers — toolName + args forwarding', () => {
it('get_codebase_overview posts to /v1/get_codebase_overview with include_stats default true', async () => {
const fetcher = makeStub();
await executeGetCodebaseOverview({}, projectDir, fetcher as unknown as typeof fetch);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_codebase_overview$/);
expect(body).toMatchObject({ include_stats: true, target_dir: projectDir });
});
it('get_file_analysis forwards file_path', async () => {
const fetcher = makeStub();
await executeGetFileAnalysis(
{ file_path: 'apps/server/src/index.ts' },
projectDir,
fetcher as unknown as typeof fetch,
);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_file_analysis$/);
expect(body).toMatchObject({
file_path: 'apps/server/src/index.ts',
target_dir: projectDir,
});
});
it('get_symbol_info forwards symbol_name and omits optional fields when unset', async () => {
const fetcher = makeStub();
await executeGetSymbolInfo(
{ symbol_name: 'buildSystemPrompt' },
projectDir,
fetcher as unknown as typeof fetch,
);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_symbol_info$/);
expect(body).toMatchObject({ symbol_name: 'buildSystemPrompt', target_dir: projectDir });
expect(body).not.toHaveProperty('file_path');
expect(body).not.toHaveProperty('framework_type');
});
it('search_symbols defaults limit to 20 and forwards filters when set', async () => {
const fetcher = makeStub();
await executeSearchSymbols(
{ query: 'User', symbol_type: 'class' },
projectDir,
fetcher as unknown as typeof fetch,
);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/search_symbols$/);
expect(body).toMatchObject({
query: 'User',
symbol_type: 'class',
limit: 20,
target_dir: projectDir,
});
});
it('get_dependencies defaults direction to "both"', async () => {
const fetcher = makeStub();
await executeGetDependencies({}, projectDir, fetcher as unknown as typeof fetch);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_dependencies$/);
expect(body).toMatchObject({ direction: 'both', target_dir: projectDir });
expect(body).not.toHaveProperty('file_path');
});
it('watch_changes forwards enable=false', async () => {
const fetcher = makeStub();
await executeWatchChanges(
{ enable: false },
projectDir,
fetcher as unknown as typeof fetch,
);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/watch_changes$/);
expect(body).toMatchObject({ enable: false, target_dir: projectDir });
});
it('get_semantic_neighborhoods defaults max_results to 10', async () => {
const fetcher = makeStub();
await executeGetSemanticNeighborhoods(
{},
projectDir,
fetcher as unknown as typeof fetch,
);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_semantic_neighborhoods$/);
expect(body).toMatchObject({ max_results: 10, target_dir: projectDir });
});
it('get_framework_analysis sends only target_dir when no args are provided', async () => {
const fetcher = makeStub();
await executeGetFrameworkAnalysis(
{},
projectDir,
fetcher as unknown as typeof fetch,
);
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_framework_analysis$/);
expect(body).toMatchObject({ target_dir: projectDir });
expect(body).not.toHaveProperty('framework');
expect(body).not.toHaveProperty('include_stats');
});
});