Files
promptfoo--promptfoo/test/index.test.ts
T
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

2265 lines
70 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as cache from '../src/cache';
import { evaluate as doEvaluate } from '../src/evaluator';
import * as index from '../src/index';
import { evaluate } from '../src/index';
import logger from '../src/logger';
import Eval from '../src/models/eval';
import { readProviderPromptMap } from '../src/prompts/index';
import * as providers from '../src/providers/index';
import { doRedteamRun } from '../src/redteam/shared';
import * as fileUtils from '../src/util/file';
import { warnOnDegradedJsonlRecovery, writeMultipleOutputs, writeOutput } from '../src/util/index';
import { createMockProvider } from './factories/provider';
vi.mock('../src/cache');
vi.mock('../src/database', () => ({
getDb: vi.fn().mockReturnValue({ select: vi.fn(), insert: vi.fn(), transaction: vi.fn() }),
}));
vi.mock('../src/evaluator', async () => {
const originalModule =
await vi.importActual<typeof import('../src/evaluator')>('../src/evaluator');
return {
...originalModule,
evaluate: vi.fn().mockImplementation(async (testSuite) => {
const results = (testSuite.tests || []).map((test: any, idx: number) => {
const mergedTest = { ...testSuite.defaultTest, ...test };
if (testSuite.defaultTest?.assert && test.assert) {
mergedTest.assert = [...(testSuite.defaultTest.assert || []), ...(test.assert || [])];
}
if (testSuite.defaultTest?.vars && test.vars) {
mergedTest.vars = { ...testSuite.defaultTest.vars, ...test.vars };
}
return {
testCase: mergedTest,
test: mergedTest,
vars: mergedTest.vars || {},
promptIdx: 0,
testIdx: idx,
success: true,
score: 1,
latencyMs: 100,
namedScores: {},
failureReason: 0,
};
});
return {
results,
toEvaluateSummary: async () => ({ results }),
};
}),
};
});
vi.mock('../src/globalConfig/accounts', async () => {
const originalModule = await vi.importActual<typeof import('../src/globalConfig/accounts')>(
'../src/globalConfig/accounts',
);
return {
...originalModule,
// Pass the override through so tests can verify the full propagation chain.
// Individual tests can override this with mockImplementationOnce.
getAuthor: vi.fn((override?: string | null) => override ?? null),
};
});
vi.mock('../src/migrate');
vi.mock('../src/prompts', async () => {
const originalModule = await vi.importActual<typeof import('../src/prompts')>('../src/prompts');
return {
...originalModule,
readProviderPromptMap: vi.fn().mockReturnValue({}),
};
});
vi.mock('../src/redteam/shared', async () => {
const originalModule =
await vi.importActual<typeof import('../src/redteam/shared')>('../src/redteam/shared');
return {
...originalModule,
doRedteamRun: vi.fn(),
};
});
vi.mock('../src/providers', async () => {
const originalModule =
await vi.importActual<typeof import('../src/providers')>('../src/providers');
return {
...originalModule,
loadApiProvider: vi.fn(),
loadApiProviders: vi.fn(),
};
});
vi.mock('../src/telemetry');
vi.mock('../src/util');
vi.mock('../src/util/file');
describe('index.ts exports', () => {
const expectedNamedExports = [
'ConfigResolutionError',
'EmailValidationError',
'EvalRunError',
'EVENT_SOURCES',
'isCliEventSource',
'MAX_SUGGESTIONS_COUNT',
'PromptSuggestionsRejectedError',
'ServerError',
'assertions',
'buildInputPromptDescription',
'cache',
'evaluate',
'generateTable',
'getInputDescription',
'getInputType',
'guardrails',
'isApiProvider',
'isGradingResult',
'isProviderOptions',
'isResultFailureReason',
'isTransformFunction',
'loadApiProvider',
'loadApiProviders',
'normalizeInputDefinition',
'normalizeInputs',
'ProbeLimitExceededError',
'redteam',
];
const expectedSchemaExports = [
'AssertionOrSetSchema',
'AssertionSchema',
'AssertionSetSchema',
'AssertionTypeSchema',
'AtomicTestCaseSchema',
'BaseAssertionTypesSchema',
'BaseTokenUsageSchema',
'CommandLineOptionsSchema',
'CompletedPromptSchema',
'CompletionTokenDetailsSchema',
'ConversationMessageSchema',
'DerivedMetricSchema',
'DocumentMediaInjectionPlacementSchema',
'DocumentMediaInjectionPlacementValues',
'DocxInjectionPlacementSchema',
'DocxInjectionPlacementValues',
'EvalResultsFilterMode',
'EvaluateOptionsSchema',
'EventSourceSchema',
'GradingConfigSchema',
'InputConfigSchema',
'InputDefinitionObjectSchema',
'InputDefinitionSchema',
'InputTypeSchema',
'InputTypeValues',
'InputsSchema',
'NotPrefixedAssertionTypesSchema',
'OutputConfigSchema',
'OutputFileExtension',
'PartialGenerationError',
'PluginConfigSchema',
'PolicyObjectSchema',
'ProvidersSchema',
'ResultFailureReason',
'ScenarioSchema',
'SpecialAssertionTypesSchema',
'StrategyConfigSchema',
'TestCaseSchema',
'TestCasesWithMetadataPromptSchema',
'TestCasesWithMetadataSchema',
'TestCaseWithVarsFileSchema',
'TestGeneratorConfigSchema',
'TestSuiteConfigSchema',
'TestSuiteSchema',
'UnifiedConfigSchema',
'VarsSchema',
];
it('should export all expected named modules', () => {
expectedNamedExports.forEach((exportName) => {
expect(index).toHaveProperty(exportName);
});
});
it('should export all expected schemas', () => {
expectedSchemaExports.forEach((exportName) => {
expect(index).toHaveProperty(exportName);
});
});
it('should not have unexpected exports', () => {
const actualExports = Object.keys(index)
.filter((key) => key !== 'default')
.sort();
expect(actualExports).toEqual([...expectedNamedExports, ...expectedSchemaExports].sort());
});
it('redteam should have expected properties', () => {
expect(index.redteam).toEqual({
Base: {
Grader: expect.any(Function),
Plugin: expect.any(Function),
},
Extractors: {
extractEntities: expect.any(Function),
extractMcpToolsInfo: expect.any(Function),
extractSystemPurpose: expect.any(Function),
},
Graders: expect.any(Object),
Plugins: expect.any(Object),
Strategies: expect.any(Object),
generate: expect.any(Function),
run: expect.any(Function),
});
});
it('default export should match named exports', () => {
expect(index.default).toEqual({
assertions: index.assertions,
cache: index.cache,
evaluate: index.evaluate,
guardrails: index.guardrails,
loadApiProvider: index.loadApiProvider,
loadApiProviders: index.loadApiProviders,
redteam: index.redteam,
});
});
it('should export cache with correct methods', () => {
expect(cache).toHaveProperty('getCache');
expect(cache).toHaveProperty('fetchWithCache');
expect(cache).toHaveProperty('enableCache');
expect(cache).toHaveProperty('disableCache');
expect(cache).toHaveProperty('clearCache');
expect(cache).toHaveProperty('isCacheEnabled');
expect(cache).toHaveProperty('withCacheEnabled');
});
});
describe('evaluate function', () => {
let loadApiProvidersSpy: ReturnType<typeof vi.spyOn>;
let loadApiProviderSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(cache.withCacheEnabled).mockImplementation((_enabled, fn) => fn());
vi.mocked(warnOnDegradedJsonlRecovery).mockImplementation(() => {});
// Set up spies for provider functions
loadApiProvidersSpy = vi.spyOn(providers, 'loadApiProviders').mockResolvedValue([]);
loadApiProviderSpy = vi
.spyOn(providers, 'loadApiProvider')
.mockResolvedValue(createMockProvider({ id: 'mock-provider' }));
});
afterEach(() => {
loadApiProvidersSpy.mockRestore();
loadApiProviderSpy.mockRestore();
});
it('should handle function prompts correctly', async () => {
const mockPromptFunction = function testPrompt() {
return 'Test prompt';
};
const testSuite = {
prompts: [mockPromptFunction],
providers: [],
tests: [],
};
await index.evaluate(testSuite);
expect(readProviderPromptMap).toHaveBeenCalledWith(testSuite, [
{
raw: mockPromptFunction.toString(),
label: 'testPrompt',
function: mockPromptFunction,
},
]);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
prompts: [
{
raw: mockPromptFunction.toString(),
label: 'testPrompt',
function: mockPromptFunction,
},
],
providerPromptMap: {},
}),
expect.anything(),
expect.objectContaining({
eventSource: 'library',
}),
);
});
it('should pin package callers to library semantics', async () => {
await evaluate(
{
prompts: ['test prompt'],
providers: [],
tests: [],
},
{ eventSource: 'cli' } as never,
);
expect(doEvaluate).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
eventSource: 'library',
}),
);
});
it('should process different types of prompts correctly', async () => {
const testSuite = {
prompts: [
'string prompt',
{ raw: 'object prompt' },
function functionPrompt() {
return 'function prompt';
},
],
providers: [],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
prompts: expect.arrayContaining([
expect.any(Object),
expect.any(Object),
expect.any(Object),
]),
}),
expect.anything(),
expect.any(Object),
);
});
it('should resolve nested providers', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [],
tests: [{ options: { provider: 'openai:gpt-3.5-turbo' } }],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
options: expect.objectContaining({
provider: expect.anything(),
}),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should resolve provider configuration in defaultTest', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [],
defaultTest: {
options: {
provider: {
id: 'azure:chat:test',
config: {
apiHost: 'test-host',
apiKey: 'test-key',
},
},
},
},
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
options: expect.objectContaining({
provider: expect.anything(),
}),
}),
}),
expect.anything(),
expect.anything(),
);
});
it('should resolve provider configuration in individual tests', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [],
tests: [
{
options: {
provider: {
id: 'azure:chat:test',
config: {
apiHost: 'test-host',
apiKey: 'test-key',
},
},
},
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
options: expect.objectContaining({
provider: expect.anything(),
}),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should resolve provider configuration in assertions', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [],
tests: [
{
assert: [
{
type: 'equals' as const,
value: 'expected value',
provider: {
id: 'azure:chat:test',
config: {
apiHost: 'test-host',
apiKey: 'test-key',
},
},
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: expect.anything(),
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should scope cache disabling to the eval when specified', async () => {
vi.mocked(cache.withCacheEnabled).mockImplementation((_enabled, fn) => fn());
await evaluate({ prompts: ['test'], providers: [] }, { cache: false });
expect(cache.withCacheEnabled).toHaveBeenCalledWith(false, expect.any(Function));
});
it('should write results to database when writeLatestResults is true', async () => {
const createEvalSpy = vi.spyOn(Eval, 'create');
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
};
await evaluate(testSuite);
expect(createEvalSpy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ author: null }),
);
createEvalSpy.mockRestore();
});
it('should not persist evaluate-only author in config', async () => {
const createEvalSpy = vi.spyOn(Eval, 'create');
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
author: 'author@example.com',
};
await evaluate(testSuite);
expect(createEvalSpy.mock.calls[0][0]).not.toHaveProperty('author');
createEvalSpy.mockRestore();
});
it('should propagate the testSuite author through getAuthor to Eval.create', async () => {
const { getAuthor } = await import('../src/globalConfig/accounts');
const createEvalSpy = vi.spyOn(Eval, 'create');
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
author: 'programmatic@example.com',
};
await evaluate(testSuite);
expect(vi.mocked(getAuthor)).toHaveBeenCalledWith('programmatic@example.com');
expect(createEvalSpy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ author: 'programmatic@example.com' }),
);
createEvalSpy.mockRestore();
});
it('should let cloud identity win over the testSuite author when cloud auth is active', async () => {
const { getAuthor } = await import('../src/globalConfig/accounts');
vi.mocked(getAuthor).mockReturnValueOnce('cloud@example.com');
const createEvalSpy = vi.spyOn(Eval, 'create');
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
author: 'override@example.com',
};
await evaluate(testSuite);
expect(vi.mocked(getAuthor)).toHaveBeenCalledWith('override@example.com');
expect(createEvalSpy).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ author: 'cloud@example.com' }),
);
createEvalSpy.mockRestore();
});
it('should write output to file when outputPath is set', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
outputPath: 'test.json',
};
await evaluate(testSuite);
expect(writeMultipleOutputs).toHaveBeenCalledWith(['test.json'], expect.any(Eval), null);
});
it('should finalize JSONL files streamed during evaluation', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
outputPath: 'test.jsonl',
};
await evaluate(testSuite);
expect(writeMultipleOutputs).toHaveBeenCalledWith(['test.jsonl'], expect.any(Eval), null);
});
it('finalizes every output path (JSONL included) and warns on degraded recovery', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
outputPath: ['test.jsonl', 'test.json'],
};
await evaluate(testSuite);
// JSONL is no longer filtered out post-run — both paths are finalized — and the recovery
// warning hook is consulted with the full path list.
expect(warnOnDegradedJsonlRecovery).toHaveBeenCalledWith(expect.any(Eval), [
'test.jsonl',
'test.json',
]);
expect(writeMultipleOutputs).toHaveBeenCalledWith(
['test.jsonl', 'test.json'],
expect.any(Eval),
null,
);
});
it('should skip writing output when outputPath is empty', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
outputPath: '',
};
await evaluate(testSuite);
expect(writeOutput).not.toHaveBeenCalled();
expect(writeMultipleOutputs).not.toHaveBeenCalled();
});
it('should write multiple outputs when outputPath is an array', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
outputPath: ['test1.json', 'test2.jsonl', 'test3.json'],
};
await evaluate(testSuite);
expect(writeMultipleOutputs).toHaveBeenCalledWith(
['test1.json', 'test2.jsonl', 'test3.json'],
expect.any(Eval),
null,
);
});
describe('providerMap functionality', () => {
it('should resolve assertion provider by ID from providerMap', async () => {
const mockProvider1 = createMockProvider({
id: 'provider-1',
label: 'Provider One',
});
const mockProvider2 = createMockProvider({ id: 'provider-2' });
// Mock loadApiProviders to return our test providers
loadApiProvidersSpy.mockResolvedValueOnce([mockProvider1, mockProvider2]);
const testSuite = {
prompts: ['test'],
providers: ['provider-1', 'provider-2'],
tests: [
{
assert: [
{
type: 'equals' as const,
value: 'expected',
provider: 'provider-1', // Should resolve from providerMap by ID
},
],
},
],
};
await evaluate(testSuite);
// Verify doEvaluate was called with the resolved provider
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: mockProvider1, // Should be resolved to the actual provider object
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should resolve assertion provider by label from providerMap', async () => {
const mockProvider = createMockProvider({
id: 'azure:chat:gpt-4',
label: 'GPT-4',
});
// Mock loadApiProviders to return our test provider
loadApiProvidersSpy.mockResolvedValueOnce([mockProvider]);
const testSuite = {
prompts: ['test'],
providers: ['azure:chat:gpt-4'],
tests: [
{
assert: [
{
type: 'equals' as const,
value: 'expected',
provider: 'GPT-4', // Should resolve by label from providerMap
},
],
},
],
};
await evaluate(testSuite);
// Verify the provider was resolved by label
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: mockProvider,
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should fall back to loadApiProvider when provider not found in providerMap', async () => {
const mockExistingProvider = createMockProvider({ id: 'existing-provider' });
// Mock loadApiProviders to return existing provider
loadApiProvidersSpy.mockResolvedValueOnce([mockExistingProvider]);
const testSuite = {
prompts: ['test'],
providers: ['existing-provider'],
tests: [
{
assert: [
{
type: 'equals' as const,
value: 'expected',
provider: 'echo',
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: expect.not.objectContaining({ id: mockExistingProvider.id }),
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
const runtimeSuite = vi.mocked(doEvaluate).mock.calls.at(-1)?.[0];
const runtimeAssertion = runtimeSuite?.tests?.[0].assert?.[0];
if (!runtimeAssertion || runtimeAssertion.type === 'assert-set') {
throw new Error('Expected a regular assertion with a fallback provider');
}
expect(runtimeAssertion.provider.id()).toBe('echo');
});
it('should handle providers without labels in providerMap', async () => {
const mockProvider = createMockProvider({ id: 'provider-without-label' });
loadApiProvidersSpy.mockResolvedValueOnce([mockProvider]);
const testSuite = {
prompts: ['test'],
providers: ['provider-without-label'],
tests: [
{
assert: [
{
type: 'equals' as const,
value: 'expected',
provider: 'provider-without-label', // Should resolve by ID only
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: mockProvider,
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('does not let a provider label silently shadow another provider id', async () => {
const providerById = createMockProvider({ id: 'judge' });
const providerWithCollidingLabel = createMockProvider({
id: 'litellm:judge',
label: 'judge',
});
loadApiProvidersSpy.mockResolvedValueOnce([providerById, providerWithCollidingLabel]);
const testSuite = {
prompts: ['test'],
providers: ['judge', 'litellm:judge'],
tests: [
{
assert: [
{
type: 'equals' as const,
value: 'expected',
provider: 'judge',
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: providerById,
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
// Regression for GitHub issue #4111: model-graded assertions that specified
// `provider` as a string ID were not resolved from the main providers
// array/providerMap, causing those assertions to fail.
describe('Model-graded assertions with provider resolution', () => {
it('should resolve model-graded assertions using providers from main array', async () => {
const mockLiteLLMProvider = createMockProvider({
id: 'litellm:gemini-pro',
config: {
apiBaseUrl: 'http://localhost:4000',
apiKey: 'test-key',
temperature: 0.1,
max_tokens: 8096,
},
});
// Mock loadApiProviders to return the LiteLLM provider
loadApiProvidersSpy.mockResolvedValueOnce([mockLiteLLMProvider]);
const testSuite = {
prompts: ['What is the capital of France?'],
providers: [
{
id: 'litellm:gemini-pro',
config: {
apiBaseUrl: 'http://localhost:4000',
apiKey: 'test-key',
temperature: 0.1,
max_tokens: 8096,
},
},
],
tests: [
{
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate if the response correctly identifies Paris as the capital',
threshold: 0.8,
provider: 'litellm:gemini-pro', // String ID that should resolve from providerMap
},
],
},
],
};
await evaluate(testSuite);
// Verify the g-eval assertion was resolved with the correct provider
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
type: 'g-eval',
provider: mockLiteLLMProvider, // Should be resolved to the provider object
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should resolve different providers for response and model-graded assertions', async () => {
const mockResponseProvider = createMockProvider({ id: 'litellm:gpt-4' });
const mockGevalProvider = createMockProvider({ id: 'litellm:gemini-pro' });
// Mock loadApiProviders to return both providers
loadApiProvidersSpy.mockResolvedValueOnce([mockResponseProvider, mockGevalProvider]);
const testSuite = {
prompts: ['What is the capital of France?'],
providers: ['litellm:gpt-4', 'litellm:gemini-pro'], // Both providers in main array
tests: [
{
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate correctness',
provider: 'litellm:gemini-pro', // Different provider for g-eval
},
],
},
],
};
await evaluate(testSuite);
// Verify g-eval uses the gemini provider while main response uses gpt-4
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
providers: expect.arrayContaining([mockResponseProvider, mockGevalProvider]),
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: mockGevalProvider, // G-eval should use gemini provider
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should handle multiple model-graded assertions with same provider', async () => {
const mockLiteLLMProvider = createMockProvider({ id: 'litellm:claude-3' });
loadApiProvidersSpy.mockResolvedValueOnce([mockLiteLLMProvider]);
const testSuite = {
prompts: ['Explain quantum physics'],
providers: ['litellm:claude-3'],
tests: [
{
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate scientific accuracy',
provider: 'litellm:claude-3',
},
{
type: 'g-eval' as const,
value: 'Evaluate clarity of explanation',
provider: 'litellm:claude-3',
},
],
},
],
};
await evaluate(testSuite);
// Both g-eval assertions should resolve to the same provider object
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
type: 'g-eval',
provider: mockLiteLLMProvider,
}),
expect.objectContaining({
type: 'g-eval',
provider: mockLiteLLMProvider,
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should resolve configured providers within grading provider type maps', async () => {
const mockLiteLLMProvider = createMockProvider({ id: 'litellm:judge' });
loadApiProvidersSpy.mockResolvedValueOnce([mockLiteLLMProvider]);
const testSuite = {
prompts: ['Test prompt'],
providers: ['litellm:judge'],
defaultTest: {
options: {
provider: { text: { id: 'litellm:judge' } },
},
},
tests: [
{
options: {
provider: { text: 'litellm:judge' },
},
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate response',
provider: { text: 'litellm:judge' },
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
options: expect.objectContaining({
provider: { text: mockLiteLLMProvider },
}),
}),
tests: expect.arrayContaining([
expect.objectContaining({
options: expect.objectContaining({
provider: { text: mockLiteLLMProvider },
}),
assert: expect.arrayContaining([
expect.objectContaining({
provider: { text: mockLiteLLMProvider },
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('does not eagerly load unused provider types from grading provider maps', async () => {
const mockLiteLLMProvider = createMockProvider({ id: 'litellm:judge' });
loadApiProvidersSpy.mockResolvedValueOnce([mockLiteLLMProvider]);
const testSuite = {
prompts: ['Test prompt'],
providers: ['litellm:judge'],
defaultTest: {
options: {
provider: {
text: 'litellm:judge',
embedding: 'unsupported-provider:unused-embedding',
},
},
},
tests: [
{
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate response',
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
options: expect.objectContaining({
provider: {
text: mockLiteLLMProvider,
embedding: 'unsupported-provider:unused-embedding',
},
}),
}),
}),
expect.anything(),
expect.anything(),
);
});
it('preserves suite env for deferred grading provider map entries', async () => {
const mockTargetProvider = createMockProvider({ id: 'echo' });
loadApiProvidersSpy.mockResolvedValueOnce([mockTargetProvider]);
const testSuite = {
env: { GRADER_API_KEY: 'suite-key' },
prompts: ['Test prompt'],
providers: ['echo'],
defaultTest: {
options: {
provider: {
text: {
id: 'litellm:inline-judge',
config: { apiKey: '{{ env.GRADER_API_KEY }}' },
},
embedding: 'unsupported-provider:unused-embedding',
},
},
},
tests: [
{
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate response',
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
options: expect.objectContaining({
provider: {
text: {
id: 'litellm:inline-judge',
config: { apiKey: '{{ env.GRADER_API_KEY }}' },
env: { GRADER_API_KEY: 'suite-key' },
},
embedding: {
id: 'unsupported-provider:unused-embedding',
env: { GRADER_API_KEY: 'suite-key' },
},
},
}),
}),
}),
expect.anything(),
expect.anything(),
);
});
it('should fall back to loadApiProvider for model-graded assertions when provider not in main array', async () => {
const mockMainProvider = createMockProvider({ id: 'litellm:gpt-4' });
// Mock main providers array (only has gpt-4)
loadApiProvidersSpy.mockResolvedValueOnce([mockMainProvider]);
const testSuite = {
prompts: ['Test prompt'],
providers: ['litellm:gpt-4'], // Only gpt-4 in main providers
tests: [
{
assert: [
{
type: 'g-eval' as const,
value: 'Evaluate response',
provider: 'echo',
},
],
},
],
};
await evaluate(testSuite);
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
type: 'g-eval',
provider: expect.not.objectContaining({ id: mockMainProvider.id }),
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
const runtimeSuite = vi.mocked(doEvaluate).mock.calls.at(-1)?.[0];
const runtimeAssertion = runtimeSuite?.tests?.[0].assert?.[0];
if (!runtimeAssertion || runtimeAssertion.type === 'assert-set') {
throw new Error('Expected a regular assertion with a fallback provider');
}
expect(runtimeAssertion.provider.id()).toBe('echo');
});
});
});
});
describe('redteam package wrapper', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(doRedteamRun).mockResolvedValue(undefined);
});
afterEach(() => {
vi.mocked(doRedteamRun).mockReset();
});
it('should pin package callers to library semantics', async () => {
await index.redteam.run({ eventSource: 'cli' } as never);
expect(doRedteamRun).toHaveBeenCalledWith(
expect.objectContaining({
eventSource: 'library',
}),
);
});
});
describe('evaluate with external defaultTest', () => {
let loadApiProvidersSpy: ReturnType<typeof vi.spyOn>;
let loadApiProviderSpy: ReturnType<typeof vi.spyOn>;
let maybeLoadFromExternalFileSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(cache.withCacheEnabled).mockImplementation((_enabled, fn) => fn());
// Set up spies for provider functions
loadApiProvidersSpy = vi.spyOn(providers, 'loadApiProviders').mockResolvedValue([]);
loadApiProviderSpy = vi
.spyOn(providers, 'loadApiProvider')
.mockResolvedValue(createMockProvider({ id: 'mock-provider' }));
maybeLoadFromExternalFileSpy = vi.mocked(fileUtils.maybeLoadFromExternalFile);
});
afterEach(() => {
loadApiProvidersSpy.mockRestore();
loadApiProviderSpy.mockRestore();
});
it('should load defaultTest from external file when using file:// syntax', async () => {
const externalDefaultTest = {
assert: [{ type: 'equals' as const, value: 'test' }],
vars: { foo: 'bar' },
options: { provider: 'openai:gpt-4' },
};
const mockApiProvider = createMockProvider({
id: 'mock-provider',
response: { output: 'test output' },
});
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
maybeLoadFromExternalFileSpy.mockResolvedValueOnce(externalDefaultTest);
const testSuite = {
providers: ['mock-provider'],
prompts: [{ raw: 'Test prompt', label: 'Test' }],
tests: [{ vars: { test: 'value' } }],
defaultTest: 'file://path/to/defaultTest.yaml',
};
const summary = await evaluate(testSuite, {});
expect(fileUtils.maybeLoadFromExternalFile).toHaveBeenCalledWith(
'file://path/to/defaultTest.yaml',
);
const result = summary.results[0] as any;
expect(result.testCase.assert).toEqual(externalDefaultTest.assert);
});
it('should handle inline defaultTest objects', async () => {
const inlineDefaultTest = {
assert: [{ type: 'contains' as const, value: 'inline' }],
vars: { inline: true },
};
const mockApiProvider = createMockProvider({
id: 'mock-provider',
response: { output: 'test output' },
});
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
const testSuite = {
providers: ['mock-provider'],
prompts: [{ raw: 'Test prompt', label: 'Test' }],
tests: [{ vars: { test: 'value' } }],
defaultTest: inlineDefaultTest,
};
const summary = await evaluate(testSuite, {});
const result = summary.results[0] as any;
expect(result.test.assert).toEqual(inlineDefaultTest.assert);
expect(result.test.vars).toMatchObject(inlineDefaultTest.vars);
});
it('should handle missing external defaultTest file gracefully', async () => {
const mockApiProvider = createMockProvider({
id: 'mock-provider',
response: { output: 'test output' },
});
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
const testSuite = {
providers: ['mock-provider'],
prompts: [{ raw: 'Test prompt', label: 'Test' }],
tests: [{ vars: { test: 'value' } }],
defaultTest: 'file://nonexistent.yaml',
};
maybeLoadFromExternalFileSpy.mockRejectedValueOnce(
new Error('File does not exist: /Users/mdangelo/projects/pf-codium/nonexistent.yaml'),
);
await expect(evaluate(testSuite, {})).rejects.toThrow('File does not exist');
});
it('should not load external file when defaultTest is not a file:// string', async () => {
const mockApiProvider = createMockProvider({
id: 'mock-provider',
response: { output: 'test output' },
});
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
const testSuite = {
providers: ['mock-provider'],
prompts: [{ raw: 'Test prompt', label: 'Test' }],
tests: [{ vars: { test: 'value' } }],
defaultTest: undefined,
};
const summary = await evaluate(testSuite, {});
expect(fileUtils.maybeLoadFromExternalFile).not.toHaveBeenCalled();
expect(summary.results).toHaveLength(1);
});
describe('ApiProvider instances in configurations', () => {
let mockApiProvider: any;
let mockCustomProvider: any;
let resolveProviderSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockApiProvider = createMockProvider({
id: 'mock-api-provider',
response: { output: 'mock response' },
});
mockCustomProvider = createMockProvider({
id: 'custom-validator',
response: { output: 'custom validation response' },
});
// Mock resolveProvider to track calls and return mock providers
resolveProviderSpy = vi
.spyOn(providers, 'resolveProvider')
.mockImplementation(async (provider) => {
if (typeof provider === 'string') {
return createMockProvider({ id: provider });
}
if (typeof provider === 'object' && 'id' in provider && typeof provider.id === 'string') {
// This is a ProviderOptions object
return createMockProvider({ id: provider.id as string });
}
// This shouldn't be called for ApiProvider instances due to our fix
return provider;
});
});
afterEach(() => {
resolveProviderSpy.mockRestore();
});
it('should not resolve ApiProvider instances in defaultTest.options.provider', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [mockApiProvider],
tests: [{ vars: { test: 'value' } }],
defaultTest: {
options: {
provider: mockCustomProvider, // ApiProvider instance
},
},
};
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
await evaluate(testSuite);
// Verify that resolveProvider was NOT called with the ApiProvider instance
expect(resolveProviderSpy).not.toHaveBeenCalledWith(
mockCustomProvider,
expect.any(Object),
expect.any(Object),
);
// Verify the ApiProvider instance is passed through unchanged
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
options: expect.objectContaining({
provider: mockCustomProvider, // Should be the same instance
}),
}),
}),
expect.anything(),
expect.anything(),
);
});
it('should not resolve ApiProvider instances in defaultTest.provider', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [mockApiProvider],
tests: [{ vars: { test: 'value' } }],
defaultTest: {
provider: mockCustomProvider, // ApiProvider instance
},
};
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
await evaluate(testSuite);
// Verify that resolveProvider was NOT called with the ApiProvider instance
expect(resolveProviderSpy).not.toHaveBeenCalledWith(
mockCustomProvider,
expect.any(Object),
expect.any(Object),
);
// Verify the ApiProvider instance is passed through unchanged
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
provider: mockCustomProvider, // Should be the same instance
}),
}),
expect.anything(),
expect.anything(),
);
});
it('should not resolve ApiProvider instances in test.options.provider', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [mockApiProvider],
tests: [
{
vars: { test: 'value' },
options: {
provider: mockCustomProvider, // ApiProvider instance
},
},
],
};
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
await evaluate(testSuite);
// Verify that resolveProvider was NOT called with the ApiProvider instance
expect(resolveProviderSpy).not.toHaveBeenCalledWith(
mockCustomProvider,
expect.any(Object),
expect.any(Object),
);
// Verify the ApiProvider instance is passed through unchanged
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
options: expect.objectContaining({
provider: mockCustomProvider, // Should be the same instance
}),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should not resolve ApiProvider instances in assertion.provider', async () => {
const testSuite = {
prompts: ['test prompt'],
providers: [mockApiProvider],
tests: [
{
vars: { test: 'value' },
assert: [
{
type: 'llm-rubric' as const,
value: 'Test assertion',
provider: mockCustomProvider, // ApiProvider instance
},
],
},
],
};
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
await evaluate(testSuite);
// Verify that resolveProvider was NOT called with the ApiProvider instance
expect(resolveProviderSpy).not.toHaveBeenCalledWith(
mockCustomProvider,
expect.any(Object),
expect.any(Object),
);
// Verify the ApiProvider instance is passed through unchanged
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
tests: expect.arrayContaining([
expect.objectContaining({
assert: expect.arrayContaining([
expect.objectContaining({
provider: mockCustomProvider, // Should be the same instance
}),
]),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
it('should verify ApiProvider instances are preserved through the evaluation', async () => {
// This test focuses on the key behavior: ApiProvider instances should be passed through unchanged
const testSuite = {
prompts: ['test prompt'],
providers: [mockApiProvider],
tests: [
{
vars: { test: 'value' },
options: {
provider: mockCustomProvider, // ApiProvider instance
},
},
],
defaultTest: {
options: {
provider: mockCustomProvider, // ApiProvider instance
},
assert: [
{
type: 'llm-rubric' as const,
value: 'Test assertion',
provider: mockCustomProvider, // ApiProvider instance
},
],
},
};
loadApiProvidersSpy.mockResolvedValueOnce([mockApiProvider]);
await evaluate(testSuite);
// The key test: verify that ApiProvider instances were NOT passed to resolveProvider
expect(resolveProviderSpy).not.toHaveBeenCalledWith(
mockCustomProvider,
expect.any(Object),
expect.any(Object),
);
// Verify that the ApiProvider instances are preserved in the final test suite
expect(doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
defaultTest: expect.objectContaining({
options: expect.objectContaining({
provider: mockCustomProvider, // Same instance
}),
assert: expect.arrayContaining([
expect.objectContaining({
provider: mockCustomProvider, // Same instance
}),
]),
}),
tests: expect.arrayContaining([
expect.objectContaining({
options: expect.objectContaining({
provider: mockCustomProvider, // Same instance
}),
}),
]),
}),
expect.anything(),
expect.anything(),
);
});
});
// Regression test for #8687: resolved providers must not mutate the input
// testSuite, because SDK clients can attach circular references at runtime
// and break JSON serialization when those mutated objects are reused.
describe('input testSuite mutation safety', () => {
let resolveProviderSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.mocked(doEvaluate).mockClear();
resolveProviderSpy = vi
.spyOn(providers, 'resolveProvider')
.mockImplementation(async (provider) => {
const id =
typeof provider === 'string'
? provider
: typeof provider === 'object' && provider && 'id' in provider
? (provider as { id: string }).id
: 'resolved-provider';
return { id: () => id, callApi: vi.fn() as any };
});
});
afterEach(() => {
resolveProviderSpy.mockRestore();
});
it('does not mutate testSuite.defaultTest.options.provider when resolving for the runtime suite', async () => {
const rawProviderConfig = {
id: 'bedrock:anthropic.claude-3-haiku-20240307-v1:0',
config: { region: 'us-east-1' },
};
const testSuite = {
prompts: ['test prompt'],
providers: [],
defaultTest: {
options: {
provider: rawProviderConfig,
},
},
tests: [{ vars: { x: 'y' } }],
};
const originalDefaultTest = testSuite.defaultTest;
const originalOptions = testSuite.defaultTest.options;
await evaluate(testSuite);
expect(testSuite.defaultTest).toBe(originalDefaultTest);
expect(testSuite.defaultTest.options).toBe(originalOptions);
expect(testSuite.defaultTest.options.provider).toBe(rawProviderConfig);
expect(testSuite.defaultTest.options.provider).toEqual({
id: 'bedrock:anthropic.claude-3-haiku-20240307-v1:0',
config: { region: 'us-east-1' },
});
});
it('does not mutate testSuite.defaultTest.provider when resolving it', async () => {
const rawProviderConfig = {
id: 'anthropic:messages:claude-3-haiku',
config: { apiKey: 'sk-test' },
};
const testSuite = {
prompts: ['test prompt'],
providers: [],
defaultTest: {
provider: rawProviderConfig,
},
tests: [{ vars: { x: 'y' } }],
};
await evaluate(testSuite);
expect(testSuite.defaultTest.provider).toBe(rawProviderConfig);
expect(testSuite.defaultTest.provider).toEqual({
id: 'anthropic:messages:claude-3-haiku',
config: { apiKey: 'sk-test' },
});
});
it('does not mutate inline test.options.provider or assertion.provider', async () => {
const rawTestProviderConfig = { id: 'bedrock:test-model', config: { region: 'eu-west-1' } };
const rawAssertProviderConfig = {
id: 'anthropic:messages:claude-3-haiku',
config: { apiKey: 'sk-test' },
};
const inlineTest = {
vars: { x: 'y' },
options: { provider: rawTestProviderConfig },
assert: [
{
type: 'llm-rubric' as const,
value: 'checks the thing',
provider: rawAssertProviderConfig,
},
],
};
const testSuite = {
prompts: ['test prompt'],
providers: [],
tests: [inlineTest],
};
await evaluate(testSuite);
expect(inlineTest.options.provider).toBe(rawTestProviderConfig);
expect(inlineTest.assert[0].provider).toBe(rawAssertProviderConfig);
});
it('only clones siblings of test cases that need provider resolution; unaffected siblings keep raw config', async () => {
const rawProviderConfig = { id: 'bedrock:test-model', config: { region: 'eu-west-1' } };
const testWithProvider = {
vars: { x: '1' },
options: { provider: rawProviderConfig },
};
const testWithoutProvider = { vars: { x: '2' } };
const testSuite = {
prompts: ['test prompt'],
providers: [],
tests: [testWithProvider, testWithoutProvider],
};
await evaluate(testSuite);
expect(testWithProvider.options.provider).toBe(rawProviderConfig);
expect(testSuite.tests[1]).toBe(testWithoutProvider);
});
it('passes the resolved provider to doEvaluate on a cloned defaultTest', async () => {
const rawProviderConfig = {
id: 'bedrock:anthropic.claude-3-haiku-20240307-v1:0',
config: { region: 'us-east-1' },
};
const testSuite = {
prompts: ['test prompt'],
providers: [],
defaultTest: {
options: {
provider: rawProviderConfig,
},
},
tests: [{ vars: { x: 'y' } }],
};
await evaluate(testSuite);
// The runtime defaultTest must be a different object than the input so the
// lazy SDK-client mutation downstream cannot leak back into the input that the
// unified config aliases.
const passedTestSuite = vi.mocked(doEvaluate).mock.calls.at(-1)?.[0];
expect(passedTestSuite).toBeDefined();
expect(passedTestSuite!.defaultTest).not.toBe(testSuite.defaultTest);
expect((passedTestSuite!.defaultTest as { options: unknown }).options).not.toBe(
testSuite.defaultTest.options,
);
expect(
(
passedTestSuite!.defaultTest as { options: { provider: { id: () => string } } }
).options.provider.id(),
).toBe('bedrock:anthropic.claude-3-haiku-20240307-v1:0');
});
it('produces an Eval config that is JSON-serializable even when the resolved provider gains circular references', async () => {
// Mimic the AWS / Anthropic SDK pattern: ApiProvider instance whose first
// `callApi` lazily attaches a client that holds a circular reference. Without
// the fix, the resolved instance was stored on the input, and once the cycle
// appeared the unified config persisted via drizzle's text-json column threw
// "Converting circular structure to JSON ... AwsRestJsonProtocol.serdeContext".
type ResolvedProvider = {
id: () => string;
callApi: ReturnType<typeof vi.fn>;
sdkClient?: { self?: unknown };
};
const resolvedProvider: ResolvedProvider = {
id: () => 'bedrock:resolved',
callApi: vi.fn().mockImplementation(async function (this: ResolvedProvider) {
const cyclic: { self?: unknown } = {};
cyclic.self = cyclic;
this.sdkClient = cyclic;
return { output: 'ok' };
}),
};
resolveProviderSpy.mockResolvedValue(resolvedProvider);
const createEvalSpy = vi.spyOn(Eval, 'create');
const testSuite = {
prompts: ['test prompt'],
providers: [],
defaultTest: {
options: {
provider: {
id: 'bedrock:anthropic.claude-3-haiku-20240307-v1:0',
config: { region: 'us-east-1' },
},
},
},
tests: [{ vars: { x: 'y' } }],
writeLatestResults: true,
};
await evaluate(testSuite);
await (resolvedProvider.callApi as unknown as (...args: unknown[]) => Promise<unknown>)(
'anything',
{},
{},
);
expect(resolvedProvider.sdkClient).toBeDefined();
// Direct check on the actual production failure mode: the config object handed
// to Eval.create is what drizzle JSON-serializes via the text-json column.
const persistedConfig = createEvalSpy.mock.calls.at(-1)?.[0];
expect(persistedConfig).toBeDefined();
expect(() => JSON.stringify(persistedConfig)).not.toThrow();
// The input itself must also be safe — library callers may persist or log it.
expect(() => JSON.stringify(testSuite)).not.toThrow();
const passedTestSuite = vi.mocked(doEvaluate).mock.calls.at(-1)?.[0];
expect(passedTestSuite).toBeDefined();
expect(
(passedTestSuite!.defaultTest as { options: { provider: { id: () => string } } }).options
.provider,
).toBe(resolvedProvider);
expect(
(
passedTestSuite!.defaultTest as { options: { provider: { id: () => string } } }
).options.provider.id(),
).toBe('bedrock:resolved');
createEvalSpy.mockRestore();
});
it('serializes live ApiProvider references before persisting the Eval config', async () => {
const cyclicClient: Record<string, unknown> = {};
cyclicClient.self = cyclicClient;
const liveProvider = createMockProvider({
id: 'live-provider',
label: 'Live Provider',
config: { region: 'us-east-1' },
}) as ReturnType<typeof createMockProvider> & { sdkClient?: unknown };
liveProvider.sdkClient = cyclicClient;
loadApiProvidersSpy.mockResolvedValueOnce([liveProvider]);
const createEvalSpy = vi.spyOn(Eval, 'create');
const testSuite = {
prompts: ['test prompt'],
providers: [liveProvider],
defaultTest: {
provider: liveProvider,
options: {
provider: liveProvider,
},
},
tests: [
{
options: {
provider: liveProvider,
},
assert: [
{
type: 'llm-rubric' as const,
value: 'looks good',
provider: liveProvider,
},
],
},
],
scenarios: [
{
config: [{}],
tests: [
{
options: {
provider: liveProvider,
},
},
],
},
],
writeLatestResults: true,
};
await evaluate(testSuite);
const persistedConfig = createEvalSpy.mock.calls.at(-1)?.[0] as {
providers?: unknown;
defaultTest?: {
provider?: unknown;
options?: { provider?: unknown };
};
tests?: Array<{
options?: { provider?: unknown };
assert?: Array<{ provider?: unknown }>;
}>;
scenarios?: Array<{ tests?: Array<{ options?: { provider?: unknown } }> }>;
};
const serializableProvider = {
id: 'live-provider',
label: 'Live Provider',
config: { region: 'us-east-1' },
};
expect(() => JSON.stringify(persistedConfig)).not.toThrow();
expect(persistedConfig.providers).toEqual([serializableProvider]);
expect(persistedConfig.defaultTest?.provider).toEqual(serializableProvider);
expect(persistedConfig.defaultTest?.options?.provider).toEqual(serializableProvider);
expect(persistedConfig.tests?.[0].options?.provider).toEqual(serializableProvider);
expect(persistedConfig.tests?.[0].assert?.[0].provider).toEqual(serializableProvider);
expect(persistedConfig.scenarios?.[0].tests?.[0].options?.provider).toEqual(
serializableProvider,
);
expect(JSON.stringify(persistedConfig)).not.toContain('sdkClient');
createEvalSpy.mockRestore();
});
it('replaces function-valued transforms with "[inline function]" markers in the persisted config and warns', async () => {
// Function transforms are first-class at runtime but not JSON-serializable.
// When writeLatestResults is true they must not silently vanish from the
// persisted config — replace with a named marker and surface a warning.
const providerFn = () => Promise.resolve({ output: 'raw' });
loadApiProvidersSpy.mockResolvedValueOnce([createMockProvider({ id: 'mock-provider' })]);
function namedTransform(output: unknown) {
return String(output).toUpperCase();
}
const anonymousTransform = (output: unknown) => output;
const transformVarsFn = (vars: unknown) => vars as Record<string, unknown>;
const contextTransformFn = () => 'context';
const createEvalSpy = vi.spyOn(Eval, 'create');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const testSuite = {
prompts: ['p'],
providers: [providerFn],
defaultTest: {
options: { transform: namedTransform },
},
tests: [
{
vars: { q: 'x' },
options: {
transform: namedTransform,
transformVars: transformVarsFn,
},
assert: [
{
type: 'contains' as const,
value: 'x',
transform: anonymousTransform,
contextTransform: contextTransformFn,
},
],
},
],
scenarios: [
{
config: [{}],
tests: [{ options: { transform: namedTransform } }],
},
],
writeLatestResults: true,
};
await evaluate(testSuite);
const persistedConfig = createEvalSpy.mock.calls.at(-1)?.[0] as {
defaultTest?: { options?: { transform?: unknown } };
tests?: Array<{
options?: { transform?: unknown; transformVars?: unknown };
assert?: Array<{ transform?: unknown; contextTransform?: unknown }>;
}>;
scenarios?: Array<{ tests?: Array<{ options?: { transform?: unknown } }> }>;
};
// Named functions get their name in the marker; anonymous ones get the
// container-property name via JS function-name inference (here: "transform"
// and "contextTransform" via property-assignment naming).
expect(persistedConfig.defaultTest?.options?.transform).toBe(
'[inline function]: namedTransform',
);
expect(persistedConfig.tests?.[0].options?.transform).toBe(
'[inline function]: namedTransform',
);
expect(persistedConfig.tests?.[0].options?.transformVars).toBe(
'[inline function]: transformVarsFn',
);
expect(typeof persistedConfig.tests?.[0].assert?.[0].transform).toBe('string');
expect(persistedConfig.tests?.[0].assert?.[0].transform).toMatch(/^\[inline function\]/);
expect(typeof persistedConfig.tests?.[0].assert?.[0].contextTransform).toBe('string');
expect(persistedConfig.tests?.[0].assert?.[0].contextTransform).toMatch(
/^\[inline function\]/,
);
expect(persistedConfig.scenarios?.[0].tests?.[0].options?.transform).toBe(
'[inline function]: namedTransform',
);
// Every transform field must survive JSON round-trip (the regression we guard).
const roundTripped = JSON.parse(JSON.stringify(persistedConfig)) as typeof persistedConfig;
expect(roundTripped.tests?.[0].options?.transform).toBe('[inline function]: namedTransform');
expect(roundTripped.tests?.[0].assert?.[0].transform).toMatch(/^\[inline function\]/);
// Warning surfaces the serialization substitution. Assert on content, not
// call count — a future refactor that splits this into multiple lines
// should still satisfy the guarantee.
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[inline function]'));
createEvalSpy.mockRestore();
warnSpy.mockRestore();
});
it('does not warn or rewrite persisted config when no function transforms are present', async () => {
loadApiProvidersSpy.mockResolvedValueOnce([createMockProvider({ id: 'mock-provider' })]);
const createEvalSpy = vi.spyOn(Eval, 'create');
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const testSuite = {
prompts: ['p'],
providers: ['mock-provider'],
tests: [
{
vars: { q: 'x' },
options: { transform: 'output.toUpperCase()' },
},
],
writeLatestResults: true,
};
await evaluate(testSuite);
const persistedConfig = createEvalSpy.mock.calls.at(-1)?.[0] as {
tests?: Array<{ options?: { transform?: unknown } }>;
};
expect(persistedConfig.tests?.[0].options?.transform).toBe('output.toUpperCase()');
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[inline function]'));
createEvalSpy.mockRestore();
warnSpy.mockRestore();
});
it('does not emit the serialization warning when writeLatestResults is false', async () => {
loadApiProvidersSpy.mockResolvedValueOnce([createMockProvider({ id: 'mock-provider' })]);
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
function fn(output: unknown) {
return output;
}
await evaluate({
prompts: ['p'],
providers: ['mock-provider'],
tests: [{ vars: { q: 'x' }, options: { transform: fn } }],
// writeLatestResults omitted → no warning even though the function is present
});
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[inline function]'));
warnSpy.mockRestore();
});
it('passes scenario-nested test cases through unchanged (provider resolution there is the evaluator runtime path, not evaluate())', async () => {
// Pin current behavior: the Node evaluate surface only resolves providers on
// `constructedTestSuite.tests`, NOT on `scenarios[i].tests`. If a future change
// adds scenario provider resolution at this layer, it must extend
// `cloneTestForResolve` coverage to scenarios — otherwise #8687 recurs.
const rawProviderConfig = {
id: 'bedrock:anthropic.claude-3-haiku-20240307-v1:0',
config: { region: 'us-east-1' },
};
const scenarioTest = { vars: { x: 'y' }, options: { provider: rawProviderConfig } };
const testSuite = {
prompts: ['test prompt'],
providers: [],
scenarios: [{ config: [{}], tests: [scenarioTest] }],
tests: [{ vars: { z: 'w' } }],
};
await evaluate(testSuite);
expect(scenarioTest.options.provider).toBe(rawProviderConfig);
expect(() => JSON.stringify(testSuite)).not.toThrow();
});
});
});
describe('evaluate sharing functionality', () => {
let loadApiProvidersSpy: ReturnType<typeof vi.spyOn>;
let createShareableUrlMock: ReturnType<typeof vi.fn>;
let isSharingEnabledMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(cache.withCacheEnabled).mockImplementation((_enabled, fn) => fn());
// Set up spies for provider functions
loadApiProvidersSpy = vi.spyOn(providers, 'loadApiProviders').mockResolvedValue([]);
// Mock the share module
const shareModule = await import('../src/share');
createShareableUrlMock = vi
.spyOn(shareModule, 'createShareableUrl')
.mockResolvedValue(null) as unknown as ReturnType<typeof vi.fn>;
isSharingEnabledMock = vi
.spyOn(shareModule, 'isSharingEnabled')
.mockReturnValue(false) as unknown as ReturnType<typeof vi.fn>;
});
afterEach(() => {
loadApiProvidersSpy.mockRestore();
createShareableUrlMock.mockRestore();
isSharingEnabledMock.mockRestore();
});
it('should create shareable URL when sharing is enabled', async () => {
const mockUrl = 'https://app.promptfoo.dev/eval/test-123';
isSharingEnabledMock.mockReturnValue(true);
createShareableUrlMock.mockResolvedValue(mockUrl);
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
sharing: true,
};
const result = await evaluate(testSuite);
expect(createShareableUrlMock).toHaveBeenCalledWith(expect.anything(), { silent: true });
expect(result.shareableUrl).toBe(mockUrl);
expect(result.shared).toBe(true);
});
it('should not share when writeLatestResults is false', async () => {
isSharingEnabledMock.mockReturnValue(true);
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: false,
sharing: true,
};
await evaluate(testSuite);
expect(createShareableUrlMock).not.toHaveBeenCalled();
});
it('should not share when sharing is not enabled in config', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
sharing: false,
};
await evaluate(testSuite);
expect(createShareableUrlMock).not.toHaveBeenCalled();
});
it('should not share when sharing is undefined', async () => {
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
};
await evaluate(testSuite);
expect(createShareableUrlMock).not.toHaveBeenCalled();
});
it('should not call createShareableUrl when isSharingEnabled returns false', async () => {
isSharingEnabledMock.mockReturnValue(false);
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
sharing: true,
};
await evaluate(testSuite);
expect(isSharingEnabledMock).toHaveBeenCalled();
expect(createShareableUrlMock).not.toHaveBeenCalled();
});
it('should handle sharing errors gracefully', async () => {
isSharingEnabledMock.mockReturnValue(true);
createShareableUrlMock.mockRejectedValue(new Error('Network error'));
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
sharing: true,
};
// Should not throw
const result = await evaluate(testSuite);
expect(result.shareableUrl).toBeUndefined();
expect(result.shared).toBeFalsy();
});
it('should handle null URL from createShareableUrl', async () => {
isSharingEnabledMock.mockReturnValue(true);
createShareableUrlMock.mockResolvedValue(null);
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
sharing: true,
};
const result = await evaluate(testSuite);
expect(result.shareableUrl).toBeUndefined();
expect(result.shared).toBeFalsy();
});
it('should support sharing config object', async () => {
const mockUrl = 'https://custom.server.com/eval/test-123';
isSharingEnabledMock.mockReturnValue(true);
createShareableUrlMock.mockResolvedValue(mockUrl);
const testSuite = {
prompts: ['test'],
providers: [],
writeLatestResults: true,
sharing: {
apiBaseUrl: 'https://custom.server.com/api',
appBaseUrl: 'https://custom.server.com',
},
};
const result = await evaluate(testSuite);
expect(result.shareableUrl).toBe(mockUrl);
expect(result.shared).toBe(true);
});
});