Files
promptfoo--promptfoo/test/per-test-config.test.ts
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
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

384 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runEval } from '../src/evaluator';
import { createEmptyTokenUsage } from '../src/util/tokenUsageUtils';
import type { ApiProvider, Prompt, TestCase } from '../src/types/index';
describe('Per-test configuration merging', () => {
let mockProvider: ApiProvider;
let mockPrompt: Prompt;
beforeEach(() => {
vi.resetAllMocks();
// Mock provider that captures the config it receives
mockProvider = {
id: vi.fn(() => 'test-provider'),
label: 'Test Provider',
config: {
temperature: 0.5,
provider_field: 'provider_value',
},
callApi: vi.fn(async (_prompt, context) => {
// Return the config so we can verify it was merged correctly
return {
output: JSON.stringify({ received_config: context?.prompt?.config }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
mockPrompt = {
id: 'test-prompt',
raw: 'Test prompt: {{input}}',
label: 'Test Prompt',
config: {
prompt_field: 'prompt_value',
temperature: 0.7, // Override provider
},
};
});
afterEach(() => {
vi.resetAllMocks();
});
it('should merge test.options into prompt.config', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {
test_field: 'test_value',
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
expect(mockProvider.callApi).toHaveBeenCalled();
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Verify config merge
expect(context.prompt.config).toMatchObject({
prompt_field: 'prompt_value',
temperature: 0.7,
test_field: 'test_value', // Added from test.options
});
});
it('should allow test.options to override prompt.config', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {
temperature: 1.0, // Override prompt's temperature
response_format: { type: 'json_object' },
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Test options should override prompt config
expect(context.prompt.config.temperature).toBe(1.0);
expect(context.prompt.config.response_format).toEqual({ type: 'json_object' });
// Prompt field should still be there
expect(context.prompt.config.prompt_field).toBe('prompt_value');
});
it('should do shallow merge (replace nested objects)', async () => {
mockPrompt.config = {
nested: {
field1: 'value1',
field2: 'value2',
},
};
const test: TestCase = {
vars: { input: 'test' },
options: {
nested: {
field2: 'override', // Only field2
},
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Shallow merge: nested object is completely replaced
expect(context.prompt.config.nested).toEqual({
field2: 'override',
});
// field1 is lost (not deep merged)
expect(context.prompt.config.nested.field1).toBeUndefined();
});
it('should handle undefined test.options', async () => {
const test: TestCase = {
vars: { input: 'test' },
// No options
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Should use prompt config as-is
expect(context.prompt.config).toMatchObject({
prompt_field: 'prompt_value',
temperature: 0.7,
});
});
it('should handle empty test.options object', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Should use prompt config as-is
expect(context.prompt.config).toMatchObject({
prompt_field: 'prompt_value',
temperature: 0.7,
});
});
it('should preserve non-overlapping fields from both configs', async () => {
mockPrompt.config = {
temperature: 0.5,
max_tokens: 100,
};
const test: TestCase = {
vars: { input: 'test' },
options: {
response_format: { type: 'json_object' },
top_p: 0.9,
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// All fields should be present
expect(context.prompt.config).toMatchObject({
temperature: 0.5,
max_tokens: 100,
response_format: { type: 'json_object' },
top_p: 0.9,
});
});
it('should support provider-specific fields in test.options', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {
// OpenAI-specific field
response_format: {
type: 'json_schema',
json_schema: {
name: 'test_schema',
strict: true,
schema: {
type: 'object',
properties: {
answer: { type: 'string' },
},
required: ['answer'],
additionalProperties: false,
},
},
},
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Provider-specific field should be passed through
expect(context.prompt.config.response_format).toBeDefined();
expect(context.prompt.config.response_format.type).toBe('json_schema');
expect(context.prompt.config.response_format.json_schema).toBeDefined();
});
it('should support Google Vertex responseSchema in test.options', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {
// Google-specific field
responseSchema: {
type: 'OBJECT',
properties: {
answer: { type: 'STRING' },
confidence: { type: 'NUMBER' },
},
required: ['answer'],
},
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Google-specific field should be passed through
expect(context.prompt.config.responseSchema).toBeDefined();
expect(context.prompt.config.responseSchema.type).toBe('OBJECT');
expect(context.prompt.config.responseSchema.properties.answer.type).toBe('STRING');
});
it('should preserve existing test.options behavior (transform, storeOutputAs)', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {
transform: 'output.toUpperCase()',
storeOutputAs: 'result',
response_format: { type: 'json_object' },
},
};
// These fields should still be accessible in test.options AND in prompt.config
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// Fields should be in prompt.config
expect(context.prompt.config.transform).toBe('output.toUpperCase()');
expect(context.prompt.config.storeOutputAs).toBe('result');
expect(context.prompt.config.response_format).toEqual({ type: 'json_object' });
});
it('should handle multiple test.options fields simultaneously', async () => {
const test: TestCase = {
vars: { input: 'test' },
options: {
response_format: { type: 'json_object' },
temperature: 0.9,
max_tokens: 500,
top_p: 0.95,
custom_field: 'custom_value',
},
};
const _result = await runEval({
provider: mockProvider,
prompt: mockPrompt,
test,
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
});
const callArgs = (mockProvider.callApi as ReturnType<typeof vi.fn>).mock.calls[0];
const context = callArgs[1];
// All fields should be in merged config
expect(context.prompt.config).toMatchObject({
response_format: { type: 'json_object' },
temperature: 0.9,
max_tokens: 500,
top_p: 0.95,
custom_field: 'custom_value',
prompt_field: 'prompt_value', // From prompt
});
});
});