為可測性 export 三個內部函式(isSafeRepoPath、extractBalancedJSON/extractJSONText、normalizeText)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
7.6 KiB
JavaScript
234 lines
7.6 KiB
JavaScript
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import axios from 'axios';
|
|
import { extractBalancedJSON, extractJSONText } from '../llm.js';
|
|
|
|
const ENV_KEYS = [
|
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
|
];
|
|
|
|
let saved = {};
|
|
beforeEach(() => {
|
|
saved = {};
|
|
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
});
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
mock.restoreAll();
|
|
});
|
|
|
|
function mockOpenCodeResponse(content) {
|
|
let calls = 0;
|
|
mock.method(axios, 'post', async () => {
|
|
calls += 1;
|
|
if (calls === 1) return { data: { id: 'ses_test' } };
|
|
return { data: { parts: [{ type: 'text', text: content }] } };
|
|
});
|
|
}
|
|
|
|
describe('chat - OpenCode', async () => {
|
|
const { chat } = await import('../llm.js');
|
|
|
|
it('uses OpenCode server session API', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
process.env.OPENCODE_PROVIDER = 'google';
|
|
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
|
const calls = [];
|
|
mock.method(axios, 'post', async (url, payload, opts) => {
|
|
calls.push({ url, payload, headers: opts.headers });
|
|
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
|
return { data: { parts: [{ type: 'text', text: 'opencode response' }] } };
|
|
});
|
|
|
|
const result = await chat('sys', 'user');
|
|
|
|
assert.equal(result, 'opencode response');
|
|
assert.equal(calls[0].url, 'http://opencode.local:4096/session');
|
|
assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' });
|
|
assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message');
|
|
assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' });
|
|
assert.equal(calls[1].payload.system, 'sys');
|
|
assert.deepEqual(calls[1].payload.parts, [{ type: 'text', text: 'user' }]);
|
|
assert.equal(calls[1].headers['Authorization'], undefined);
|
|
});
|
|
|
|
it('passes an insecure https agent to OpenCode by default', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
|
const agents = [];
|
|
mock.method(axios, 'post', async (url, _payload, opts) => {
|
|
agents.push(opts.httpsAgent);
|
|
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
|
return { data: { parts: [{ type: 'text', text: 'ok' }] } };
|
|
});
|
|
|
|
await chat('sys', 'user');
|
|
|
|
assert.equal(agents.length, 2);
|
|
assert.equal(agents[0].options.rejectUnauthorized, false);
|
|
assert.equal(agents[1].options.rejectUnauthorized, false);
|
|
});
|
|
|
|
it('extracts text from OpenCode message parts', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
let calls = 0;
|
|
mock.method(axios, 'post', async () => {
|
|
calls += 1;
|
|
if (calls === 1) return { data: { id: 'ses_test' } };
|
|
return { data: { parts: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } };
|
|
});
|
|
|
|
const result = await chat('sys', 'user');
|
|
|
|
assert.equal(result, 'hello world');
|
|
});
|
|
|
|
it('calls process.exit(1) when OpenCode fails', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
mock.method(axios, 'post', async () => { throw new Error('fail'); });
|
|
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); });
|
|
|
|
await assert.rejects(() => chat('sys', 'user'), /exit:1/);
|
|
|
|
assert.equal(exitMock.mock.calls[0].arguments[0], 1);
|
|
});
|
|
});
|
|
|
|
describe('chatJSON', async () => {
|
|
const { chatJSON } = await import('../llm.js');
|
|
|
|
it('parses plain JSON response', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
mockOpenCodeResponse('[{"level":"critical"}]');
|
|
|
|
const result = await chatJSON('sys', 'user');
|
|
|
|
assert.deepEqual(result, [{ level: 'critical' }]);
|
|
});
|
|
|
|
it('strips markdown code block before parsing', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
mockOpenCodeResponse('```json\n[{"level":"info"}]\n```');
|
|
|
|
const result = await chatJSON('sys', 'user');
|
|
|
|
assert.deepEqual(result, [{ level: 'info' }]);
|
|
});
|
|
|
|
it('extracts JSON array from surrounding prose', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
mockOpenCodeResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.');
|
|
|
|
const result = await chatJSON('sys', 'user');
|
|
|
|
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
|
|
});
|
|
|
|
it('extracts JSON object from surrounding prose', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
mockOpenCodeResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}');
|
|
|
|
const result = await chatJSON('sys', 'user');
|
|
|
|
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
|
|
});
|
|
|
|
it('returns [] when JSON is invalid', async () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
mockOpenCodeResponse('not json');
|
|
|
|
const result = await chatJSON('sys', 'user');
|
|
|
|
assert.deepEqual(result, []);
|
|
});
|
|
});
|
|
|
|
describe('extractBalancedJSON', () => {
|
|
it('returns the whole object for a simple object from index 0', () => {
|
|
const text = '{"a":1}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
|
});
|
|
|
|
it('returns the full balanced segment for deeply nested object/array', () => {
|
|
const text = '{"a":[1,{"b":[2,{"c":3}]}],"d":4}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":[1,{"b":[2,{"c":3}]}],"d":4}');
|
|
});
|
|
|
|
it('does not let braces inside a string value break balancing', () => {
|
|
const text = '{"a":"}{"}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"}{"}');
|
|
});
|
|
|
|
it('handles an escaped quote inside a string value', () => {
|
|
const text = '{"a":"\\""}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"\\""}');
|
|
});
|
|
|
|
it('returns null for truncated/incomplete JSON', () => {
|
|
const text = '{"a":1';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), null);
|
|
});
|
|
|
|
it('extracts a balanced array when starting at a "["', () => {
|
|
const text = '[1,[2,3],{"a":4}]';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '[1,[2,3],{"a":4}]');
|
|
});
|
|
|
|
it('excludes trailing content after the balanced segment', () => {
|
|
const text = '{"a":1} trailing text {"b":2}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
|
});
|
|
});
|
|
|
|
describe('extractJSONText', () => {
|
|
it('strips a fenced ```json block', () => {
|
|
const text = '```json\n{"a":1}\n```';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.deepEqual(JSON.parse(result), { a: 1 });
|
|
});
|
|
|
|
it('extracts a JSON object after leading prose', () => {
|
|
const text = 'Here are the findings:\n{"level":"critical"}';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.deepEqual(JSON.parse(result), { level: 'critical' });
|
|
});
|
|
|
|
it('extracts an array embedded in surrounding text', () => {
|
|
const text = 'prefix [1,2,3] suffix';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.deepEqual(JSON.parse(result), [1, 2, 3]);
|
|
});
|
|
|
|
it('returns an already-pure JSON string as-is', () => {
|
|
const text = '{"a":1,"b":[2,3]}';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.equal(result, '{"a":1,"b":[2,3]}');
|
|
assert.deepEqual(JSON.parse(result), { a: 1, b: [2, 3] });
|
|
});
|
|
|
|
it('returns the de-fenced original text when no valid JSON is found', () => {
|
|
const text = '```\nnot json at all\n```';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.equal(result, 'not json at all');
|
|
});
|
|
});
|