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