| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| import { |
| OpencodeProvider, |
| resetToolUseIdCounter, |
| } from '../../../src/providers/opencode-provider.js'; |
| import type { ProviderMessage, ModelDefinition } from '@automaker/types'; |
| import { collectAsyncGenerator } from '../../utils/helpers.js'; |
| import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform'; |
|
|
| vi.mock('@automaker/platform', () => ({ |
| spawnJSONLProcess: vi.fn(), |
| isWslAvailable: vi.fn().mockReturnValue(false), |
| findCliInWsl: vi.fn().mockReturnValue(null), |
| createWslCommand: vi.fn(), |
| windowsToWslPath: vi.fn(), |
| getOpenCodeAuthIndicators: vi.fn().mockResolvedValue({ |
| hasAuthFile: false, |
| hasOAuthToken: false, |
| hasApiKey: false, |
| }), |
| })); |
|
|
| describe('opencode-provider.ts', () => { |
| let provider: OpencodeProvider; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| resetToolUseIdCounter(); |
| provider = new OpencodeProvider(); |
| }); |
|
|
| afterEach(() => { |
| |
| |
| }); |
|
|
| |
| |
| |
|
|
| describe('getName', () => { |
| it("should return 'opencode' as provider name", () => { |
| expect(provider.getName()).toBe('opencode'); |
| }); |
| }); |
|
|
| describe('getCliName', () => { |
| it("should return 'opencode' as CLI name", () => { |
| expect(provider.getCliName()).toBe('opencode'); |
| }); |
| }); |
|
|
| describe('getAvailableModels', () => { |
| it('should return 5 models', () => { |
| const models = provider.getAvailableModels(); |
| expect(models).toHaveLength(5); |
| }); |
|
|
| it('should include Big Pickle as default', () => { |
| const models = provider.getAvailableModels(); |
| const bigPickle = models.find((m) => m.id === 'opencode/big-pickle'); |
|
|
| expect(bigPickle).toBeDefined(); |
| expect(bigPickle?.name).toBe('Big Pickle (Free)'); |
| expect(bigPickle?.provider).toBe('opencode'); |
| expect(bigPickle?.default).toBe(true); |
| expect(bigPickle?.modelString).toBe('opencode/big-pickle'); |
| }); |
|
|
| it('should include free tier GLM model', () => { |
| const models = provider.getAvailableModels(); |
| const glm = models.find((m) => m.id === 'opencode/glm-5-free'); |
|
|
| expect(glm).toBeDefined(); |
| expect(glm?.name).toBe('GLM 5 Free'); |
| expect(glm?.tier).toBe('basic'); |
| }); |
|
|
| it('should include free tier MiniMax model', () => { |
| const models = provider.getAvailableModels(); |
| const minimax = models.find((m) => m.id === 'opencode/minimax-m2.5-free'); |
|
|
| expect(minimax).toBeDefined(); |
| expect(minimax?.name).toBe('MiniMax M2.5 Free'); |
| expect(minimax?.tier).toBe('basic'); |
| }); |
|
|
| it('should have all models support tools', () => { |
| const models = provider.getAvailableModels(); |
|
|
| models.forEach((model) => { |
| expect(model.supportsTools).toBe(true); |
| }); |
| }); |
|
|
| it('should have models with modelString property', () => { |
| const models = provider.getAvailableModels(); |
|
|
| for (const model of models) { |
| expect(model).toHaveProperty('modelString'); |
| expect(typeof model.modelString).toBe('string'); |
| } |
| }); |
| }); |
|
|
| describe('parseModelsOutput', () => { |
| it('should parse nested provider model IDs', () => { |
| const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n'); |
|
|
| const parseModelsOutput = ( |
| provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] } |
| ).parseModelsOutput.bind(provider); |
| const models = parseModelsOutput(output); |
|
|
| expect(models).toHaveLength(2); |
| const openrouterModel = models.find((model) => model.id.startsWith('openrouter/')); |
|
|
| expect(openrouterModel).toBeDefined(); |
| expect(openrouterModel?.provider).toBe('openrouter'); |
| expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet'); |
| }); |
| }); |
|
|
| describe('supportsFeature', () => { |
| it("should support 'tools' feature", () => { |
| expect(provider.supportsFeature('tools')).toBe(true); |
| }); |
|
|
| it("should support 'text' feature", () => { |
| expect(provider.supportsFeature('text')).toBe(true); |
| }); |
|
|
| it("should support 'vision' feature", () => { |
| expect(provider.supportsFeature('vision')).toBe(true); |
| }); |
|
|
| it("should not support 'thinking' feature", () => { |
| expect(provider.supportsFeature('thinking')).toBe(false); |
| }); |
|
|
| it("should not support 'mcp' feature", () => { |
| expect(provider.supportsFeature('mcp')).toBe(false); |
| }); |
|
|
| it("should not support 'cli' feature", () => { |
| expect(provider.supportsFeature('cli')).toBe(false); |
| }); |
|
|
| it('should return false for unknown features', () => { |
| expect(provider.supportsFeature('unknown-feature')).toBe(false); |
| expect(provider.supportsFeature('nonexistent')).toBe(false); |
| expect(provider.supportsFeature('')).toBe(false); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('buildCliArgs', () => { |
| it('should build correct args with run subcommand', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Hello', |
| model: 'opencode/big-pickle', |
| cwd: '/tmp/project', |
| }); |
|
|
| expect(args[0]).toBe('run'); |
| }); |
|
|
| it('should include --format json for streaming output', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Hello', |
| model: 'opencode/big-pickle', |
| cwd: '/tmp/project', |
| }); |
|
|
| const formatIndex = args.indexOf('--format'); |
| expect(formatIndex).toBeGreaterThan(-1); |
| expect(args[formatIndex + 1]).toBe('json'); |
| }); |
|
|
| it('should include model with --model flag', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Hello', |
| model: 'anthropic/claude-sonnet-4-5', |
| cwd: '/tmp/project', |
| }); |
|
|
| const modelIndex = args.indexOf('--model'); |
| expect(modelIndex).toBeGreaterThan(-1); |
| expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); |
| }); |
|
|
| it('should strip opencode- prefix from model', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Hello', |
| model: 'opencode-anthropic/claude-sonnet-4-5', |
| cwd: '/tmp/project', |
| }); |
|
|
| const modelIndex = args.indexOf('--model'); |
| expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); |
| }); |
|
|
| it('should handle missing cwd', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Hello', |
| model: 'opencode/big-pickle', |
| }); |
|
|
| expect(args).not.toContain('-c'); |
| }); |
|
|
| it('should handle model from opencode provider', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Hello', |
| model: 'opencode/big-pickle', |
| cwd: '/tmp/project', |
| }); |
|
|
| expect(args).toContain('--model'); |
| expect(args).toContain('opencode/big-pickle'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('normalizeEvent', () => { |
| describe('text events (new OpenCode format)', () => { |
| it('should convert text to assistant message with text content', () => { |
| const event = { |
| type: 'text', |
| part: { |
| type: 'text', |
| text: 'Hello, world!', |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toEqual({ |
| type: 'assistant', |
| session_id: 'test-session', |
| message: { |
| role: 'assistant', |
| content: [ |
| { |
| type: 'text', |
| text: 'Hello, world!', |
| }, |
| ], |
| }, |
| }); |
| }); |
|
|
| it('should return null for empty text', () => { |
| const event = { |
| type: 'text', |
| part: { |
| type: 'text', |
| text: '', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toBeNull(); |
| }); |
|
|
| it('should return null for text with undefined text', () => { |
| const event = { |
| type: 'text', |
| part: {}, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toBeNull(); |
| }); |
| }); |
|
|
| describe('tool_call events', () => { |
| it('should convert tool_call to assistant message with tool_use content', () => { |
| const event = { |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| call_id: 'call-123', |
| name: 'Read', |
| args: { file_path: '/tmp/test.txt' }, |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toEqual({ |
| type: 'assistant', |
| session_id: 'test-session', |
| message: { |
| role: 'assistant', |
| content: [ |
| { |
| type: 'tool_use', |
| name: 'Read', |
| tool_use_id: 'call-123', |
| input: { file_path: '/tmp/test.txt' }, |
| }, |
| ], |
| }, |
| }); |
| }); |
|
|
| it('should generate tool_use_id when call_id is missing', () => { |
| const event = { |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| name: 'Write', |
| args: { content: 'test' }, |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].type).toBe('tool_use'); |
| expect(result?.message?.content[0].tool_use_id).toBe('opencode-tool-1'); |
|
|
| |
| const result2 = provider.normalizeEvent({ |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| name: 'Edit', |
| args: {}, |
| }, |
| }); |
| expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2'); |
| }); |
| }); |
|
|
| describe('tool_result events', () => { |
| it('should convert tool_result to assistant message with tool_result content', () => { |
| const event = { |
| type: 'tool_result', |
| part: { |
| type: 'tool-result', |
| call_id: 'call-123', |
| output: 'File contents here', |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toEqual({ |
| type: 'assistant', |
| session_id: 'test-session', |
| message: { |
| role: 'assistant', |
| content: [ |
| { |
| type: 'tool_result', |
| tool_use_id: 'call-123', |
| content: 'File contents here', |
| }, |
| ], |
| }, |
| }); |
| }); |
|
|
| it('should handle tool_result without call_id', () => { |
| const event = { |
| type: 'tool_result', |
| part: { |
| type: 'tool-result', |
| output: 'Result without ID', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].type).toBe('tool_result'); |
| expect(result?.message?.content[0].tool_use_id).toBeUndefined(); |
| }); |
| }); |
|
|
| describe('tool_error events', () => { |
| it('should convert tool_error to error message', () => { |
| const event = { |
| type: 'tool_error', |
| part: { |
| type: 'tool-error', |
| call_id: 'call-123', |
| error: 'File not found', |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toEqual({ |
| type: 'error', |
| session_id: 'test-session', |
| error: 'File not found', |
| }); |
| }); |
|
|
| it('should provide default error message when error is missing', () => { |
| const event = { |
| type: 'tool_error', |
| part: { |
| type: 'tool-error', |
| call_id: 'call-123', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.type).toBe('error'); |
| expect(result?.error).toBe('Tool execution failed'); |
| }); |
| }); |
|
|
| describe('step_start events', () => { |
| it('should return null for step_start events (informational)', () => { |
| const event = { |
| type: 'step_start', |
| part: { |
| type: 'step-start', |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toBeNull(); |
| }); |
| }); |
|
|
| describe('step_finish events', () => { |
| it('should convert successful step_finish to result message', () => { |
| const event = { |
| type: 'step_finish', |
| part: { |
| type: 'step-finish', |
| reason: 'stop', |
| result: 'Task completed successfully', |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toEqual({ |
| type: 'result', |
| subtype: 'success', |
| session_id: 'test-session', |
| result: 'Task completed successfully', |
| }); |
| }); |
|
|
| it('should convert step_finish with error to error message', () => { |
| const event = { |
| type: 'step_finish', |
| part: { |
| type: 'step-finish', |
| reason: 'error', |
| error: 'Something went wrong', |
| }, |
| sessionID: 'test-session', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toEqual({ |
| type: 'error', |
| session_id: 'test-session', |
| error: 'Something went wrong', |
| }); |
| }); |
|
|
| it('should convert step_finish with error property to error message', () => { |
| const event = { |
| type: 'step_finish', |
| part: { |
| type: 'step-finish', |
| error: 'Process failed', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.type).toBe('error'); |
| expect(result?.error).toBe('Process failed'); |
| }); |
|
|
| it('should provide default error message for failed step without error text', () => { |
| const event = { |
| type: 'step_finish', |
| part: { |
| type: 'step-finish', |
| reason: 'error', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.type).toBe('error'); |
| expect(result?.error).toBe('Step execution failed'); |
| }); |
|
|
| it('should treat step_finish with reason=stop as success', () => { |
| const event = { |
| type: 'step_finish', |
| part: { |
| type: 'step-finish', |
| reason: 'stop', |
| result: 'Done', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.type).toBe('result'); |
| expect(result?.subtype).toBe('success'); |
| }); |
| }); |
|
|
| describe('unknown events', () => { |
| it('should return null for unknown event types', () => { |
| const event = { |
| type: 'unknown-event', |
| data: 'some data', |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result).toBeNull(); |
| }); |
|
|
| it('should return null for null input', () => { |
| const result = provider.normalizeEvent(null); |
| expect(result).toBeNull(); |
| }); |
|
|
| it('should return null for undefined input', () => { |
| const result = provider.normalizeEvent(undefined); |
| expect(result).toBeNull(); |
| }); |
|
|
| it('should return null for non-object input', () => { |
| expect(provider.normalizeEvent('string')).toBeNull(); |
| expect(provider.normalizeEvent(123)).toBeNull(); |
| expect(provider.normalizeEvent(true)).toBeNull(); |
| }); |
|
|
| it('should return null for events without type', () => { |
| expect(provider.normalizeEvent({})).toBeNull(); |
| expect(provider.normalizeEvent({ data: 'no type' })).toBeNull(); |
| }); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('executeQuery', () => { |
| |
| |
| |
| |
| function setupMockedProvider(): OpencodeProvider { |
| const mockedProvider = new OpencodeProvider(); |
| |
| (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; |
| (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
| return mockedProvider; |
| } |
|
|
| it('should stream text events as assistant messages', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| const mockEvents = [ |
| { type: 'text', part: { type: 'text', text: 'Hello ' } }, |
| { type: 'text', part: { type: 'text', text: 'World!' } }, |
| ]; |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue( |
| (async function* () { |
| for (const event of mockEvents) { |
| yield event; |
| } |
| })() |
| ); |
|
|
| const results = await collectAsyncGenerator<ProviderMessage>( |
| mockedProvider.executeQuery({ |
| prompt: 'Say hello', |
| model: 'anthropic/claude-sonnet-4-5', |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| expect(results).toHaveLength(2); |
| expect(results[0].type).toBe('assistant'); |
| expect(results[0].message?.content[0].text).toBe('Hello '); |
| expect(results[1].message?.content[0].text).toBe('World!'); |
| }); |
|
|
| it('should emit tool_use and tool_result with matching IDs', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| const mockEvents = [ |
| { |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| call_id: 'tool-1', |
| name: 'Read', |
| args: { file_path: '/tmp/test.txt' }, |
| }, |
| }, |
| { |
| type: 'tool_result', |
| part: { |
| type: 'tool-result', |
| call_id: 'tool-1', |
| output: 'File contents', |
| }, |
| }, |
| ]; |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue( |
| (async function* () { |
| for (const event of mockEvents) { |
| yield event; |
| } |
| })() |
| ); |
|
|
| const results = await collectAsyncGenerator<ProviderMessage>( |
| mockedProvider.executeQuery({ |
| prompt: 'Read a file', |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| expect(results).toHaveLength(2); |
|
|
| const toolUse = results[0]; |
| const toolResult = results[1]; |
|
|
| expect(toolUse.type).toBe('assistant'); |
| expect(toolUse.message?.content[0].type).toBe('tool_use'); |
| expect(toolUse.message?.content[0].tool_use_id).toBe('tool-1'); |
|
|
| expect(toolResult.type).toBe('assistant'); |
| expect(toolResult.message?.content[0].type).toBe('tool_result'); |
| expect(toolResult.message?.content[0].tool_use_id).toBe('tool-1'); |
| }); |
|
|
| it('should pass stdinData containing the prompt', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: 'My test prompt', |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.stdinData).toBe('My test prompt'); |
| }); |
|
|
| it('should extract text from array prompt', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| const arrayPrompt = [ |
| { type: 'text', text: 'First part' }, |
| { type: 'image', source: { type: 'base64', data: '...' } }, |
| { type: 'text', text: 'Second part' }, |
| ]; |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: arrayPrompt as unknown as string, |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.stdinData).toBe('First part\nSecond part'); |
| }); |
|
|
| it('should include correct CLI args in subprocess options', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: 'Test', |
| model: 'opencode-anthropic/claude-opus-4-5', |
| cwd: '/tmp/workspace', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.args).toContain('run'); |
| expect(call.args).toContain('--format'); |
| expect(call.args).toContain('json'); |
| expect(call.args).toContain('--model'); |
| expect(call.args).toContain('anthropic/claude-opus-4-5'); |
| }); |
|
|
| it('should skip null-normalized events', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| const mockEvents = [ |
| { type: 'unknown-internal-event', data: 'ignored' }, |
| { type: 'text', part: { type: 'text', text: 'Valid text' } }, |
| { type: 'another-unknown', foo: 'bar' }, |
| { type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } }, |
| ]; |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue( |
| (async function* () { |
| for (const event of mockEvents) { |
| yield event; |
| } |
| })() |
| ); |
|
|
| const results = await collectAsyncGenerator<ProviderMessage>( |
| mockedProvider.executeQuery({ |
| prompt: 'Test', |
| model: 'opencode/big-pickle', |
| cwd: '/test', |
| }) |
| ); |
|
|
| |
| expect(results.length).toBe(2); |
| }); |
|
|
| it('should throw error when CLI is not installed', async () => { |
| |
| |
| const unmockedProvider = new OpencodeProvider(); |
| (unmockedProvider as unknown as { cliPath: string | null }).cliPath = null; |
| (unmockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; |
|
|
| await expect( |
| collectAsyncGenerator( |
| unmockedProvider.executeQuery({ |
| prompt: 'Test', |
| cwd: '/test', |
| }) |
| ) |
| ).rejects.toThrow(/CLI not found/); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('getSpawnConfig', () => { |
| it('should return npx as Windows strategy', () => { |
| const config = provider.getSpawnConfig(); |
| expect(config.windowsStrategy).toBe('npx'); |
| }); |
|
|
| it('should specify opencode-ai@latest as npx package', () => { |
| const config = provider.getSpawnConfig(); |
| expect(config.npxPackage).toBe('opencode-ai@latest'); |
| }); |
|
|
| it('should include common paths for Linux', () => { |
| const config = provider.getSpawnConfig(); |
| const linuxPaths = config.commonPaths['linux']; |
|
|
| expect(linuxPaths).toBeDefined(); |
| expect(linuxPaths.length).toBeGreaterThan(0); |
| expect(linuxPaths.some((p) => p.includes('opencode'))).toBe(true); |
| }); |
|
|
| it('should include common paths for macOS', () => { |
| const config = provider.getSpawnConfig(); |
| const darwinPaths = config.commonPaths['darwin']; |
|
|
| expect(darwinPaths).toBeDefined(); |
| expect(darwinPaths.length).toBeGreaterThan(0); |
| expect(darwinPaths.some((p) => p.includes('homebrew'))).toBe(true); |
| }); |
|
|
| it('should include common paths for Windows', () => { |
| const config = provider.getSpawnConfig(); |
| const win32Paths = config.commonPaths['win32']; |
|
|
| expect(win32Paths).toBeDefined(); |
| expect(win32Paths.length).toBeGreaterThan(0); |
| expect(win32Paths.some((p) => p.includes('npm'))).toBe(true); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('detectInstallation', () => { |
| beforeEach(() => { |
| |
| vi.mocked(getOpenCodeAuthIndicators).mockResolvedValue({ |
| hasAuthFile: false, |
| hasOAuthToken: false, |
| hasApiKey: false, |
| }); |
| }); |
|
|
| it('should return installed true when CLI is found', async () => { |
| (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode'; |
| (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
|
|
| const result = await provider.detectInstallation(); |
|
|
| expect(result.installed).toBe(true); |
| expect(result.path).toBe('/usr/local/bin/opencode'); |
| }); |
|
|
| it('should return installed false when CLI is not found', async () => { |
| |
| |
| (provider as unknown as { cliPath: string | null }).cliPath = null; |
| (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; |
|
|
| const result = await provider.detectInstallation(); |
|
|
| expect(result.installed).toBe(false); |
| }); |
|
|
| it('should return method as npm when using npx strategy', async () => { |
| (provider as unknown as { cliPath: string }).cliPath = 'npx'; |
| (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; |
|
|
| const result = await provider.detectInstallation(); |
|
|
| expect(result.method).toBe('npm'); |
| }); |
|
|
| it('should return method as cli when using native strategy', async () => { |
| (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode'; |
| (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
|
|
| const result = await provider.detectInstallation(); |
|
|
| expect(result.method).toBe('cli'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('config management', () => { |
| it('should get and set config', () => { |
| provider.setConfig({ apiKey: 'test-api-key' }); |
|
|
| const config = provider.getConfig(); |
| expect(config.apiKey).toBe('test-api-key'); |
| }); |
|
|
| it('should merge config updates', () => { |
| provider.setConfig({ apiKey: 'key1' }); |
| provider.setConfig({ model: 'model1' }); |
|
|
| const config = provider.getConfig(); |
| expect(config.apiKey).toBe('key1'); |
| expect(config.model).toBe('model1'); |
| }); |
| }); |
|
|
| describe('validateConfig', () => { |
| it('should validate config from base class', () => { |
| const result = provider.validateConfig(); |
|
|
| expect(result.valid).toBe(true); |
| expect(result.errors).toHaveLength(0); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('extractPromptText edge cases', () => { |
| function setupMockedProvider(): OpencodeProvider { |
| const mockedProvider = new OpencodeProvider(); |
| (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; |
| (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
| return mockedProvider; |
| } |
|
|
| it('should handle empty array prompt', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: [] as unknown as string, |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.stdinData).toBe(''); |
| }); |
|
|
| it('should handle array prompt with only image blocks (no text)', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| const imageOnlyPrompt = [ |
| { type: 'image', source: { type: 'base64', data: 'abc123' } }, |
| { type: 'image', source: { type: 'base64', data: 'def456' } }, |
| ]; |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: imageOnlyPrompt as unknown as string, |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.stdinData).toBe(''); |
| }); |
|
|
| it('should handle array prompt with mixed content types', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| const mixedPrompt = [ |
| { type: 'text', text: 'Analyze this image' }, |
| { type: 'image', source: { type: 'base64', data: 'abc123' } }, |
| { type: 'text', text: 'And this one' }, |
| { type: 'image', source: { type: 'base64', data: 'def456' } }, |
| { type: 'text', text: 'What differences do you see?' }, |
| ]; |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: mixedPrompt as unknown as string, |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.stdinData).toBe('Analyze this image\nAnd this one\nWhat differences do you see?'); |
| }); |
|
|
| it('should handle text blocks with empty text property', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| const promptWithEmptyText = [ |
| { type: 'text', text: 'Hello' }, |
| { type: 'text', text: '' }, |
| { type: 'text', text: 'World' }, |
| ]; |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: promptWithEmptyText as unknown as string, |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| |
| expect(call.stdinData).toBe('Hello\nWorld'); |
| }); |
| }); |
|
|
| describe('abort handling', () => { |
| function setupMockedProvider(): OpencodeProvider { |
| const mockedProvider = new OpencodeProvider(); |
| (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; |
| (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
| return mockedProvider; |
| } |
|
|
| it('should pass abortController to subprocess options', async () => { |
| const mockedProvider = setupMockedProvider(); |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); |
|
|
| const abortController = new AbortController(); |
|
|
| await collectAsyncGenerator( |
| mockedProvider.executeQuery({ |
| prompt: 'Test', |
| cwd: '/tmp', |
| abortController, |
| }) |
| ); |
|
|
| const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; |
| expect(call.abortController).toBe(abortController); |
| }); |
| }); |
|
|
| describe('session_id preservation', () => { |
| function setupMockedProvider(): OpencodeProvider { |
| const mockedProvider = new OpencodeProvider(); |
| (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; |
| (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
| return mockedProvider; |
| } |
|
|
| it('should preserve session_id through the full executeQuery flow', async () => { |
| const mockedProvider = setupMockedProvider(); |
| const sessionId = 'test-session-123'; |
|
|
| const mockEvents = [ |
| { type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId }, |
| { |
| type: 'tool_call', |
| part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' }, |
| sessionID: sessionId, |
| }, |
| { |
| type: 'tool_result', |
| part: { type: 'tool-result', call_id: 'c1', output: 'file content' }, |
| sessionID: sessionId, |
| }, |
| { |
| type: 'step_finish', |
| part: { type: 'step-finish', reason: 'stop', result: 'Done' }, |
| sessionID: sessionId, |
| }, |
| ]; |
|
|
| vi.mocked(spawnJSONLProcess).mockReturnValue( |
| (async function* () { |
| for (const event of mockEvents) { |
| yield event; |
| } |
| })() |
| ); |
|
|
| const results = await collectAsyncGenerator<ProviderMessage>( |
| mockedProvider.executeQuery({ |
| prompt: 'Test', |
| model: 'opencode/big-pickle', |
| cwd: '/tmp', |
| }) |
| ); |
|
|
| |
| expect(results).toHaveLength(4); |
| results.forEach((result) => { |
| expect(result.session_id).toBe(sessionId); |
| }); |
| }); |
| }); |
|
|
| describe('normalizeEvent additional edge cases', () => { |
| it('should handle tool_call with empty args object', () => { |
| const event = { |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| call_id: 'call-123', |
| name: 'Glob', |
| args: {}, |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].type).toBe('tool_use'); |
| expect(result?.message?.content[0].input).toEqual({}); |
| }); |
|
|
| it('should handle tool_call with null args', () => { |
| const event = { |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| call_id: 'call-123', |
| name: 'Glob', |
| args: null, |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].type).toBe('tool_use'); |
| expect(result?.message?.content[0].input).toBeNull(); |
| }); |
|
|
| it('should handle tool_call with complex nested args', () => { |
| const event = { |
| type: 'tool_call', |
| part: { |
| type: 'tool-call', |
| call_id: 'call-123', |
| name: 'Edit', |
| args: { |
| file_path: '/tmp/test.ts', |
| changes: [ |
| { line: 10, old: 'foo', new: 'bar' }, |
| { line: 20, old: 'baz', new: 'qux' }, |
| ], |
| options: { replace_all: true }, |
| }, |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].type).toBe('tool_use'); |
| expect(result?.message?.content[0].input).toEqual({ |
| file_path: '/tmp/test.ts', |
| changes: [ |
| { line: 10, old: 'foo', new: 'bar' }, |
| { line: 20, old: 'baz', new: 'qux' }, |
| ], |
| options: { replace_all: true }, |
| }); |
| }); |
|
|
| it('should handle tool_result with empty output', () => { |
| const event = { |
| type: 'tool_result', |
| part: { |
| type: 'tool-result', |
| call_id: 'call-123', |
| output: '', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].type).toBe('tool_result'); |
| expect(result?.message?.content[0].content).toBe(''); |
| }); |
|
|
| it('should handle text with whitespace-only text', () => { |
| const event = { |
| type: 'text', |
| part: { |
| type: 'text', |
| text: ' ', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| |
| expect(result).not.toBeNull(); |
| expect(result?.message?.content[0].text).toBe(' '); |
| }); |
|
|
| it('should handle text with newlines', () => { |
| const event = { |
| type: 'text', |
| part: { |
| type: 'text', |
| text: 'Line 1\nLine 2\nLine 3', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3'); |
| }); |
|
|
| it('should handle step_finish with both result and error (error takes precedence)', () => { |
| const event = { |
| type: 'step_finish', |
| part: { |
| type: 'step-finish', |
| reason: 'stop', |
| result: 'Some result', |
| error: 'But also an error', |
| }, |
| }; |
|
|
| const result = provider.normalizeEvent(event); |
|
|
| expect(result?.type).toBe('error'); |
| expect(result?.error).toBe('But also an error'); |
| }); |
| }); |
|
|
| describe('isInstalled', () => { |
| it('should return true when CLI path is set', async () => { |
| (provider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; |
| (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; |
|
|
| const result = await provider.isInstalled(); |
|
|
| expect(result).toBe(true); |
| }); |
|
|
| it('should return false when CLI path is null', async () => { |
| (provider as unknown as { cliPath: string | null }).cliPath = null; |
| (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; |
|
|
| const result = await provider.isInstalled(); |
|
|
| expect(result).toBe(false); |
| }); |
| }); |
|
|
| describe('model tier validation', () => { |
| it('should have exactly one default model', () => { |
| const models = provider.getAvailableModels(); |
| const defaultModels = models.filter((m) => m.default === true); |
|
|
| expect(defaultModels).toHaveLength(1); |
| expect(defaultModels[0].id).toBe('opencode/big-pickle'); |
| }); |
|
|
| it('should have valid tier values for all models', () => { |
| const models = provider.getAvailableModels(); |
| const validTiers = ['basic', 'standard', 'premium']; |
|
|
| models.forEach((model) => { |
| expect(validTiers).toContain(model.tier); |
| }); |
| }); |
|
|
| it('should have descriptions for all models', () => { |
| const models = provider.getAvailableModels(); |
|
|
| models.forEach((model) => { |
| expect(model.description).toBeDefined(); |
| expect(typeof model.description).toBe('string'); |
| expect(model.description!.length).toBeGreaterThan(0); |
| }); |
| }); |
| }); |
|
|
| describe('buildCliArgs edge cases', () => { |
| it('should handle very long prompts', () => { |
| const longPrompt = 'a'.repeat(10000); |
| const args = provider.buildCliArgs({ |
| prompt: longPrompt, |
| model: 'opencode/big-pickle', |
| cwd: '/tmp', |
| }); |
|
|
| |
| |
| expect(args).toContain('run'); |
| expect(args).not.toContain('-'); |
| expect(args.join(' ')).not.toContain(longPrompt); |
| }); |
|
|
| it('should handle prompts with special characters', () => { |
| const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\''; |
| const args = provider.buildCliArgs({ |
| prompt: specialPrompt, |
| model: 'opencode/big-pickle', |
| cwd: '/tmp', |
| }); |
|
|
| |
| expect(args).toContain('run'); |
| expect(args).not.toContain('-'); |
| }); |
|
|
| it('should handle cwd with spaces', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Test', |
| model: 'opencode/big-pickle', |
| cwd: '/tmp/path with spaces/project', |
| }); |
|
|
| |
| expect(args).not.toContain('-c'); |
| expect(args).not.toContain('/tmp/path with spaces/project'); |
| }); |
|
|
| it('should handle model with unusual characters', () => { |
| const args = provider.buildCliArgs({ |
| prompt: 'Test', |
| model: 'opencode-provider/model-v1.2.3-beta', |
| cwd: '/tmp', |
| }); |
|
|
| const modelIndex = args.indexOf('--model'); |
| expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('parseProvidersOutput', () => { |
| |
| function parseProviders(output: string) { |
| return ( |
| provider as unknown as { |
| parseProvidersOutput: (output: string) => Array<{ |
| id: string; |
| name: string; |
| authenticated: boolean; |
| authMethod?: 'oauth' | 'api_key'; |
| }>; |
| } |
| ).parseProvidersOutput(output); |
| } |
|
|
| |
| |
| |
|
|
| describe('Critical Fix Validation', () => { |
| it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => { |
| const output = 'β z.ai coding plan oauth'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(1); |
| expect(result[0].id).toBe('zai-coding-plan'); |
| expect(result[0].name).toBe('z.ai coding plan'); |
| expect(result[0].authMethod).toBe('oauth'); |
| }); |
|
|
| it('should map "z.ai" to "z-ai" (different from coding plan)', () => { |
| const output = 'β z.ai api'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(1); |
| expect(result[0].id).toBe('z-ai'); |
| expect(result[0].name).toBe('z.ai'); |
| expect(result[0].authMethod).toBe('api_key'); |
| }); |
|
|
| it('should distinguish between "z.ai coding plan" and "z.ai"', () => { |
| const output = 'β z.ai coding plan oauth\nβ z.ai api'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(2); |
| expect(result[0].id).toBe('zai-coding-plan'); |
| expect(result[0].name).toBe('z.ai coding plan'); |
| expect(result[1].id).toBe('z-ai'); |
| expect(result[1].name).toBe('z.ai'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('Provider Name Mapping', () => { |
| it('should map all 12 providers correctly', () => { |
| const output = `β anthropic oauth |
| β github copilot oauth |
| β google api |
| β openai api |
| β openrouter api |
| β azure api |
| β amazon bedrock oauth |
| β ollama api |
| β lm studio api |
| β opencode oauth |
| β z.ai coding plan oauth |
| β z.ai api`; |
|
|
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(12); |
| expect(result.map((p) => p.id)).toEqual([ |
| 'anthropic', |
| 'github-copilot', |
| 'google', |
| 'openai', |
| 'openrouter', |
| 'azure', |
| 'amazon-bedrock', |
| 'ollama', |
| 'lmstudio', |
| 'opencode', |
| 'zai-coding-plan', |
| 'z-ai', |
| ]); |
| }); |
|
|
| it('should handle case-insensitive provider names and preserve original casing', () => { |
| const output = 'β Anthropic api\nβ OPENAI oauth\nβ GitHub Copilot oauth'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(3); |
| expect(result[0].id).toBe('anthropic'); |
| expect(result[0].name).toBe('Anthropic'); |
| expect(result[1].id).toBe('openai'); |
| expect(result[1].name).toBe('OPENAI'); |
| expect(result[2].id).toBe('github-copilot'); |
| expect(result[2].name).toBe('GitHub Copilot'); |
| }); |
|
|
| it('should handle multi-word provider names with spaces', () => { |
| const output = 'β Amazon Bedrock oauth\nβ LM Studio api\nβ GitHub Copilot oauth'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].id).toBe('amazon-bedrock'); |
| expect(result[0].name).toBe('Amazon Bedrock'); |
| expect(result[1].id).toBe('lmstudio'); |
| expect(result[1].name).toBe('LM Studio'); |
| expect(result[2].id).toBe('github-copilot'); |
| expect(result[2].name).toBe('GitHub Copilot'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('Duplicate Aliases', () => { |
| it('should map provider aliases to the same ID', () => { |
| |
| const copilot1 = parseProviders('β copilot oauth'); |
| const copilot2 = parseProviders('β github copilot oauth'); |
| expect(copilot1[0].id).toBe('github-copilot'); |
| expect(copilot2[0].id).toBe('github-copilot'); |
|
|
| |
| const bedrock1 = parseProviders('β bedrock oauth'); |
| const bedrock2 = parseProviders('β amazon bedrock oauth'); |
| expect(bedrock1[0].id).toBe('amazon-bedrock'); |
| expect(bedrock2[0].id).toBe('amazon-bedrock'); |
|
|
| |
| const lm1 = parseProviders('β lmstudio api'); |
| const lm2 = parseProviders('β lm studio api'); |
| expect(lm1[0].id).toBe('lmstudio'); |
| expect(lm2[0].id).toBe('lmstudio'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('Authentication Methods', () => { |
| it('should detect oauth and api_key auth methods', () => { |
| const output = 'β anthropic oauth\nβ openai api\nβ google api_key'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].authMethod).toBe('oauth'); |
| expect(result[1].authMethod).toBe('api_key'); |
| expect(result[2].authMethod).toBe('api_key'); |
| }); |
|
|
| it('should set authenticated to true and handle case-insensitive auth methods', () => { |
| const output = 'β anthropic OAuth\nβ openai API'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].authenticated).toBe(true); |
| expect(result[0].authMethod).toBe('oauth'); |
| expect(result[1].authenticated).toBe(true); |
| expect(result[1].authMethod).toBe('api_key'); |
| }); |
|
|
| it('should return undefined authMethod for unknown auth types', () => { |
| const output = 'β anthropic unknown-auth'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].authenticated).toBe(true); |
| expect(result[0].authMethod).toBeUndefined(); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('ANSI Escape Sequences', () => { |
| it('should strip ANSI color codes from output', () => { |
| const output = '\x1b[32mβ anthropic oauth\x1b[0m'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(1); |
| expect(result[0].id).toBe('anthropic'); |
| expect(result[0].name).toBe('anthropic'); |
| }); |
|
|
| it('should handle complex ANSI sequences and codes in provider names', () => { |
| const output = |
| '\x1b[1;32mβ\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(1); |
| expect(result[0].id).toBe('github-copilot'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('Edge Cases', () => { |
| it('should return empty array for empty output or no β symbols', () => { |
| expect(parseProviders('')).toEqual([]); |
| expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]); |
| expect(parseProviders('No authenticated providers')).toEqual([]); |
| }); |
|
|
| it('should skip malformed lines with β but insufficient content', () => { |
| const output = 'β\nβ \nβ anthropic\nβ openai api'; |
| const result = parseProviders(output); |
|
|
| |
| expect(result).toHaveLength(1); |
| expect(result[0].id).toBe('openai'); |
| }); |
|
|
| it('should use fallback for unknown providers (spaces to hyphens)', () => { |
| const output = 'β unknown provider name oauth'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].id).toBe('unknown-provider-name'); |
| expect(result[0].name).toBe('unknown provider name'); |
| }); |
|
|
| it('should handle extra whitespace and mixed case', () => { |
| const output = 'β AnThRoPiC oauth'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].id).toBe('anthropic'); |
| expect(result[0].name).toBe('AnThRoPiC'); |
| }); |
|
|
| it('should handle multiple β symbols on same line', () => { |
| const output = 'β β anthropic oauth'; |
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(1); |
| expect(result[0].id).toBe('anthropic'); |
| }); |
|
|
| it('should handle different newline formats and trailing newlines', () => { |
| const outputUnix = 'β anthropic oauth\nβ openai api'; |
| const outputWindows = 'β anthropic oauth\r\nβ openai api\r\n\r\n'; |
|
|
| const resultUnix = parseProviders(outputUnix); |
| const resultWindows = parseProviders(outputWindows); |
|
|
| expect(resultUnix).toHaveLength(2); |
| expect(resultWindows).toHaveLength(2); |
| }); |
|
|
| it('should handle provider names with numbers and special characters', () => { |
| const output = 'β gpt-4o api'; |
| const result = parseProviders(output); |
|
|
| expect(result[0].id).toBe('gpt-4o'); |
| expect(result[0].name).toBe('gpt-4o'); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe('Real-world CLI Output', () => { |
| it('should parse CLI output with box drawing characters and decorations', () => { |
| const output = `βββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β Authenticated Providers β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββ€ |
| β anthropic oauth |
| β openai api |
| βββββββββββββββββββββββββββββββββββββββββββββββββββ`; |
|
|
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(2); |
| expect(result[0].id).toBe('anthropic'); |
| expect(result[1].id).toBe('openai'); |
| }); |
|
|
| it('should parse output with ANSI colors and box characters', () => { |
| const output = `\x1b[1mβββββββββββββββββββββββββββββββββββββββββββββββββββ\x1b[0m |
| \x1b[1mβ Authenticated Providers β\x1b[0m |
| \x1b[1mβββββββββββββββββββββββββββββββββββββββββββββββββββ€\x1b[0m |
| \x1b[32mβ\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m |
| \x1b[32mβ\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m |
| \x1b[1mβββββββββββββββββββββββββββββββββββββββββββββββββββ\x1b[0m`; |
|
|
| const result = parseProviders(output); |
|
|
| expect(result).toHaveLength(2); |
| expect(result[0].id).toBe('anthropic'); |
| expect(result[1].id).toBe('google'); |
| }); |
|
|
| it('should handle "no authenticated providers" message', () => { |
| const output = `βββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β No authenticated providers found β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββ`; |
|
|
| const result = parseProviders(output); |
| expect(result).toEqual([]); |
| }); |
| }); |
| }); |
| }); |
|
|