test: repair test suite after R1-R3 refactors and add Claude Code tests
The R1-R3 refactors removed the per-provider handle*Error methods, consolidated validators into validateNumberInRange, renamed Gemini's buildGenerationConfig to buildConfig, and split OpenWebUI's message conversion and response formatting into openwebui-strategies.ts. 44 of 91 existing tests broke as a result. Changes: claude.test.ts (8 -> 13 passing) - Switch error mapping tests from handleAnthropicError to the base normalizeError method. - Update temperature error-message expectation to match the new validateNumberInRange wording. openai.test.ts (16 -> 22 passing) - Same handleOpenAIError -> normalizeError switch. - Add a regression test for the 429+"quota" path that goes through mapProviderError instead of the base mapping. gemini.test.ts (17 -> 27 passing) - Update getInfo expectations to the new default model (gemini-2.5) and provider version 2.0.0. - Switch to normalizeError. - Rename buildGenerationConfig -> buildConfig (renamed during R2). - Update formatCompletionResponse mocks: the @google/genai SDK exposes response.text as a getter rather than walking candidates[0].content.parts. openwebui.test.ts (4 -> 19 passing) - Drop tests for convertMessages / convertMessagesToPrompt / formatChatResponse / formatOllamaResponse / makeRequest: these moved into openwebui-strategies.ts and openwebui-http.ts during R3 and warrant their own test files. - Add error-mapping tests covering the new mapProviderError + providerErrorMessages plumbing. claude-code.test.ts (new, 18 tests) - Constructor accepts subscription mode (empty config). - getInfo advertises subscriptionAuth + requiresLocalCli. - mapProviderError catches ENOENT and "not logged in". - errorForSdkAssistantError covers each documented SDK error code. - buildPrompt handles single-turn pass-through, multi-system combination, multi-turn flattening with role labels, and rejects empty turn lists. Full suite: 100 pass / 0 fail across 5 files.
This commit is contained in:
178
tests/claude-code.test.ts
Normal file
178
tests/claude-code.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for ClaudeCodeProvider
|
||||
*
|
||||
* The provider shells out to the local Claude Code CLI via
|
||||
* @anthropic-ai/claude-agent-sdk. Tests cover the public surface and the
|
||||
* provider-specific hooks; full integration is not exercised because it
|
||||
* would spawn a real CLI process.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { ClaudeCodeProvider } from '../src/providers/claude-code.js';
|
||||
import { AIProviderError, AIErrorType } from '../src/types/index.js';
|
||||
|
||||
describe('ClaudeCodeProvider', () => {
|
||||
let provider: ClaudeCodeProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ClaudeCodeProvider({ apiKey: 'test-key' });
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should accept empty config (subscription mode)', () => {
|
||||
const p = new ClaudeCodeProvider({});
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should accept apiKey override', () => {
|
||||
const p = new ClaudeCodeProvider({ apiKey: 'sk-...' });
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should accept custom defaults', () => {
|
||||
const p = new ClaudeCodeProvider({
|
||||
defaultModel: 'opus',
|
||||
maxTurns: 5,
|
||||
allowedTools: ['Read', 'Bash'],
|
||||
cwd: '/tmp/agent'
|
||||
});
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should reject non-string apiKey', () => {
|
||||
expect(() => new ClaudeCodeProvider({ apiKey: 123 as any }))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfo', () => {
|
||||
it('should advertise subscriptionAuth capability', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
expect(info.name).toBe('Claude Code');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.capabilities).toMatchObject({
|
||||
subscriptionAuth: true,
|
||||
requiresLocalCli: true,
|
||||
systemMessages: true
|
||||
});
|
||||
expect(info.models).toContain('sonnet');
|
||||
expect(info.models).toContain('opus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error mapping', () => {
|
||||
const mapError = (err: any) => (provider as any).mapProviderError(err);
|
||||
|
||||
it('should detect missing CLI', () => {
|
||||
const err = new Error('spawn claude ENOENT');
|
||||
|
||||
const mapped = mapError(err);
|
||||
|
||||
expect(mapped).toBeInstanceOf(AIProviderError);
|
||||
expect(mapped.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(mapped.message).toContain('CLI');
|
||||
});
|
||||
|
||||
it('should detect missing authentication', () => {
|
||||
const err = new Error('not logged in');
|
||||
|
||||
const mapped = mapError(err);
|
||||
|
||||
expect(mapped).toBeInstanceOf(AIProviderError);
|
||||
expect(mapped.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
});
|
||||
|
||||
it('should return null for unrelated errors', () => {
|
||||
expect(mapError(new Error('something else'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK assistant error mapping', () => {
|
||||
const mapSdkError = (code: string) => (provider as any).errorForSdkAssistantError(code);
|
||||
|
||||
it('should map authentication_failed', () => {
|
||||
const err = mapSdkError('authentication_failed');
|
||||
expect(err.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
});
|
||||
|
||||
it('should map rate_limit', () => {
|
||||
const err = mapSdkError('rate_limit');
|
||||
expect(err.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it('should map model_not_found', () => {
|
||||
const err = mapSdkError('model_not_found');
|
||||
expect(err.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should map server_error to NETWORK', () => {
|
||||
const err = mapSdkError('server_error');
|
||||
expect(err.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should fall back to UNKNOWN for unrecognized codes', () => {
|
||||
const err = mapSdkError('weird_new_code');
|
||||
expect(err.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompt building', () => {
|
||||
const build = (messages: any[]) => (provider as any).buildPrompt(messages);
|
||||
|
||||
it('should pass single user turn through unchanged', () => {
|
||||
const result = build([{ role: 'user', content: 'Hello' }]);
|
||||
|
||||
expect(result.prompt).toBe('Hello');
|
||||
expect(result.systemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should combine multiple system messages', () => {
|
||||
const result = build([
|
||||
{ role: 'system', content: 'Be brief' },
|
||||
{ role: 'system', content: 'Use British spelling' },
|
||||
{ role: 'user', content: 'Hi' }
|
||||
]);
|
||||
|
||||
expect(result.systemPrompt).toBe('Be brief\n\nUse British spelling');
|
||||
expect(result.prompt).toBe('Hi');
|
||||
});
|
||||
|
||||
it('should flatten multi-turn conversation with role labels', () => {
|
||||
const result = build([
|
||||
{ role: 'user', content: 'What is 2+2?' },
|
||||
{ role: 'assistant', content: '4' },
|
||||
{ role: 'user', content: 'Why?' }
|
||||
]);
|
||||
|
||||
expect(result.prompt).toContain('Human: What is 2+2?');
|
||||
expect(result.prompt).toContain('Assistant: 4');
|
||||
expect(result.prompt).toContain('Human: Why?');
|
||||
expect(result.prompt.endsWith('Assistant:')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty turn list', () => {
|
||||
expect(() => build([{ role: 'system', content: 'only system' }]))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should require initialization before complete()', async () => {
|
||||
await expect(
|
||||
provider.complete({ messages: [{ role: 'user', content: 'test' }] })
|
||||
).rejects.toThrow('Provider must be initialized before use');
|
||||
});
|
||||
|
||||
it('should validate temperature range when initialized', async () => {
|
||||
(provider as any).initialized = true;
|
||||
|
||||
await expect(
|
||||
provider.complete({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user