Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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' });
});
});
});