0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Mock dependencies before importing the base class
|
|
vi.mock('../../../src/envars', () => ({
|
|
getEnvString: vi.fn(),
|
|
getEnvInt: vi.fn().mockReturnValue(300000),
|
|
}));
|
|
|
|
vi.mock('../../../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../src/cliState', () => ({
|
|
default: {
|
|
basePath: '/test/base/path',
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../src/esm', () => ({
|
|
importModule: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/util/file', () => ({
|
|
maybeLoadFromExternalFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/util/index', () => ({
|
|
maybeLoadToolsFromExternalFile: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
// Use vi.hoisted to hoist mock definitions before vi.mock
|
|
const { mockMcpInstance, MockMCPClient } = vi.hoisted(() => {
|
|
const mockMcpInstance = {
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
class MockMCPClient {
|
|
initialize = mockMcpInstance.initialize;
|
|
getAllTools = mockMcpInstance.getAllTools;
|
|
cleanup = mockMcpInstance.cleanup;
|
|
}
|
|
|
|
return { mockMcpInstance, MockMCPClient };
|
|
});
|
|
|
|
vi.mock('../../../src/providers/mcp/client', () => ({
|
|
MCPClient: MockMCPClient,
|
|
}));
|
|
|
|
vi.mock('../../../src/providers/mcp/transform', () => ({
|
|
transformMCPToolsToGoogle: vi.fn().mockReturnValue([]),
|
|
}));
|
|
|
|
vi.mock('../../../src/providers/google/auth', () => ({
|
|
GoogleAuthManager: {
|
|
determineVertexMode: vi.fn().mockReturnValue(false),
|
|
validateAndWarn: vi.fn(),
|
|
getApiKey: vi.fn().mockReturnValue({ apiKey: 'test-key', source: 'config' }),
|
|
resolveRegion: vi.fn().mockReturnValue('us-central1'),
|
|
resolveProjectId: vi.fn().mockResolvedValue('test-project'),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../src/providers/google/util', () => ({
|
|
normalizeTools: vi.fn((tools) => tools),
|
|
}));
|
|
|
|
vi.mock('../../../src/util/templates', () => ({
|
|
getNunjucksEngine: vi.fn(() => ({
|
|
renderString: vi.fn((str) => str),
|
|
})),
|
|
}));
|
|
|
|
import cliState from '../../../src/cliState';
|
|
import { importModule } from '../../../src/esm';
|
|
import { GoogleAuthManager } from '../../../src/providers/google/auth';
|
|
import { GoogleGenericProvider } from '../../../src/providers/google/base';
|
|
import { maybeLoadToolsFromExternalFile } from '../../../src/util/index';
|
|
|
|
import type { CallApiContextParams, ProviderResponse } from '../../../src/types/index';
|
|
|
|
// Create a concrete implementation for testing
|
|
class TestGoogleProvider extends GoogleGenericProvider {
|
|
getApiEndpoint(action?: string): string {
|
|
return `https://test-api.example.com/${this.modelName}${action ? `:${action}` : ''}`;
|
|
}
|
|
|
|
async getAuthHeaders(): Promise<Record<string, string>> {
|
|
return { Authorization: 'Bearer test-token' };
|
|
}
|
|
|
|
async callApi(prompt: string, _context?: CallApiContextParams): Promise<ProviderResponse> {
|
|
return { output: `Response to: ${prompt}` };
|
|
}
|
|
}
|
|
|
|
describe('GoogleGenericProvider', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset hoisted mock instance methods to ensure test isolation
|
|
mockMcpInstance.initialize.mockReset().mockResolvedValue(undefined);
|
|
mockMcpInstance.getAllTools.mockReset().mockReturnValue([]);
|
|
mockMcpInstance.cleanup.mockReset().mockResolvedValue(undefined);
|
|
// Reset utility mocks
|
|
vi.mocked(maybeLoadToolsFromExternalFile).mockReset().mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should initialize with model name and default config', () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
expect(provider.modelName).toBe('gemini-2.5-pro');
|
|
expect(provider.config).toEqual({});
|
|
expect(GoogleAuthManager.determineVertexMode).toHaveBeenCalled();
|
|
expect(GoogleAuthManager.validateAndWarn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should initialize with custom config', () => {
|
|
const config = { apiKey: 'custom-key', temperature: 0.7 };
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', { config });
|
|
|
|
expect(provider.config).toEqual(config);
|
|
});
|
|
|
|
it('should store env overrides', () => {
|
|
const env = { GOOGLE_API_KEY: 'env-key' };
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', { env });
|
|
|
|
expect(provider.env).toEqual(env);
|
|
});
|
|
|
|
it('should support custom id', () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', { id: 'custom-id' });
|
|
|
|
expect(provider.id()).toBe('custom-id');
|
|
});
|
|
|
|
it('should initialize MCP client when configured', async () => {
|
|
const mcpConfig = { enabled: true, servers: [] };
|
|
const _provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { mcp: mcpConfig },
|
|
});
|
|
|
|
// Wait for MCP initialization
|
|
await vi.waitFor(() => {
|
|
expect(mockMcpInstance.initialize).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should not initialize MCP when not configured', () => {
|
|
mockMcpInstance.initialize.mockClear();
|
|
new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
// Verify initialize was not called since MCP was not configured
|
|
expect(mockMcpInstance.initialize).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('id()', () => {
|
|
it('should return google prefix for non-vertex mode', () => {
|
|
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(false);
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
expect(provider.id()).toBe('google:gemini-2.5-pro');
|
|
});
|
|
|
|
it('should return vertex prefix for vertex mode', () => {
|
|
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(true);
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
expect(provider.id()).toBe('vertex:gemini-2.5-pro');
|
|
});
|
|
|
|
it('should use custom id when provided', () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', { id: 'my-custom-provider' });
|
|
|
|
expect(provider.id()).toBe('my-custom-provider');
|
|
});
|
|
});
|
|
|
|
describe('toString()', () => {
|
|
it('should return Google AI Studio string for non-vertex mode', () => {
|
|
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(false);
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
expect(provider.toString()).toBe('[Google Google AI Studio Provider gemini-2.5-pro]');
|
|
});
|
|
|
|
it('should return Vertex AI string for vertex mode', () => {
|
|
vi.mocked(GoogleAuthManager.determineVertexMode).mockReturnValue(true);
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
expect(provider.toString()).toBe('[Google Vertex AI Provider gemini-2.5-pro]');
|
|
});
|
|
});
|
|
|
|
describe('getApiKey()', () => {
|
|
it('should delegate to GoogleAuthManager', () => {
|
|
vi.mocked(GoogleAuthManager.getApiKey).mockReturnValue({
|
|
apiKey: 'resolved-key',
|
|
source: 'GOOGLE_API_KEY',
|
|
});
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { apiKey: 'config-key' },
|
|
env: { GOOGLE_API_KEY: 'env-key' },
|
|
});
|
|
|
|
const result = provider.getApiKey();
|
|
|
|
expect(result).toBe('resolved-key');
|
|
expect(GoogleAuthManager.getApiKey).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getRegion()', () => {
|
|
it('should delegate to GoogleAuthManager', () => {
|
|
vi.mocked(GoogleAuthManager.resolveRegion).mockReturnValue('europe-west1');
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { region: 'europe-west1' },
|
|
});
|
|
|
|
expect(provider.getRegion()).toBe('europe-west1');
|
|
});
|
|
});
|
|
|
|
describe('getProjectId()', () => {
|
|
it('should delegate to GoogleAuthManager', async () => {
|
|
vi.mocked(GoogleAuthManager.resolveProjectId).mockResolvedValue('my-project');
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { projectId: 'my-project' },
|
|
});
|
|
|
|
const result = await provider.getProjectId();
|
|
|
|
expect(result).toBe('my-project');
|
|
});
|
|
});
|
|
|
|
describe('getAllTools()', () => {
|
|
it('should return empty array when no tools configured', async () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
const tools = await provider['getAllTools']();
|
|
|
|
expect(tools).toEqual([]);
|
|
});
|
|
|
|
it('should load tools from config', async () => {
|
|
const configTools = [{ functionDeclarations: [{ name: 'test_fn' }] }];
|
|
vi.mocked(maybeLoadToolsFromExternalFile).mockResolvedValue(configTools);
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { tools: configTools },
|
|
});
|
|
|
|
const tools = await provider['getAllTools']();
|
|
|
|
expect(maybeLoadToolsFromExternalFile).toHaveBeenCalledWith(configTools, undefined);
|
|
expect(tools).toEqual(configTools);
|
|
});
|
|
|
|
it('should combine MCP tools with config tools', async () => {
|
|
const mcpConfig = { enabled: true, servers: [] };
|
|
const configTools = [{ functionDeclarations: [{ name: 'config_fn' }] }];
|
|
|
|
// Set up mocks before creating provider
|
|
vi.mocked(maybeLoadToolsFromExternalFile).mockResolvedValue(configTools);
|
|
|
|
const { transformMCPToolsToGoogle } = await import('../../../src/providers/mcp/transform');
|
|
const mcpTool = { functionDeclarations: [{ name: 'mcp_fn' }] };
|
|
vi.mocked(transformMCPToolsToGoogle).mockReturnValue([mcpTool]);
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { mcp: mcpConfig, tools: configTools },
|
|
});
|
|
|
|
// Wait for MCP initialization to complete
|
|
await vi.waitFor(() => {
|
|
expect(mockMcpInstance.initialize).toHaveBeenCalled();
|
|
});
|
|
|
|
const tools = await provider['getAllTools']();
|
|
|
|
// Should have MCP tool + config tool
|
|
expect(transformMCPToolsToGoogle).toHaveBeenCalled();
|
|
expect(tools).toContainEqual(mcpTool);
|
|
expect(tools.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('loadExternalFunction()', () => {
|
|
it('should load function from file', async () => {
|
|
const mockFn = vi.fn().mockReturnValue('result');
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
const fn = await provider['loadExternalFunction']('file://test.js');
|
|
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('/test/base/path', 'test.js'),
|
|
undefined,
|
|
);
|
|
expect(fn).toBe(mockFn);
|
|
});
|
|
|
|
it('should load named function from file', async () => {
|
|
const mockFn = vi.fn().mockReturnValue('result');
|
|
vi.mocked(importModule).mockResolvedValue({ myFunction: mockFn });
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
const fn = await provider['loadExternalFunction']('file://test.js:myFunction');
|
|
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('/test/base/path', 'test.js'),
|
|
'myFunction',
|
|
);
|
|
expect(fn).toBe(mockFn);
|
|
});
|
|
|
|
it('should load named function from Windows-style file paths', async () => {
|
|
const mockFn = vi.fn().mockReturnValue('result');
|
|
vi.mocked(importModule).mockResolvedValue({ myFunction: mockFn });
|
|
|
|
// Use a base path that contains the target on Windows (where path.resolve
|
|
// recognises `C:/...` as absolute) so the loader's path-traversal guard
|
|
// does not reject it. On POSIX the drive letter is treated as a relative
|
|
// segment, which still keeps the resolved path inside the base.
|
|
const originalBasePath = cliState.basePath;
|
|
cliState.basePath = 'C:/';
|
|
|
|
try {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
const fn = await provider['loadExternalFunction']('file://C:/path/to/test.js:myFunction');
|
|
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('C:/', 'C:/path/to/test.js'),
|
|
'myFunction',
|
|
);
|
|
expect(fn).toBe(mockFn);
|
|
} finally {
|
|
cliState.basePath = originalBasePath;
|
|
}
|
|
});
|
|
|
|
it('should throw error when function not found', async () => {
|
|
vi.mocked(importModule).mockResolvedValue({ someOtherFn: vi.fn() });
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
await expect(provider['loadExternalFunction']('file://test.js:myFunction')).rejects.toThrow(
|
|
'Function callback malformed',
|
|
);
|
|
});
|
|
|
|
it('should throw error when module export is not a function', async () => {
|
|
vi.mocked(importModule).mockResolvedValue('not a function');
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
await expect(provider['loadExternalFunction']('file://test.js')).rejects.toThrow(
|
|
'Function callback malformed',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('executeFunctionCallback()', () => {
|
|
it('should execute function callback from config', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue({ result: 'success' });
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
const config = {
|
|
functionToolCallbacks: {
|
|
test_function: mockCallback,
|
|
},
|
|
};
|
|
|
|
const result = await provider['executeFunctionCallback'](
|
|
'test_function',
|
|
'{"arg": "value"}',
|
|
config,
|
|
);
|
|
|
|
expect(mockCallback).toHaveBeenCalledWith('{"arg": "value"}');
|
|
expect(result).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should cache loaded function callbacks', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue({ result: 'success' });
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
const config = {
|
|
functionToolCallbacks: {
|
|
test_function: mockCallback,
|
|
},
|
|
};
|
|
|
|
await provider['executeFunctionCallback']('test_function', '{}', config);
|
|
await provider['executeFunctionCallback']('test_function', '{}', config);
|
|
|
|
// Callback should be cached after first call
|
|
expect(provider['loadedFunctionCallbacks']['test_function']).toBe(mockCallback);
|
|
});
|
|
|
|
it('should load function from file:// reference', async () => {
|
|
const mockFn = vi.fn().mockResolvedValue({ loaded: true });
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
const config = {
|
|
functionToolCallbacks: {
|
|
file_function: 'file://callbacks.js:myCallback',
|
|
},
|
|
};
|
|
|
|
const result = await provider['executeFunctionCallback']('file_function', '{}', config);
|
|
|
|
expect(result).toEqual({ loaded: true });
|
|
});
|
|
|
|
it('should throw error when callback not found', async () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
const config = {
|
|
functionToolCallbacks: {},
|
|
};
|
|
|
|
await expect(
|
|
provider['executeFunctionCallback']('missing_function', '{}', config),
|
|
).rejects.toThrow("No callback found for function 'missing_function'");
|
|
});
|
|
});
|
|
|
|
describe('cleanup()', () => {
|
|
it('should cleanup MCP client when initialized', async () => {
|
|
const mcpConfig = { enabled: true, servers: [] };
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { mcp: mcpConfig },
|
|
});
|
|
|
|
// Wait for MCP initialization
|
|
await vi.waitFor(() => {
|
|
expect(mockMcpInstance.initialize).toHaveBeenCalled();
|
|
});
|
|
|
|
await provider.cleanup();
|
|
|
|
// MCPClient.cleanup should have been called
|
|
expect(mockMcpInstance.cleanup).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle cleanup when no MCP client', async () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
// Should not throw
|
|
await expect(provider.cleanup()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getTimeout()', () => {
|
|
it('should return config timeout when set', () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro', {
|
|
config: { timeoutMs: 60000 },
|
|
});
|
|
|
|
expect(provider['getTimeout']()).toBe(60000);
|
|
});
|
|
|
|
it('should return default timeout when not configured', () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
// Default is REQUEST_TIMEOUT_MS from shared.ts
|
|
expect(provider['getTimeout']()).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('abstract method implementations', () => {
|
|
it('should call getApiEndpoint on subclass', () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
expect(provider.getApiEndpoint()).toBe('https://test-api.example.com/gemini-2.5-pro');
|
|
expect(provider.getApiEndpoint('generateContent')).toBe(
|
|
'https://test-api.example.com/gemini-2.5-pro:generateContent',
|
|
);
|
|
});
|
|
|
|
it('should call getAuthHeaders on subclass', async () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
const headers = await provider.getAuthHeaders();
|
|
|
|
expect(headers).toEqual({ Authorization: 'Bearer test-token' });
|
|
});
|
|
|
|
it('should call callApi on subclass', async () => {
|
|
const provider = new TestGoogleProvider('gemini-2.5-pro');
|
|
|
|
const response = await provider.callApi('Hello');
|
|
|
|
expect(response).toEqual({ output: 'Response to: Hello' });
|
|
});
|
|
});
|
|
});
|