206 lines
7.0 KiB
TypeScript
206 lines
7.0 KiB
TypeScript
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/);
|
|
});
|
|
});
|