import { describe, expect, it, vi, afterEach } from 'vitest'; import { findOpencodeServer, parseModelRef, opencodeBaseUrls, opencodePrompt } from '../opencode'; /** * The single most important behaviour under test is the SPA-catch-all trap: * `opencode serve` answers 200 with the web UI's index.html for ANY unknown * path, so a probe that trusts `res.ok` "verifies" endpoints that do not * exist. That is not hypothetical — it is exactly how this integration was * first built wrong (against an assumed OpenAI-compatible `/v1/models` that * only ever returned HTML). */ const HTML_CATCHALL = { ok: true, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), json: async () => { throw new Error('not json'); }, }; function jsonResponse(body: unknown) { return { ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => body, }; } describe('parseModelRef', () => { it('splits providerID/modelID, keeping slashes inside the model id', () => { expect(parseModelRef('opencode/deepseek-v4-flash-free')).toEqual({ providerID: 'opencode', modelID: 'deepseek-v4-flash-free', }); // Real provider ids do contain slashes (e.g. openrouter's // "anthropic/claude-..."), so only the FIRST slash separates. expect(parseModelRef('openrouter/anthropic/claude-sonnet-4.5')).toEqual({ providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4.5', }); }); it('rejects malformed refs rather than guessing', () => { expect(parseModelRef('noslash')).toBeNull(); expect(parseModelRef('/leading')).toBeNull(); expect(parseModelRef('trailing/')).toBeNull(); expect(parseModelRef('')).toBeNull(); }); }); describe('opencodeBaseUrls', () => { const original = process.env.OPENCODE_BASE_URL; afterEach(() => { if (original === undefined) delete process.env.OPENCODE_BASE_URL; else process.env.OPENCODE_BASE_URL = original; }); it('refuses a non-loopback override — this class must never reach off-machine', () => { process.env.OPENCODE_BASE_URL = 'https://evil.example.com'; const urls = opencodeBaseUrls(); expect(urls.some((u) => u.includes('evil.example.com'))).toBe(false); expect(urls[0]).toMatch(/127\.0\.0\.1|localhost/); }); it('honours a loopback override, trying it first', () => { process.env.OPENCODE_BASE_URL = 'http://127.0.0.1:9999/'; expect(opencodeBaseUrls()[0]).toBe('http://127.0.0.1:9999'); }); }); describe('auth', () => { const originalFetch = global.fetch; const originalPw = process.env.OPENCODE_SERVER_PASSWORD; afterEach(() => { global.fetch = originalFetch; if (originalPw === undefined) delete process.env.OPENCODE_SERVER_PASSWORD; else process.env.OPENCODE_SERVER_PASSWORD = originalPw; vi.restoreAllMocks(); }); it('sends HTTP Basic with the username EXACTLY "opencode"', async () => { // Verified against 1.18.14: an empty or arbitrary username 401s even with // the right password, and no bearer/custom-header form works. Its OpenAPI // spec declares no securitySchemes, so this is only knowable by trying it // - which makes it exactly the kind of thing to pin with a test. process.env.OPENCODE_SERVER_PASSWORD = 'hunter2'; const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ id: 'm', providerID: 'p' }] })); global.fetch = fetchMock as unknown as typeof fetch; await findOpencodeServer(); const sentHeaders = fetchMock.mock.calls[0][1].headers as Record; const decoded = Buffer.from(sentHeaders.Authorization.replace('Basic ', ''), 'base64').toString(); expect(decoded).toBe('opencode:hunter2'); }); it('sends no auth header at all when no password is configured', async () => { delete process.env.OPENCODE_SERVER_PASSWORD; const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ id: 'm', providerID: 'p' }] })); global.fetch = fetchMock as unknown as typeof fetch; await findOpencodeServer(); const sentHeaders = fetchMock.mock.calls[0][1].headers as Record; expect(sentHeaders.Authorization).toBeUndefined(); }); }); describe('findOpencodeServer', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('does NOT accept the web UI catch-all as a working API (200 + HTML)', async () => { global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch; expect(await findOpencodeServer()).toBeNull(); }); it('parses the real /api/model shape into providerID/modelID refs', async () => { global.fetch = vi.fn().mockResolvedValue( jsonResponse({ data: [ { id: 'deepseek-v4-flash-free', providerID: 'opencode', name: 'DeepSeek V4 Flash Free' }, { id: 'deepseek-chat', providerID: 'deepseek' }, { id: '', providerID: 'broken' }, ], }), ) as unknown as typeof fetch; const found = await findOpencodeServer(); expect(found?.models.map((m) => m.ref)).toEqual([ 'opencode/deepseek-v4-flash-free', 'deepseek/deepseek-chat', ]); expect(found?.models[0].label).toBe('DeepSeek V4 Flash Free (opencode)'); }); it('returns null when the server answers JSON with no models', async () => { global.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: [] })) as unknown as typeof fetch; expect(await findOpencodeServer()).toBeNull(); }); }); describe('opencodePrompt', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('returns only the text parts — never the model\'s private reasoning', async () => { global.fetch = vi.fn() .mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' })) .mockResolvedValueOnce( jsonResponse({ parts: [ { type: 'step-start' }, { type: 'reasoning', text: 'SECRET chain of thought that must not be shown' }, { type: 'text', text: 'The visible answer.' }, { type: 'step-finish' }, ], }), ) as unknown as typeof fetch; const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'opencode', modelID: 'm' }, 'sys', 'q'); expect(result).toEqual({ ok: true, answer: 'The visible answer.' }); if (result.ok) expect(result.answer).not.toContain('SECRET'); }); it('fails cleanly when no session can be created', async () => { global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch; const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q'); expect(result.ok).toBe(false); }); it('fails cleanly when the reply carries no text part', async () => { global.fetch = vi.fn() .mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' })) .mockResolvedValueOnce(jsonResponse({ parts: [{ type: 'step-start' }, { type: 'reasoning', text: 'only thinking' }] })) as unknown as typeof fetch; const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q'); expect(result).toEqual({ ok: false, error: 'OpenCode returned no message content' }); }); }); describe('listOpencodeProviders', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('merges /provider and /provider/auth into one list, connected first', async () => { const fetchMock = vi.fn((url: string) => { if (url.endsWith('/provider')) { return Promise.resolve(jsonResponse({ all: [{ id: 'anthropic', name: 'Anthropic' }, { id: 'deepseek', name: 'DeepSeek' }, { id: 'github-copilot', name: 'GitHub Copilot' }], connected: ['deepseek'], })); } if (url.endsWith('/provider/auth')) { return Promise.resolve(jsonResponse({ anthropic: [{ type: 'api' }], deepseek: [{ type: 'api' }], 'github-copilot': [{ type: 'oauth' }], })); } return Promise.resolve(HTML_CATCHALL); }); global.fetch = fetchMock as unknown as typeof fetch; const { listOpencodeProviders } = await import('../opencode'); const result = await listOpencodeProviders('http://127.0.0.1:4096'); expect(result).toHaveLength(3); // Connected providers sort first regardless of name. expect(result[0]).toMatchObject({ id: 'deepseek', connected: true, supportsApiKey: true }); const anthropic = result.find((p) => p.id === 'anthropic'); expect(anthropic).toMatchObject({ connected: false, supportsApiKey: true }); const copilot = result.find((p) => p.id === 'github-copilot'); // OAuth-only provider: listed, but honestly marked as not addable here. expect(copilot).toMatchObject({ connected: false, supportsApiKey: false }); }); it('returns an empty list rather than throwing when /provider is unreachable', async () => { global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch; const { listOpencodeProviders } = await import('../opencode'); expect(await listOpencodeProviders('http://127.0.0.1:4096')).toEqual([]); }); }); describe('setOpencodeProviderKey / removeOpencodeProvider', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('PUTs the exact schema OpenCode requires: {type:"api", key}', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }); global.fetch = fetchMock as unknown as typeof fetch; const { setOpencodeProviderKey } = await import('../opencode'); await setOpencodeProviderKey('http://127.0.0.1:4096', 'anthropic', 'sk-real-key'); expect(fetchMock).toHaveBeenCalledWith( 'http://127.0.0.1:4096/auth/anthropic', expect.objectContaining({ method: 'PUT', body: JSON.stringify({ type: 'api', key: 'sk-real-key' }) }), ); }); it('throws with the upstream status when OpenCode rejects the credential', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 }) as unknown as typeof fetch; const { setOpencodeProviderKey } = await import('../opencode'); await expect(setOpencodeProviderKey('http://127.0.0.1:4096', 'anthropic', 'bad')).rejects.toThrow(/400/); }); it('DELETEs by provider id and encodes it in the path', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }); global.fetch = fetchMock as unknown as typeof fetch; const { removeOpencodeProvider } = await import('../opencode'); await removeOpencodeProvider('http://127.0.0.1:4096', 'weird id/with slash'); expect(fetchMock).toHaveBeenCalledWith( 'http://127.0.0.1:4096/auth/weird%20id%2Fwith%20slash', expect.objectContaining({ method: 'DELETE' }), ); }); });