diff --git a/app/test/config.test.js b/app/test/config.test.js index 7139dae..942acc3 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; const ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', + 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', ]; let saved = {}; @@ -20,26 +20,35 @@ afterEach(() => { describe('getLLMConfig', () => { it('returns null provider when no env vars set', () => { - const cfg = getLLMConfig(); + const cfg = getLLMConfig({ commandExistsFn: () => false }); assert.equal(cfg.provider, null); assert.deepEqual(cfg.apiKeys, []); + assert.equal(cfg.command, null); }); - it('detects opencode server with defaults', () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'opencode'); - assert.deepEqual(cfg.apiKeys, ['opencode']); - assert.equal(cfg.baseURL, 'http://opencode.local:4096'); - assert.equal(cfg.model, 'gemini-2.5-flash'); + it('detects the first installed assistant CLI with defaults', () => { + const cfg = getLLMConfig({ commandExistsFn: command => command === 'claude' }); + assert.equal(cfg.provider, 'claude'); + assert.deepEqual(cfg.apiKeys, ['claude']); + assert.equal(cfg.baseURL, null); + assert.equal(cfg.command, 'claude'); + assert.equal(cfg.model, 'sonnet'); }); - it('detects opencode server with custom model', () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + it('uses MODEL for the selected assistant CLI', () => { + process.env.MODEL = 'gpt-5-mini'; + const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' }); + assert.equal(cfg.provider, 'codex'); + assert.equal(cfg.command, 'codex'); + assert.equal(cfg.model, 'gpt-5-mini'); + }); + + it('can force a CLI with AI_ASSISTANT_CLI', () => { + process.env.AI_ASSISTANT_CLI = 'opencode'; process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro'; - const cfg = getLLMConfig(); + const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === 'opencode' }); assert.equal(cfg.provider, 'opencode'); - assert.equal(cfg.baseURL, 'http://opencode.local:4096'); + assert.equal(cfg.command, 'opencode'); assert.equal(cfg.model, 'google/gemini-2.5-pro'); }); diff --git a/app/test/llm.test.js b/app/test/llm.test.js index ffb3e1e..5d7bd5c 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -1,133 +1,96 @@ import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import axios from 'axios'; +import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { extractBalancedJSON, extractJSONText } from '../llm.js'; const ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_RETRY_ATTEMPTS', + 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER', + 'FAKE_AI_STDOUT', 'FAKE_AI_STDERR', 'FAKE_AI_EXIT', 'FAKE_AI_STDIN_PATH', 'FAKE_AI_ARGS_PATH', ]; let saved = {}; +let tempDir; beforeEach(() => { saved = {}; for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + tempDir = null; }); -afterEach(() => { +afterEach(async () => { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } + if (tempDir) await rm(tempDir, { recursive: true, force: true }); 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 }] } }; - }); +async function installFakeCLI(command = 'codex') { + tempDir = await mkdtemp(join(tmpdir(), 'ai-cli-test-')); + const script = join(tempDir, command); + await writeFile(script, `#!/bin/sh +if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi +if [ -n "$FAKE_AI_STDIN_PATH" ]; then cat > "$FAKE_AI_STDIN_PATH"; else cat >/dev/null; fi +if [ -n "$FAKE_AI_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi +if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi +exit "\${FAKE_AI_EXIT:-0}" +`); + await chmod(script, 0o755); + process.env.PATH = `${tempDir}:${saved.PATH || ''}`; + return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') }; } -describe('chat - OpenCode', async () => { +describe('chat - assistant CLI', 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' }] } }; - }); + it('runs the detected CLI with MODEL and sends the prompts through stdin', async () => { + const { stdinPath, argsPath } = await installFakeCLI('codex'); + process.env.MODEL = 'gpt-5-mini'; + process.env.FAKE_AI_STDOUT = 'cli response'; + process.env.FAKE_AI_STDIN_PATH = stdinPath; + process.env.FAKE_AI_ARGS_PATH = argsPath; 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); + assert.equal(result, 'cli response'); + assert.match(await readFile(argsPath, 'utf8'), /exec --model gpt-5-mini/); + const prompt = await readFile(stdinPath, 'utf8'); + assert.match(prompt, /\nsys\n<\/system>/); + assert.match(prompt, /\nuser\n<\/user>/); }); - 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' }] } }; - }); + it('can force opencode with AI_ASSISTANT_CLI', async () => { + const { argsPath } = await installFakeCLI('opencode'); + process.env.AI_ASSISTANT_CLI = 'opencode'; + process.env.MODEL = 'google/gemini-2.5-pro'; + process.env.FAKE_AI_STDOUT = 'ok'; + process.env.FAKE_AI_ARGS_PATH = argsPath; const result = await chat('sys', 'user'); - assert.equal(result, 'hello world'); + assert.equal(result, 'ok'); + assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/); }); - it('throws an error when OpenCode fails instead of exiting the process', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_RETRY_ATTEMPTS = '1'; - mock.method(axios, 'post', async () => { - const err = new Error('Request failed with status code 500'); - err.response = { status: 500, data: { error: 'provider overloaded' } }; - throw err; - }); + it('throws an error when the CLI fails instead of exiting the process', async () => { + await installFakeCLI('codex'); + process.env.FAKE_AI_EXIT = '2'; + process.env.FAKE_AI_STDERR = 'provider overloaded'; const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); }); - await assert.rejects(() => chat('sys', 'user'), /HTTP 500.*provider overloaded/); + await assert.rejects(() => chat('sys', 'user'), /provider overloaded/); assert.equal(exitMock.mock.calls.length, 0); }); - - it('retries transient OpenCode failures before returning content', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_RETRY_ATTEMPTS = '2'; - let messageAttempts = 0; - mock.method(axios, 'post', async (url) => { - if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; - messageAttempts += 1; - if (messageAttempts === 1) { - const err = new Error('Request failed with status code 500'); - err.response = { status: 500, data: { error: 'temporary failure' } }; - throw err; - } - return { data: { parts: [{ type: 'text', text: 'ok after retry' }] } }; - }); - - const result = await chat('sys', 'user'); - - assert.equal(result, 'ok after retry'); - assert.equal(messageAttempts, 2); - }); }); 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"}]'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]'; const result = await chatJSON('sys', 'user'); @@ -135,8 +98,8 @@ describe('chatJSON', async () => { }); it('strips markdown code block before parsing', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - mockOpenCodeResponse('```json\n[{"level":"info"}]\n```'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```'; const result = await chatJSON('sys', 'user'); @@ -144,8 +107,8 @@ describe('chatJSON', async () => { }); 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.'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.'; const result = await chatJSON('sys', 'user'); @@ -153,8 +116,8 @@ describe('chatJSON', async () => { }); 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"}'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}'; const result = await chatJSON('sys', 'user'); @@ -162,8 +125,8 @@ describe('chatJSON', async () => { }); it('returns [] when JSON is invalid', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - mockOpenCodeResponse('not json'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = 'not json'; const result = await chatJSON('sys', 'user'); diff --git a/app/test/preflight.test.js b/app/test/preflight.test.js index 8d02e5e..4f6e4c6 100644 --- a/app/test/preflight.test.js +++ b/app/test/preflight.test.js @@ -1,21 +1,38 @@ import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import axios from 'axios'; +import { mkdtemp, writeFile, chmod, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js'; const LLM_ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', + 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', ]; +const ORIGINAL_PATH = process.env.PATH; function clearLLMEnv() { for (const k of LLM_ENV_KEYS) delete process.env[k]; } -afterEach(() => { +let tempDir; + +afterEach(async () => { mock.restoreAll(); clearLLMEnv(); + process.env.PATH = ORIGINAL_PATH; + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + tempDir = null; }); +async function installFakeCLI(command = 'codex') { + tempDir = await mkdtemp(join(tmpdir(), 'preflight-cli-test-')); + const script = join(tempDir, command); + await writeFile(script, '#!/bin/sh\nexit 0\n'); + await chmod(script, 0o755); + process.env.PATH = tempDir; +} + describe('checkRequiredEnv', () => { it('reports all three missing when nothing provided', () => { const result = checkRequiredEnv({ token: '', repo: '', pr: '' }); @@ -102,81 +119,40 @@ describe('verifyCommentToken', () => { }); describe('verifyLLM', () => { - it('fails when OpenCode is not configured', async () => { + it('fails when no supported assistant CLI is detected', async () => { clearLLMEnv(); + process.env.AI_ASSISTANT_CLI = 'no-such-ai-cli'; + process.env.PATH = ''; const result = await verifyLLM(); assert.equal(result.ok, false); - assert.match(result.error, /OPENCODE_BASE_URL/); + assert.match(result.error, /AI 助理 CLI/); }); - it('checks OpenCode server provider and model', async () => { + it('passes when a supported assistant CLI is detected', async () => { clearLLMEnv(); - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_PROVIDER = 'google'; - process.env.OPENCODE_MODEL = 'gemini-2.5-flash'; - const urls = []; - mock.method(axios, 'get', async (url) => { - urls.push(url); - if (url.endsWith('/global/health')) return { data: { healthy: true, version: '1.17.7' } }; - return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; - }); + await installFakeCLI('codex'); + process.env.AI_ASSISTANT_CLI = 'codex'; + process.env.MODEL = 'gpt-5-mini'; const result = await verifyLLM(); assert.equal(result.ok, true); - assert.equal(result.provider, 'opencode'); - assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']); + assert.equal(result.provider, 'codex'); + assert.equal(result.command, 'codex'); + assert.equal(result.model, 'gpt-5-mini'); }); - it('fails when configured provider is missing', async () => { + it('fails when a requested CLI is not installed', async () => { clearLLMEnv(); - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_PROVIDER = 'google'; - mock.method(axios, 'get', async (url) => { - if (url.endsWith('/global/health')) return { data: { healthy: true } }; - return { data: { providers: [{ id: 'anthropic', models: {} }] } }; - }); + process.env.AI_ASSISTANT_CLI = 'missing-cli'; + process.env.PATH = ''; const result = await verifyLLM(); assert.equal(result.ok, false); - assert.match(result.error, /未設定 provider=google/); - }); - - it('fails when configured model is missing', async () => { - clearLLMEnv(); - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_PROVIDER = 'google'; - process.env.OPENCODE_MODEL = 'gemini-2.5-pro'; - mock.method(axios, 'get', async (url) => { - if (url.endsWith('/global/health')) return { data: { healthy: true } }; - return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; - }); - - const result = await verifyLLM(); - - assert.equal(result.ok, false); - assert.match(result.error, /未列出 model=gemini-2.5-pro/); - }); - - it('passes an insecure https agent by default', async () => { - clearLLMEnv(); - process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; - const agents = []; - mock.method(axios, 'get', async (url, opts) => { - agents.push(opts.httpsAgent); - if (url.endsWith('/global/health')) return { data: { healthy: true } }; - return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; - }); - - const result = await verifyLLM(); - - assert.equal(result.ok, true); - assert.equal(agents.length, 2); - assert.equal(agents[0].options.rejectUnauthorized, false); - assert.equal(agents[1].options.rejectUnauthorized, false); + assert.match(result.error, /AI 助理 CLI/); }); }); @@ -188,7 +164,7 @@ describe('runPreflight', () => { verifyToken: async () => ({ ok: true }), verifyComment: async () => ({ ok: true }), verifyRemote: () => ({ ok: true }), - verifyLLMFn: async () => ({ ok: true, provider: 'opencode' }), + verifyLLMFn: async () => ({ ok: true, provider: 'codex' }), ...overrides, }; } @@ -239,7 +215,7 @@ describe('runPreflight', () => { it('returns false when LLM verification fails', async () => { const result = await runPreflight('/ws', makeDeps({ - verifyLLMFn: async () => ({ ok: false, error: 'OpenCode server 驗證失敗' }), + verifyLLMFn: async () => ({ ok: false, error: 'AI 助理 CLI 驗證失敗' }), })); assert.equal(result, false); });