Files
boocode/apps/server/src/services/__tests__/codecontext_client.test.ts

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/);
});
});