test: add comprehensive tests for ClaudeCodeProvider
Addresses code review feedback about missing automated tests for the ClaudeCodeProvider. ## Changes - Added unit tests for ClaudeCodeProvider class covering constructor, validateAuth, and getClient methods - Added unit tests for ClaudeCodeLanguageModel testing lazy loading behavior and error handling - Added integration tests verifying optional dependency behavior when @anthropic-ai/claude-code is not installed ## Test Coverage 1. **Unit Tests**: - ClaudeCodeProvider: Basic functionality, no API key requirement, client creation - ClaudeCodeLanguageModel: Model initialization, lazy loading, error messages, warning generation 2. **Integration Tests**: - Optional dependency behavior when package is not installed - Clear error messages for users about missing package - Provider instantiation works but usage fails gracefully All tests pass and provide comprehensive coverage for the claude-code provider implementation.
This commit is contained in:
committed by
Ralph Khreish
parent
b62cb1bbe7
commit
9995075093
85
tests/integration/claude-code-optional.test.js
Normal file
85
tests/integration/claude-code-optional.test.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the base provider to avoid circular dependencies
|
||||
jest.unstable_mockModule('../../src/ai-providers/base-provider.js', () => ({
|
||||
BaseAIProvider: class {
|
||||
constructor() {
|
||||
this.name = 'Base Provider';
|
||||
}
|
||||
handleError(context, error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock the claude-code SDK to simulate it not being installed
|
||||
jest.unstable_mockModule('@anthropic-ai/claude-code', () => {
|
||||
throw new Error("Cannot find module '@anthropic-ai/claude-code'");
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
const { ClaudeCodeProvider } = await import('../../src/ai-providers/claude-code.js');
|
||||
|
||||
describe('Claude Code Optional Dependency Integration', () => {
|
||||
describe('when @anthropic-ai/claude-code is not installed', () => {
|
||||
it('should allow provider instantiation', () => {
|
||||
// Provider should instantiate without error
|
||||
const provider = new ClaudeCodeProvider();
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider.name).toBe('Claude Code');
|
||||
});
|
||||
|
||||
it('should allow client creation', () => {
|
||||
const provider = new ClaudeCodeProvider();
|
||||
// Client creation should work
|
||||
const client = provider.getClient({});
|
||||
expect(client).toBeDefined();
|
||||
expect(typeof client).toBe('function');
|
||||
});
|
||||
|
||||
it('should fail with clear error when trying to use the model', async () => {
|
||||
const provider = new ClaudeCodeProvider();
|
||||
const client = provider.getClient({});
|
||||
const model = client('opus');
|
||||
|
||||
// The actual usage should fail with the lazy loading error
|
||||
await expect(model.doGenerate({
|
||||
prompt: [{ role: 'user', content: 'Hello' }],
|
||||
mode: { type: 'regular' }
|
||||
})).rejects.toThrow("Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider.");
|
||||
});
|
||||
|
||||
it('should provide helpful error message for streaming', async () => {
|
||||
const provider = new ClaudeCodeProvider();
|
||||
const client = provider.getClient({});
|
||||
const model = client('sonnet');
|
||||
|
||||
await expect(model.doStream({
|
||||
prompt: [{ role: 'user', content: 'Hello' }],
|
||||
mode: { type: 'regular' }
|
||||
})).rejects.toThrow("Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider.");
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider behavior', () => {
|
||||
it('should not require API key', () => {
|
||||
const provider = new ClaudeCodeProvider();
|
||||
// Should not throw
|
||||
expect(() => provider.validateAuth()).not.toThrow();
|
||||
expect(() => provider.validateAuth({ apiKey: null })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with ai-services-unified when provider is configured', async () => {
|
||||
// This tests that the provider can be selected but will fail appropriately
|
||||
// when the actual model is used
|
||||
const provider = new ClaudeCodeProvider();
|
||||
expect(provider).toBeDefined();
|
||||
|
||||
// In real usage, ai-services-unified would:
|
||||
// 1. Get the provider instance (works)
|
||||
// 2. Call provider.getClient() (works)
|
||||
// 3. Create a model (works)
|
||||
// 4. Try to generate (fails with clear error)
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/unit/ai-providers/claude-code.test.js
Normal file
104
tests/unit/ai-providers/claude-code.test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock the claude-code SDK module
|
||||
jest.unstable_mockModule('../../../src/ai-providers/custom-sdk/claude-code/index.js', () => ({
|
||||
createClaudeCode: jest.fn(() => {
|
||||
const provider = (modelId, settings) => ({
|
||||
// Mock language model
|
||||
id: modelId,
|
||||
settings
|
||||
});
|
||||
provider.languageModel = jest.fn((id, settings) => ({ id, settings }));
|
||||
provider.chat = provider.languageModel;
|
||||
return provider;
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock the base provider
|
||||
jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({
|
||||
BaseAIProvider: class {
|
||||
constructor() {
|
||||
this.name = 'Base Provider';
|
||||
}
|
||||
handleError(context, error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const { ClaudeCodeProvider } = await import('../../../src/ai-providers/claude-code.js');
|
||||
|
||||
describe('ClaudeCodeProvider', () => {
|
||||
let provider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ClaudeCodeProvider();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should set the provider name to Claude Code', () => {
|
||||
expect(provider.name).toBe('Claude Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAuth', () => {
|
||||
it('should not throw an error (no API key required)', () => {
|
||||
expect(() => provider.validateAuth({})).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not require any parameters', () => {
|
||||
expect(() => provider.validateAuth()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with any params passed', () => {
|
||||
expect(() => provider.validateAuth({
|
||||
apiKey: 'some-key',
|
||||
baseURL: 'https://example.com'
|
||||
})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient', () => {
|
||||
it('should return a claude code client', () => {
|
||||
const client = provider.getClient({});
|
||||
expect(client).toBeDefined();
|
||||
expect(typeof client).toBe('function');
|
||||
});
|
||||
|
||||
it('should create client without API key or base URL', () => {
|
||||
const client = provider.getClient({});
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle params even though they are not used', () => {
|
||||
const client = provider.getClient({
|
||||
baseURL: 'https://example.com',
|
||||
apiKey: 'unused-key'
|
||||
});
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have languageModel and chat methods', () => {
|
||||
const client = provider.getClient({});
|
||||
expect(client.languageModel).toBeDefined();
|
||||
expect(client.chat).toBeDefined();
|
||||
expect(client.chat).toBe(client.languageModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle client initialization errors', async () => {
|
||||
// Force an error by making createClaudeCode throw
|
||||
const { createClaudeCode } = await import('../../../src/ai-providers/custom-sdk/claude-code/index.js');
|
||||
createClaudeCode.mockImplementationOnce(() => {
|
||||
throw new Error('Mock initialization error');
|
||||
});
|
||||
|
||||
// Create a new provider instance to use the mocked createClaudeCode
|
||||
const errorProvider = new ClaudeCodeProvider();
|
||||
expect(() => errorProvider.getClient({})).toThrow('Mock initialization error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mock modules before importing
|
||||
jest.unstable_mockModule('@ai-sdk/provider', () => ({
|
||||
NoSuchModelError: class NoSuchModelError extends Error {
|
||||
constructor({ modelId, modelType }) {
|
||||
super(`No such model: ${modelId}`);
|
||||
this.modelId = modelId;
|
||||
this.modelType = modelType;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('@ai-sdk/provider-utils', () => ({
|
||||
generateId: jest.fn(() => 'test-id-123')
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../src/ai-providers/custom-sdk/claude-code/message-converter.js', () => ({
|
||||
convertToClaudeCodeMessages: jest.fn((prompt) => ({
|
||||
messagesPrompt: 'converted-prompt',
|
||||
systemPrompt: 'system'
|
||||
}))
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../src/ai-providers/custom-sdk/claude-code/json-extractor.js', () => ({
|
||||
extractJson: jest.fn((text) => text)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../../../../../src/ai-providers/custom-sdk/claude-code/errors.js', () => ({
|
||||
createAPICallError: jest.fn((opts) => new Error(opts.message)),
|
||||
createAuthenticationError: jest.fn((opts) => new Error(opts.message))
|
||||
}));
|
||||
|
||||
// This mock will be controlled by tests
|
||||
let mockClaudeCodeModule = null;
|
||||
jest.unstable_mockModule('@anthropic-ai/claude-code', () => {
|
||||
if (mockClaudeCodeModule) {
|
||||
return mockClaudeCodeModule;
|
||||
}
|
||||
throw new Error("Cannot find module '@anthropic-ai/claude-code'");
|
||||
});
|
||||
|
||||
// Import the module under test
|
||||
const { ClaudeCodeLanguageModel } = await import('../../../../../src/ai-providers/custom-sdk/claude-code/language-model.js');
|
||||
|
||||
describe('ClaudeCodeLanguageModel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the module mock
|
||||
mockClaudeCodeModule = null;
|
||||
// Clear module cache to ensure fresh imports
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with valid model ID', () => {
|
||||
const model = new ClaudeCodeLanguageModel({
|
||||
id: 'opus',
|
||||
settings: { maxTurns: 5 }
|
||||
});
|
||||
|
||||
expect(model.modelId).toBe('opus');
|
||||
expect(model.settings).toEqual({ maxTurns: 5 });
|
||||
expect(model.provider).toBe('claude-code');
|
||||
});
|
||||
|
||||
it('should throw NoSuchModelError for invalid model ID', async () => {
|
||||
expect(() => new ClaudeCodeLanguageModel({
|
||||
id: '',
|
||||
settings: {}
|
||||
})).toThrow('No such model: ');
|
||||
|
||||
expect(() => new ClaudeCodeLanguageModel({
|
||||
id: null,
|
||||
settings: {}
|
||||
})).toThrow('No such model: null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazy loading of @anthropic-ai/claude-code', () => {
|
||||
it('should throw error when package is not installed', async () => {
|
||||
// Keep mockClaudeCodeModule as null to simulate missing package
|
||||
const model = new ClaudeCodeLanguageModel({
|
||||
id: 'opus',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
await expect(model.doGenerate({
|
||||
prompt: [{ role: 'user', content: 'test' }],
|
||||
mode: { type: 'regular' }
|
||||
})).rejects.toThrow("Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider.");
|
||||
});
|
||||
|
||||
it('should load package successfully when available', async () => {
|
||||
// Mock successful package load
|
||||
const mockQuery = jest.fn(async function* () {
|
||||
yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } };
|
||||
yield { type: 'result', subtype: 'done', usage: { output_tokens: 10, input_tokens: 5 } };
|
||||
});
|
||||
|
||||
mockClaudeCodeModule = {
|
||||
query: mockQuery,
|
||||
AbortError: class AbortError extends Error {}
|
||||
};
|
||||
|
||||
// Need to re-import to get fresh module with mocks
|
||||
jest.resetModules();
|
||||
const { ClaudeCodeLanguageModel: FreshModel } = await import('../../../../../src/ai-providers/custom-sdk/claude-code/language-model.js');
|
||||
|
||||
const model = new FreshModel({
|
||||
id: 'opus',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
const result = await model.doGenerate({
|
||||
prompt: [{ role: 'user', content: 'test' }],
|
||||
mode: { type: 'regular' }
|
||||
});
|
||||
|
||||
expect(result.text).toBe('Hello');
|
||||
expect(mockQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only attempt to load package once', async () => {
|
||||
// Get a fresh import to ensure clean state
|
||||
jest.resetModules();
|
||||
const { ClaudeCodeLanguageModel: TestModel } = await import('../../../../../src/ai-providers/custom-sdk/claude-code/language-model.js');
|
||||
|
||||
const model = new TestModel({
|
||||
id: 'opus',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
// First call should throw
|
||||
await expect(model.doGenerate({
|
||||
prompt: [{ role: 'user', content: 'test' }],
|
||||
mode: { type: 'regular' }
|
||||
})).rejects.toThrow("Claude Code SDK is not installed");
|
||||
|
||||
// Second call should also throw without trying to load again
|
||||
await expect(model.doGenerate({
|
||||
prompt: [{ role: 'user', content: 'test' }],
|
||||
mode: { type: 'regular' }
|
||||
})).rejects.toThrow("Claude Code SDK is not installed");
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUnsupportedWarnings', () => {
|
||||
it('should generate warnings for unsupported parameters', () => {
|
||||
const model = new ClaudeCodeLanguageModel({
|
||||
id: 'opus',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
const warnings = model.generateUnsupportedWarnings({
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
topP: 0.9,
|
||||
seed: 42
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(4);
|
||||
expect(warnings[0]).toEqual({
|
||||
type: 'unsupported-setting',
|
||||
setting: 'temperature',
|
||||
details: 'Claude Code CLI does not support the temperature parameter. It will be ignored.'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no unsupported parameters', () => {
|
||||
const model = new ClaudeCodeLanguageModel({
|
||||
id: 'opus',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
const warnings = model.generateUnsupportedWarnings({});
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModel', () => {
|
||||
it('should map model IDs correctly', () => {
|
||||
const model = new ClaudeCodeLanguageModel({
|
||||
id: 'opus',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
expect(model.getModel()).toBe('opus');
|
||||
});
|
||||
|
||||
it('should return unmapped model IDs as-is', () => {
|
||||
const model = new ClaudeCodeLanguageModel({
|
||||
id: 'custom-model',
|
||||
settings: {}
|
||||
});
|
||||
|
||||
expect(model.getModel()).toBe('custom-model');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user