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
1122 lines
34 KiB
TypeScript
1122 lines
34 KiB
TypeScript
import './setup';
|
|
|
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { clearCache } from '../../src/cache';
|
|
import { runEval } from '../../src/evaluator';
|
|
import {
|
|
type ApiProvider,
|
|
type Prompt,
|
|
ResultFailureReason,
|
|
type TestSuite,
|
|
} from '../../src/types/index';
|
|
import { mockGradingApiProviderPasses, resetMockProviders } from './helpers';
|
|
|
|
describe('runEval', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetMockProviders();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
vi.clearAllMocks();
|
|
await clearCache();
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
vi.resetModules();
|
|
});
|
|
|
|
const mockProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('test-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const defaultOptions = {
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
};
|
|
|
|
it('should handle basic prompt evaluation', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
expect(result.response?.output).toBe('Test output');
|
|
expect(result.prompt.label).toBe('test-label');
|
|
expect(mockProvider.callApi).toHaveBeenCalledWith('Test prompt', expect.anything(), undefined);
|
|
});
|
|
|
|
it('should expose eval runtime vars to prompt and provider rendering', async () => {
|
|
await runEval({
|
|
...defaultOptions,
|
|
evalId: 'eval-runtime-vars',
|
|
promptIdx: 4,
|
|
provider: mockProvider,
|
|
prompt: {
|
|
raw: '{{__evalId}} {{__evalStepId}} {{__repeatIndex}}',
|
|
label: 'test-label',
|
|
},
|
|
repeatIndex: 2,
|
|
test: {
|
|
vars: {
|
|
__evalId: 'spoofed-eval-id',
|
|
__evalStepId: 'spoofed-step-id',
|
|
__repeatIndex: 99,
|
|
},
|
|
},
|
|
testIdx: 3,
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(mockProvider.callApi).toHaveBeenCalledWith(
|
|
'eval-runtime-vars test-3-prompt-4-repeat-2 2',
|
|
expect.objectContaining({
|
|
vars: expect.objectContaining({
|
|
__evalId: 'eval-runtime-vars',
|
|
__evalStepId: 'test-3-prompt-4-repeat-2',
|
|
__repeatIndex: 2,
|
|
}),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('excludes eval runtime vars from the persisted result', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
evalId: 'eval-runtime-vars',
|
|
promptIdx: 4,
|
|
provider: mockProvider,
|
|
prompt: {
|
|
raw: '{{__evalStepId}} {{topic}}',
|
|
label: 'test-label',
|
|
},
|
|
repeatIndex: 2,
|
|
test: {
|
|
vars: { topic: 'weather' },
|
|
},
|
|
testIdx: 3,
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
// The reserved __eval* runtime vars must not be persisted on result.vars:
|
|
// they would pollute stored results and, being _-prefixed, be restored
|
|
// onto re-run test.vars by --filter-* re-runs. Real test vars are kept.
|
|
expect(results[0].vars).toEqual({ topic: 'weather' });
|
|
});
|
|
|
|
it('excludes eval runtime vars from assertion context', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
evalId: 'eval-runtime-vars',
|
|
provider: mockProvider,
|
|
prompt: { raw: '{{topic}}', label: 'test-label' },
|
|
test: {
|
|
vars: { topic: 'weather' },
|
|
assert: [
|
|
{
|
|
type: 'javascript',
|
|
value:
|
|
"!('__evalId' in context.vars) && !('__evalStepId' in context.vars) && !('__repeatIndex' in context.vars) && context.vars.topic === 'weather'",
|
|
},
|
|
],
|
|
},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
// The assertion passes only if context.vars carries the real test var but
|
|
// none of the reserved __eval* runtime vars.
|
|
expect(results[0].gradingResult?.pass).toBe(true);
|
|
expect(results[0].success).toBe(true);
|
|
});
|
|
|
|
it('should pass dynamic prompt config from prompt functions to the provider', async () => {
|
|
const dynamicConfig = { temperature: 0.5, tools: [{ name: 'test_tool' }] };
|
|
const promptWithFunction: Prompt = {
|
|
raw: 'Dynamic prompt',
|
|
label: 'dynamic-label',
|
|
function: async () => ({
|
|
prompt: 'Rendered dynamic prompt',
|
|
config: dynamicConfig,
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: promptWithFunction,
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
|
|
// Verify the provider received the dynamic config
|
|
const callApiMock = vi.mocked(mockProvider.callApi);
|
|
const callApiArgs = callApiMock.mock.calls[0];
|
|
expect(callApiArgs).toBeDefined();
|
|
const context = callApiArgs![1];
|
|
expect(context).toBeDefined();
|
|
expect(context!.prompt.config).toEqual(dynamicConfig);
|
|
});
|
|
|
|
it('should merge dynamic prompt config with test.options (test.options takes precedence)', async () => {
|
|
const promptWithFunction: Prompt = {
|
|
raw: 'Dynamic prompt',
|
|
label: 'dynamic-label',
|
|
function: async () => ({
|
|
prompt: 'Rendered dynamic prompt',
|
|
config: { temperature: 0.5, max_tokens: 100 },
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: promptWithFunction,
|
|
test: { options: { repeat: 3, temperature: 0.9 } },
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
|
|
// Provider options should override dynamic config, while evaluator-only options stay internal.
|
|
const callApiMock = vi.mocked(mockProvider.callApi);
|
|
const callApiArgs = callApiMock.mock.calls[0];
|
|
expect(callApiArgs).toBeDefined();
|
|
const context = callApiArgs![1];
|
|
expect(context).toBeDefined();
|
|
expect(context!.prompt.config).toEqual({ temperature: 0.9, max_tokens: 100 });
|
|
});
|
|
|
|
it('should not leak dynamic prompt config across runEval calls for a shared prompt object', async () => {
|
|
const promptWithFunction: Prompt = {
|
|
raw: 'Dynamic prompt {{mode}}',
|
|
label: 'dynamic-label',
|
|
config: { top_p: 0.9 },
|
|
function: async ({ vars }) => ({
|
|
prompt: `Rendered dynamic prompt for ${vars.mode}`,
|
|
config:
|
|
vars.mode === 'first'
|
|
? { temperature: 0.5, response_format: { type: 'json_object' } }
|
|
: { max_tokens: 100 },
|
|
}),
|
|
};
|
|
|
|
await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: promptWithFunction,
|
|
test: { vars: { mode: 'first' } },
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: promptWithFunction,
|
|
test: { vars: { mode: 'second' } },
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(mockProvider.callApi).toHaveBeenCalledTimes(2);
|
|
const callApiMock = vi.mocked(mockProvider.callApi);
|
|
|
|
const firstCallArgs = callApiMock.mock.calls[0];
|
|
const secondCallArgs = callApiMock.mock.calls[1];
|
|
expect(firstCallArgs).toBeDefined();
|
|
expect(secondCallArgs).toBeDefined();
|
|
const firstCallContext = firstCallArgs![1];
|
|
const secondCallContext = secondCallArgs![1];
|
|
expect(firstCallContext).toBeDefined();
|
|
expect(secondCallContext).toBeDefined();
|
|
expect(firstCallContext!.prompt.config).toEqual({
|
|
top_p: 0.9,
|
|
temperature: 0.5,
|
|
response_format: { type: 'json_object' },
|
|
});
|
|
expect(secondCallContext!.prompt.config).toEqual({
|
|
top_p: 0.9,
|
|
max_tokens: 100,
|
|
});
|
|
|
|
// The original shared prompt object should remain unchanged.
|
|
expect(promptWithFunction.config).toEqual({ top_p: 0.9 });
|
|
});
|
|
|
|
it('should keep pre-render fallback config in error result when renderPrompt mutates and throws', async () => {
|
|
const evalHelpers = await import('../../src/evaluatorHelpers');
|
|
const renderPromptSpy = vi
|
|
.spyOn(evalHelpers, 'renderPrompt')
|
|
.mockImplementationOnce(async (promptArg) => {
|
|
promptArg.config = {
|
|
...(promptArg.config ?? {}),
|
|
response_format: { type: 'mutated-before-throw' },
|
|
};
|
|
throw new Error('render failed');
|
|
});
|
|
|
|
const promptWithConfig: Prompt = {
|
|
raw: 'Test prompt',
|
|
label: 'test-label',
|
|
config: { response_format: { type: 'json_object' } },
|
|
};
|
|
|
|
try {
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: promptWithConfig,
|
|
test: { options: { temperature: 0.4 } },
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.failureReason).toBe(ResultFailureReason.ERROR);
|
|
expect(result.prompt.config).toEqual({
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0.4,
|
|
});
|
|
expect(promptWithConfig.config).toEqual({ response_format: { type: 'json_object' } });
|
|
expect(mockProvider.callApi).not.toHaveBeenCalled();
|
|
} finally {
|
|
renderPromptSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('should handle conversation history', async () => {
|
|
const conversations = {} as Record<string, any>;
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'Hello {{_conversation[0].output}}', label: 'test-label' },
|
|
test: {},
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
expect(conversations).toHaveProperty('test-provider:undefined');
|
|
expect(conversations['test-provider:undefined']).toHaveLength(1);
|
|
expect(conversations['test-provider:undefined'][0]).toEqual({
|
|
prompt: 'Hello ',
|
|
input: 'Hello ',
|
|
output: 'Test output',
|
|
});
|
|
});
|
|
|
|
it('should handle conversation with custom ID', async () => {
|
|
const conversations = {};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'Hello {{_conversation[0].output}}', label: 'test-label', id: 'custom-id' },
|
|
test: { metadata: { conversationId: 'conv1' } },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
expect(conversations).toHaveProperty('test-provider:custom-id:conv1');
|
|
});
|
|
|
|
it('should include sessionId from response in result metadata', async () => {
|
|
const conversations: Record<string, any[]> = {};
|
|
|
|
const providerWithSession: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('session-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
sessionId: 'response-session-123',
|
|
metadata: { existing: 'value' },
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0, numRequests: 0 },
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithSession,
|
|
prompt: { raw: 'Test prompt', label: 'session-label' },
|
|
test: { vars: {} },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
|
|
const [result] = results;
|
|
expect(result?.metadata).toMatchObject({
|
|
sessionId: 'response-session-123',
|
|
existing: 'value',
|
|
});
|
|
});
|
|
|
|
it('should include sessionId from vars in result metadata when response lacks sessionId', async () => {
|
|
const conversations: Record<string, any[]> = {};
|
|
|
|
const providerWithoutSession: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('vars-session-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0, numRequests: 0 },
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithoutSession,
|
|
prompt: { raw: 'Test prompt', label: 'vars-session-label' },
|
|
test: { vars: { sessionId: 'vars-session-456' } },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
|
|
const [result] = results;
|
|
|
|
expect(result.metadata).toMatchObject({ sessionId: 'vars-session-456' });
|
|
});
|
|
|
|
it('should include sessionId from response metadata when top-level sessionId is absent', async () => {
|
|
const conversations: Record<string, any[]> = {};
|
|
|
|
const providerWithMetadataSession: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('metadata-session-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
metadata: { sessionId: 'metadata-session-789', existing: 'keep-me' },
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0, numRequests: 0 },
|
|
}),
|
|
};
|
|
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithMetadataSession,
|
|
prompt: { raw: 'Test prompt', label: 'metadata-session-label' },
|
|
test: { vars: {} },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
|
|
expect(result.metadata).toMatchObject({
|
|
sessionId: 'metadata-session-789',
|
|
existing: 'keep-me',
|
|
});
|
|
});
|
|
|
|
it('should prioritize response metadata sessionId over vars sessionId', async () => {
|
|
const conversations: Record<string, any[]> = {};
|
|
|
|
const providerWithMetadataSession: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('metadata-session-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
metadata: { sessionId: 'metadata-session-priority' },
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0, numRequests: 0 },
|
|
}),
|
|
};
|
|
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithMetadataSession,
|
|
prompt: { raw: 'Test prompt', label: 'metadata-session-label' },
|
|
test: { vars: { sessionId: 'vars-session-ignored' } },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
|
|
expect(result.metadata).toMatchObject({ sessionId: 'metadata-session-priority' });
|
|
});
|
|
|
|
it('should include sessionIds from response metadata without adding sessionId fallback', async () => {
|
|
const conversations: Record<string, any[]> = {};
|
|
|
|
const providerWithSessionIds: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('metadata-session-ids-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
metadata: { sessionIds: ['session-a', 'session-b'] },
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0, numRequests: 0 },
|
|
}),
|
|
};
|
|
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithSessionIds,
|
|
prompt: { raw: 'Test prompt', label: 'metadata-session-ids-label' },
|
|
test: { vars: {} },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
|
|
expect(result.metadata).toMatchObject({ sessionIds: ['session-a', 'session-b'] });
|
|
expect(result.metadata).not.toHaveProperty('sessionId');
|
|
});
|
|
|
|
it('should include sessionId from test metadata when provider omits session details', async () => {
|
|
const conversations: Record<string, any[]> = {};
|
|
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-metadata-session-label' },
|
|
test: { metadata: { sessionId: 'test-metadata-session' } },
|
|
conversations,
|
|
registers: {},
|
|
});
|
|
|
|
expect(result?.metadata).toMatchObject({ sessionId: 'test-metadata-session' });
|
|
});
|
|
|
|
it('should preserve provider error context and plugin metadata on failure', async () => {
|
|
const apiError: any = new Error('Request failed with status code 400');
|
|
apiError.response = {
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
data: 'Invalid payload',
|
|
};
|
|
|
|
const failingProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('failing-provider'),
|
|
label: 'Azure GPT 5',
|
|
callApi: vi.fn().mockRejectedValue(apiError),
|
|
};
|
|
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: failingProvider,
|
|
prompt: { raw: 'Test prompt', label: 'error-label' },
|
|
test: { metadata: { pluginId: 'plugin-123', strategyId: 'basic' } },
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: true,
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.failureReason).toBe(ResultFailureReason.ERROR);
|
|
expect(result.metadata?.errorContext).toMatchObject({
|
|
providerId: 'failing-provider',
|
|
providerLabel: 'Azure GPT 5',
|
|
status: 400,
|
|
statusText: 'Bad Request',
|
|
});
|
|
expect(result.metadata?.errorContext?.responseSnippet).toContain('Invalid payload');
|
|
expect(result.metadata?.pluginId).toBe('plugin-123');
|
|
expect(result.metadata?.strategyId).toBe('basic');
|
|
expect(result.error).toContain('Request failed with status code 400');
|
|
});
|
|
|
|
it('should handle registers', async () => {
|
|
const registers = { savedValue: 'stored data' };
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'Using {{savedValue}}', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers,
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
expect(mockProvider.callApi).toHaveBeenCalledWith(
|
|
'Using stored data',
|
|
expect.anything(),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should store output in register when specified', async () => {
|
|
const registers = {};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: { options: { storeOutputAs: 'myOutput' } },
|
|
conversations: {},
|
|
registers,
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(true);
|
|
expect(registers).toHaveProperty('myOutput', 'Test output');
|
|
});
|
|
|
|
it('should handle provider errors', async () => {
|
|
const errorProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('error-provider'),
|
|
callApi: vi.fn().mockRejectedValue(new Error('API Error')),
|
|
};
|
|
|
|
// Define defaultOptions locally for this test
|
|
const defaultOptions = {
|
|
delay: 0,
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
repeatIndex: 0,
|
|
isRedteam: false,
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
evalId: 'eval-runtime-vars',
|
|
provider: errorProvider,
|
|
prompt: { raw: '{{__evalStepId}} {{topic}}', label: 'test-label' },
|
|
test: { vars: { topic: 'weather' } },
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
const result = results[0];
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('API Error');
|
|
expect(result.failureReason).toBe(ResultFailureReason.ERROR);
|
|
expect(result.vars).toEqual({ topic: 'weather' });
|
|
});
|
|
|
|
it('should keep trace linkage when a traced provider call fails', async () => {
|
|
const errorProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('traced-error-provider'),
|
|
callApi: vi.fn().mockRejectedValue(new Error('Traced API Error')),
|
|
};
|
|
|
|
const [result] = await runEval({
|
|
...defaultOptions,
|
|
provider: errorProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {
|
|
metadata: {
|
|
evaluationId: 'eval-provider-error-trace',
|
|
tracingEnabled: true,
|
|
},
|
|
},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(result.traceId).toMatch(/^[0-9a-f]{32}$/);
|
|
expect(result.evaluationId).toBe('eval-provider-error-trace');
|
|
expect(result.error).toContain('Traced API Error');
|
|
expect(result.failureReason).toBe(ResultFailureReason.ERROR);
|
|
});
|
|
|
|
it('should handle null output differently for red team tests', async () => {
|
|
const nullOutputProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('null-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: null,
|
|
tokenUsage: { total: 5, prompt: 5, completion: 0, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
// Regular test
|
|
const regularResults = await runEval({
|
|
...defaultOptions,
|
|
provider: nullOutputProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(regularResults[0].success).toBe(false);
|
|
expect(regularResults[0].error).toBe('No output');
|
|
|
|
// Red team test
|
|
const redTeamResults = await runEval({
|
|
...defaultOptions,
|
|
provider: nullOutputProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: true,
|
|
});
|
|
|
|
expect(redTeamResults[0].success).toBe(true);
|
|
expect(redTeamResults[0].error).toBeUndefined();
|
|
});
|
|
|
|
it('should apply transforms in correct order', async () => {
|
|
const providerWithTransform: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('transform-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'original',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
transform: 'output + "-provider"',
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithTransform,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {
|
|
options: { transform: 'output + "-test"' },
|
|
},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].response?.output).toBe('original-provider-test');
|
|
});
|
|
|
|
it('should skip rendering redteam inject variable to prevent nunjucks errors on template syntax', async () => {
|
|
// This tests the fix for Discord issue where redteam prompts containing {{purpose | trim}}
|
|
// caused "TypeError: Cannot read properties of undefined (reading 'replace')"
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{prompt}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
// This contains template syntax that would fail if rendered (purpose is undefined)
|
|
prompt:
|
|
'Based on your role as "{{purpose | trim}}", provide a response to a typical request.',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
redteam: {
|
|
injectVar: 'prompt',
|
|
},
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: true,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
// The inject var should be preserved as-is, not rendered
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should fail before calling the provider when redteam maxCharsPerMessage is exceeded', async () => {
|
|
const callApi = vi.fn().mockResolvedValue({ output: 'should not be called' });
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: {
|
|
id: () => 'test-provider',
|
|
callApi,
|
|
},
|
|
prompt: { raw: 'User said: {{prompt}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
prompt: 'this is too long',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
redteam: {
|
|
maxCharsPerMessage: 10,
|
|
},
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: true,
|
|
});
|
|
|
|
expect(callApi).not.toHaveBeenCalled();
|
|
expect(results[0].error).toContain('maxCharsPerMessage=10');
|
|
});
|
|
|
|
it('should not enforce redteam maxCharsPerMessage for non-redteam evals', async () => {
|
|
const callApi = vi.fn().mockResolvedValue({ output: 'success' });
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: {
|
|
id: () => 'test-provider',
|
|
callApi,
|
|
},
|
|
prompt: { raw: 'this prompt is longer than ten chars', label: 'test-label' },
|
|
test: {},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
redteam: {
|
|
maxCharsPerMessage: 10,
|
|
},
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(callApi).toHaveBeenCalledTimes(1);
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].response?.output).toBe('success');
|
|
});
|
|
|
|
it('should use default injectVar "prompt" when not explicitly set in redteam config', async () => {
|
|
// Tests the fallback to default 'prompt' injectVar when redteam config exists but injectVar is undefined
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{prompt}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
prompt:
|
|
'Based on your role as "{{purpose | trim}}", provide a response to a typical request.',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
redteam: {
|
|
// injectVar NOT set - should fall back to 'prompt'
|
|
},
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: true,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
// Should still skip rendering the default 'prompt' var
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should infer a non-default injectVar when redteam config omits it and prompt is absent', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{query}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
query:
|
|
'Based on your role as "{{purpose | trim}}", provide a response to a typical request.',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
redteam: {},
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: true,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should skip rendering generated redteam exports without a top-level redteam block', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{prompt}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
prompt: 'Please answer in two sentences for a user whose role is "{{purpose | trim}}".',
|
|
},
|
|
assert: [{ type: 'promptfoo:redteam:ascii-smuggling' }],
|
|
metadata: {
|
|
pluginId: 'ascii-smuggling',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should skip rendering generated redteam exports identified by metadata and llm-rubric assertions', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{query}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
query: 'Please answer in two sentences for a user whose role is "{{purpose | trim}}".',
|
|
},
|
|
assert: [
|
|
{
|
|
type: 'llm-rubric',
|
|
value: 'Return pass when the response is a string',
|
|
},
|
|
],
|
|
metadata: {
|
|
pluginId: 'xstest',
|
|
pluginConfig: {
|
|
modifiers: {},
|
|
},
|
|
severity: 'medium',
|
|
},
|
|
options: { provider: mockGradingApiProviderPasses },
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should infer the inject variable for generated redteam exports without config', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{query}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
query: 'Please answer in two sentences for a user whose role is "{{purpose | trim}}".',
|
|
},
|
|
assert: [{ type: 'promptfoo:redteam:ascii-smuggling' }],
|
|
metadata: {
|
|
pluginId: 'ascii-smuggling',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should infer the inject variable from the undecorated prompt template', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: {
|
|
raw: 'Locale: {{locale}}\nUser said: {{query}}',
|
|
template: 'User said: {{query}}',
|
|
label: 'test-label',
|
|
},
|
|
test: {
|
|
vars: {
|
|
locale: 'en-US',
|
|
query: 'Please answer in two sentences for a user whose role is "{{purpose | trim}}".',
|
|
},
|
|
assert: [{ type: 'promptfoo:redteam:ascii-smuggling' }],
|
|
metadata: {
|
|
pluginId: 'ascii-smuggling',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should skip rendering when redteam assertions are nested inside assert-set', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{prompt}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
prompt: 'Please answer in two sentences for a user whose role is "{{purpose | trim}}".',
|
|
},
|
|
assert: [
|
|
{
|
|
type: 'assert-set',
|
|
assert: [{ type: 'promptfoo:redteam:ascii-smuggling' }],
|
|
},
|
|
],
|
|
metadata: {
|
|
pluginId: 'ascii-smuggling',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('{{purpose | trim}}');
|
|
});
|
|
|
|
it('should continue rendering non-redteam tests that only set pluginId metadata', async () => {
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: mockProvider,
|
|
prompt: { raw: 'User said: {{query}}', label: 'test-label' },
|
|
test: {
|
|
vars: {
|
|
name: 'Alice',
|
|
query: 'Hello {{name}}',
|
|
},
|
|
metadata: {
|
|
pluginId: 'ascii-smuggling',
|
|
},
|
|
},
|
|
testSuite: {
|
|
providers: [],
|
|
prompts: [],
|
|
} as unknown as TestSuite,
|
|
conversations: {},
|
|
registers: {},
|
|
isRedteam: false,
|
|
});
|
|
|
|
expect(results[0].success).toBe(true);
|
|
expect(results[0].prompt.raw).toContain('Hello Alice');
|
|
expect(results[0].prompt.raw).not.toContain('{{name}}');
|
|
});
|
|
|
|
describe('latencyMs handling', () => {
|
|
it('should use provider-supplied latencyMs when available', async () => {
|
|
const providerWithLatency: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('latency-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
latencyMs: 5000,
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithLatency,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(results[0].latencyMs).toBe(5000);
|
|
});
|
|
|
|
it('should use provider-supplied latencyMs for cached responses', async () => {
|
|
const cachedProvider: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('cached-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Cached output',
|
|
cached: true,
|
|
latencyMs: 3500,
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: cachedProvider,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(results[0].latencyMs).toBe(3500);
|
|
expect(results[0].response?.cached).toBe(true);
|
|
});
|
|
|
|
it('should fall back to measured latency when provider does not supply latencyMs', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const providerWithoutLatency: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('no-latency-provider'),
|
|
callApi: vi.fn().mockImplementation(async () => {
|
|
// Yield first so the test still validates that runEval awaits the
|
|
// provider before computing elapsed time. Then advance the fake
|
|
// clock so the latency measurement records ~50ms without relying
|
|
// on a real timer.
|
|
await Promise.resolve();
|
|
vi.advanceTimersByTime(50);
|
|
return {
|
|
output: 'Test output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
};
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const resultPromise = runEval({
|
|
...defaultOptions,
|
|
provider: providerWithoutLatency,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
const results = await resultPromise;
|
|
|
|
// Should have measured latency (>= 45ms accounting for timer precision)
|
|
expect(results[0].latencyMs).toBeGreaterThanOrEqual(45);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('should respect provider latencyMs of 0', async () => {
|
|
const providerWithZeroLatency: ApiProvider = {
|
|
id: vi.fn().mockReturnValue('zero-latency-provider'),
|
|
callApi: vi.fn().mockResolvedValue({
|
|
output: 'Test output',
|
|
latencyMs: 0,
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
|
|
}),
|
|
};
|
|
|
|
const results = await runEval({
|
|
...defaultOptions,
|
|
provider: providerWithZeroLatency,
|
|
prompt: { raw: 'Test prompt', label: 'test-label' },
|
|
test: {},
|
|
conversations: {},
|
|
registers: {},
|
|
});
|
|
|
|
expect(results[0].latencyMs).toBe(0);
|
|
});
|
|
});
|
|
});
|