chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import {
mockApiProvider,
mockGradingApiProviderFails,
mockGradingApiProviderPasses,
toPrompt,
} from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator assertions', () => {
it('evaluate with expected value matching output', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Test output',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].success).toBe(true);
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with expected value not matching output', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Different output',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(0);
expect(summary.stats.failures).toBe(1);
expect(summary.results[0].success).toBe(false);
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with fn: expected value', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'javascript',
value: 'output === "Test output";',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].success).toBe(true);
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with fn: expected value not matching output', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'javascript',
value: 'output === "Different output";',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(0);
expect(summary.stats.failures).toBe(1);
expect(summary.results[0].success).toBe(false);
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with grading expected value', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'llm-rubric',
value: 'output is a test output',
},
],
},
],
defaultTest: {
options: {
provider: mockGradingApiProviderPasses,
},
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].success).toBe(true);
expect(summary.results[0].response?.output).toBe('Test output');
});
it('reuses a configured LiteLLM provider ID for both G-Eval calls', async () => {
const configuredLiteLLM: ApiProvider = {
id: vi.fn().mockReturnValue('litellm:gemini-pro'),
config: { apiBaseUrl: 'http://localhost:4000', temperature: 0 },
callApi: vi.fn().mockImplementation(async (_prompt, context) => ({
output:
context?.prompt?.label === 'g-eval-steps'
? JSON.stringify({ steps: ['Check factual accuracy'] })
: JSON.stringify({ score: 10, reason: 'The answer is accurate' }),
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
})),
};
const testSuite: TestSuite = {
providers: [mockApiProvider, configuredLiteLLM],
prompts: [toPrompt('What is the capital of France?')],
tests: [
{
providers: ['test-provider'],
assert: [
{
type: 'g-eval',
value: 'The answer identifies the capital correctly',
provider: 'litellm:gemini-pro',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(configuredLiteLLM.callApi).toHaveBeenCalledTimes(2);
expect(
vi.mocked(configuredLiteLLM.callApi).mock.calls.map(([, context]) => context?.prompt?.label),
).toEqual(['g-eval-steps', 'g-eval']);
expect(summary.stats.successes).toBe(1);
});
it('reuses configured graders in typed defaults and scenario assertion sets', async () => {
const configuredGrader: ApiProvider = {
id: vi.fn().mockReturnValue('litellm:judge'),
label: 'Configured grader',
callApi: vi.fn().mockResolvedValue({
output: JSON.stringify({ pass: true, score: 1, reason: 'The output passes' }),
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider, configuredGrader],
prompts: [toPrompt('Test prompt')],
defaultTest: {
options: { provider: { text: 'Configured grader' } },
},
scenarios: [
{
config: [{}],
tests: [
{
providers: ['test-provider'],
assert: [
{
type: 'assert-set',
assert: [
{ type: 'llm-rubric', value: 'Use the default grader option' },
{
type: 'llm-rubric',
value: 'Use the explicit configured grader ID option',
provider: { text: { id: 'litellm:judge' } },
},
],
},
],
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(configuredGrader.callApi).toHaveBeenCalledTimes(2);
expect(summary.stats.successes).toBe(1);
});
it('routes an assertion provider id to the id-matching provider when a sibling label collides', async () => {
const judgeById: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn().mockResolvedValue({
output: JSON.stringify({ pass: true, score: 1, reason: 'Passes via id-matched judge.' }),
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const judgeByLabel: ApiProvider = {
id: vi.fn().mockReturnValue('litellm:judge'),
label: 'judge',
callApi: vi.fn().mockResolvedValue({
output: JSON.stringify({ pass: false, score: 0, reason: 'Should never be called.' }),
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider, judgeById, judgeByLabel],
prompts: [toPrompt('Test prompt')],
tests: [
{
providers: ['test-provider'],
assert: [{ type: 'llm-rubric', value: 'Use the id-matched judge', provider: 'judge' }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(judgeById.callApi).toHaveBeenCalledTimes(1);
expect(judgeByLabel.callApi).not.toHaveBeenCalled();
expect(summary.stats.successes).toBe(1);
});
it('preserves suite env for typed graders inherited from defaultTest assertions', async () => {
const testSuite: TestSuite = {
env: { LITELLM_API_BASE: 'echo' },
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{ providers: ['test-provider'] }],
defaultTest: {
assert: [
{
type: 'llm-rubric',
value: 'Grade the test output',
rubricPrompt: '{"pass":true,"score":1,"reason":"Resolved suite env"}',
provider: {
text: {
id: '{{ env.LITELLM_API_BASE }}',
},
},
},
],
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(1);
expect(summary.results[0].success).toBe(true);
});
it('reuses configured text and embedding graders for answer relevance', async () => {
const textJudge: ApiProvider = {
id: vi.fn().mockReturnValue('litellm:text-judge'),
callApi: vi.fn().mockResolvedValue({ output: 'Test prompt' }),
};
const embeddingJudge: ApiProvider = {
id: vi.fn().mockReturnValue('litellm:embedding:judge'),
callApi: vi.fn().mockResolvedValue({ output: '' }),
callEmbeddingApi: vi.fn().mockResolvedValue({ embedding: [1, 0] }),
};
const testSuite: TestSuite = {
providers: [mockApiProvider, textJudge, embeddingJudge],
prompts: [toPrompt('Test prompt')],
tests: [
{
providers: ['test-provider'],
assert: [
{
type: 'answer-relevance',
threshold: 0.8,
provider: {
text: 'litellm:text-judge',
embedding: 'litellm:embedding:judge',
},
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(textJudge.callApi).toHaveBeenCalledTimes(3);
expect(embeddingJudge.callEmbeddingApi).toHaveBeenCalledTimes(4);
expect(summary.stats.successes).toBe(1);
});
it('evaluate with grading expected value does not pass', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'llm-rubric',
value: 'output is a test output',
},
],
},
],
defaultTest: {
options: {
provider: mockGradingApiProviderFails,
},
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(0);
expect(summary.stats.failures).toBe(1);
expect(summary.results[0].success).toBe(false);
expect(summary.results[0].response?.output).toBe('Test output');
});
});
+570
View File
@@ -0,0 +1,570 @@
import './setup';
import { randomUUID } from 'crypto';
import fs from 'fs';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type TestSuite } from '../../src/types/index';
import { processConfigFileReferences } from '../../src/util/fileReference';
import { mockApiProvider, mockReasoningApiProvider, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator basic flows', () => {
it('evaluate with vars', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1 }} {{ var2 }}')],
tests: [
{
vars: { var1: 'value1', var2: 'value2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(mockApiProvider.callApi).toHaveBeenCalledWith(
'Test prompt value1 value2',
expect.objectContaining({
vars: expect.objectContaining({ var1: 'value1', var2: 'value2' }),
test: testSuite.tests![0],
prompt: expect.any(Object),
}),
undefined,
);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt value1 value2');
expect(summary.results[0].prompt.label).toBe('Test prompt {{ var1 }} {{ var2 }}');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with vars - no escaping', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1 }} {{ var2 }}')],
tests: [
{
vars: { var1: '1 < 2', var2: 'he said "hello world"...' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt 1 < 2 he said "hello world"...');
expect(summary.results[0].prompt.label).toBe('Test prompt {{ var1 }} {{ var2 }}');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with vars as object', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1.prop1 }} {{ var2 }}')],
tests: [
{
vars: { var1: { prop1: 'value1' }, var2: 'value2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt value1 value2');
expect(summary.results[0].prompt.label).toBe('Test prompt {{ var1.prop1 }} {{ var2 }}');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with vars from file', async () => {
const originalReadFileSync = fs.readFileSync;
vi.spyOn(fs, 'readFileSync').mockImplementation((path) => {
if (typeof path === 'string' && path.includes('test_file.txt')) {
return '<h1>Sample Report</h1><p>This is a test report with some data for the year 2023.</p>';
}
return originalReadFileSync(path);
});
const evalHelpers = await import('../../src/evaluatorHelpers');
const originalRenderPrompt = evalHelpers.renderPrompt;
const mockRenderPrompt = vi.spyOn(evalHelpers, 'renderPrompt');
mockRenderPrompt.mockImplementation(async (prompt, vars) => {
if (prompt.raw.includes('{{ var1 }}')) {
return 'Test prompt <h1>Sample Report</h1><p>This is a test report with some data for the year 2023.</p>';
}
return originalRenderPrompt(prompt, vars);
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1 }}')],
tests: [
{
vars: { var1: 'file://test/fixtures/test_file.txt' },
},
],
};
try {
const processedTestSuite = await processConfigFileReferences(testSuite);
const evalRecord = await Eval.create({}, processedTestSuite.prompts, { id: randomUUID() });
await evaluate(processedTestSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(mockApiProvider.callApi).toHaveBeenCalledWith(
'Test prompt <h1>Sample Report</h1><p>This is a test report with some data for the year 2023.</p>',
expect.anything(),
undefined,
);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].prompt.raw).toBe(
'Test prompt <h1>Sample Report</h1><p>This is a test report with some data for the year 2023.</p>',
);
expect(summary.results[0].prompt.label).toBe('Test prompt {{ var1 }}');
expect(summary.results[0].response?.output).toBe('Test output');
} finally {
mockRenderPrompt.mockRestore();
fs.readFileSync = originalReadFileSync;
}
});
it('evaluate with named prompt', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt {{ var1 }} {{ var2 }}', label: 'test display name' }],
tests: [
{
vars: { var1: 'value1', var2: 'value2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt value1 value2');
expect(summary.results[0].prompt.label).toBe('test display name');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with multiple vars', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1 }} {{ var2 }}')],
tests: [
{
vars: { var1: ['value1', 'value3'], var2: ['value2', 'value4'] },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(4);
expect(summary.stats.successes).toBe(4);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 40,
prompt: 20,
completion: 20,
cached: 0,
numRequests: 4,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt value1 value2');
expect(summary.results[0].prompt.label).toBe('Test prompt {{ var1 }} {{ var2 }}');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with multiple providers', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider, mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1 }} {{ var2 }}')],
tests: [
{
vars: { var1: 'value1', var2: 'value2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
expect(summary.stats.successes).toBe(2);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 20,
prompt: 10,
completion: 10,
cached: 0,
numRequests: 2,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt value1 value2');
expect(summary.results[0].prompt.label).toBe('Test prompt {{ var1 }} {{ var2 }}');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate without tests', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt');
expect(summary.results[0].prompt.label).toBe('Test prompt');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate without tests with multiple providers', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider, mockApiProvider, mockApiProvider],
prompts: [toPrompt('Test prompt')],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(3);
expect(summary.stats.successes).toBe(3);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 30,
prompt: 15,
completion: 15,
cached: 0,
numRequests: 3,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt');
expect(summary.results[0].prompt.label).toBe('Test prompt');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate for reasoning', async () => {
const testSuite: TestSuite = {
providers: [mockReasoningApiProvider],
prompts: [toPrompt('Test prompt')],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockReasoningApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 21,
prompt: 9,
completion: 12,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 11,
acceptedPrediction: 12,
rejectedPrediction: 13,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt');
expect(summary.results[0].prompt.label).toBe('Test prompt');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('allows test case repeat to override global repeat option', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
options: {
repeat: 3,
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, {
id: randomUUID(),
});
await evaluate(testSuite, evalRecord, { repeat: 2 });
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(3);
expect(
vi.mocked(mockApiProvider.callApi).mock.calls.map(([, context]) => context?.repeatIndex),
).toEqual([0, 1, 2]);
expect(summary.results).toHaveLength(3);
});
it.each([
['zero', 0],
['negative', -1],
['fractional', 1.5],
['NaN', Number.NaN],
['infinite', Number.POSITIVE_INFINITY],
['unsafe', Number.MAX_SAFE_INTEGER + 1],
])('falls back to global repeat for a %s per-test repeat', async (_label, repeat) => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
options: {
repeat,
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, {
id: randomUUID(),
});
await evaluate(testSuite, evalRecord, { repeat: 2 });
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
});
});
+642
View File
@@ -0,0 +1,642 @@
import './setup';
import { randomUUID } from 'crypto';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearCache } from '../../src/cache';
import cliState from '../../src/cliState';
import { evaluate } from '../../src/evaluator';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import { runDbMigrations } from '../../src/migrate';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
import { mockApiProvider, resetMockProviders, toPrompt } from './helpers';
afterEach(async () => {
resetMockProviders();
vi.mocked(runExtensionHook).mockReset();
vi.clearAllMocks();
cliState.resume = false;
cliState.basePath = '';
cliState.webUI = false;
await clearCache();
});
afterAll(() => {
vi.restoreAllMocks();
vi.resetModules();
});
describe('evaluator defaultTest merging', () => {
beforeAll(async () => {
await runDbMigrations();
});
beforeEach(() => {
vi.clearAllMocks();
resetMockProviders();
// Reset runExtensionHook to default implementation (other tests may have overridden it)
vi.mocked(runExtensionHook).mockReset();
vi.mocked(runExtensionHook).mockImplementation(
async (_extensions, _hookName, context) => context,
);
});
it('should merge defaultTest.options.provider with test case options', async () => {
const mockProvider: ApiProvider = {
id: vi.fn().mockReturnValue('mock-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
prompts: [toPrompt('Test prompt {{text}}')],
providers: [mockProvider],
tests: [
{
vars: { text: 'Hello world' },
assert: [
{
type: 'equals',
value: 'Test output',
},
],
},
],
defaultTest: {
options: {
provider: {
embedding: {
id: 'bedrock:embeddings:amazon.titan-embed-text-v2:0',
config: {
region: 'us-east-1',
},
},
},
},
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// The evaluator should have processed the tests and merged defaultTest options
expect(summary.results).toBeDefined();
expect(summary.results.length).toBeGreaterThan(0);
// Check that the test case has the merged options from defaultTest
const processedTest = summary.results[0].testCase;
expect(processedTest?.options?.provider).toEqual({
embedding: {
id: 'bedrock:embeddings:amazon.titan-embed-text-v2:0',
config: {
region: 'us-east-1',
},
},
});
});
it('should allow test case options to override defaultTest options', async () => {
const mockProvider: ApiProvider = {
id: vi.fn().mockReturnValue('mock-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
prompts: [toPrompt('Test prompt {{text}}')],
providers: [mockProvider],
tests: [
{
vars: { text: 'Hello world' },
options: {
provider: 'openai:gpt-4',
},
assert: [
{
type: 'llm-rubric',
value: 'Output is correct',
},
],
},
],
defaultTest: {
options: {
provider: 'openai:gpt-3.5-turbo',
transform: 'output.toUpperCase()',
},
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// Check that the test case options override defaultTest options
const processedTest = summary.results[0].testCase;
expect(processedTest?.options?.provider).toBe('openai:gpt-4');
// But other defaultTest options should still be merged
expect(processedTest?.options?.transform).toBe('output.toUpperCase()');
});
});
describe('Evaluator with external defaultTest', () => {
beforeAll(async () => {
await runDbMigrations();
});
beforeEach(() => {
vi.clearAllMocks();
// Reset runExtensionHook to default implementation (other tests may have overridden it)
vi.mocked(runExtensionHook).mockReset();
vi.mocked(runExtensionHook).mockImplementation(
async (_extensions, _hookName, context) => context,
);
});
it('should handle string defaultTest gracefully', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt {{var}}', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
defaultTest: 'file://path/to/defaultTest.yaml' as any, // String should have been resolved before reaching evaluator
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// Should handle gracefully even if string wasn't resolved
expect(summary.results).toHaveLength(1);
expect(summary.results[0].vars).toEqual({ var: 'value' });
});
it('should apply object defaultTest properties correctly', async () => {
const defaultTest = {
assert: [{ type: 'equals' as const, value: 'expected' }],
vars: { defaultVar: 'defaultValue' },
options: { provider: 'test-provider' },
metadata: { suite: 'test-suite' },
threshold: 0.8,
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [
{ vars: { testVar: 'testValue' } },
{
vars: { testVar: 'override' },
assert: [{ type: 'contains' as const, value: 'exp' }],
threshold: 0.9,
},
],
defaultTest,
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// First test should inherit all defaultTest properties
const firstResult = summary.results[0] as any;
expect(firstResult.testCase.assert).toEqual(defaultTest.assert);
expect(firstResult.testCase.vars).toEqual({
defaultVar: 'defaultValue',
testVar: 'testValue',
});
expect(firstResult.testCase.threshold).toBe(0.8);
expect(firstResult.testCase.metadata).toEqual({ suite: 'test-suite' });
// Second test should merge/override appropriately
const secondResult = summary.results[1] as any;
expect(secondResult.testCase.assert).toEqual([
...defaultTest.assert,
{ type: 'contains' as const, value: 'exp' },
]);
expect(secondResult.testCase.threshold).toBe(0.9); // Override
});
it('should allow a test case to opt out of defaultTest assertions', async () => {
const defaultTest = {
assert: [{ type: 'equals' as const, value: 'expected' }],
vars: { defaultVar: 'defaultValue' },
options: { provider: 'default-provider' },
metadata: { suite: 'test-suite' },
threshold: 0.8,
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [
{
vars: { testVar: 'testValue' },
options: { disableDefaultAsserts: true },
assert: [{ type: 'contains' as const, value: 'exp' }],
},
],
defaultTest,
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
const result = summary.results[0] as any;
expect(result.testCase.assert).toEqual([{ type: 'contains' as const, value: 'exp' }]);
expect(result.testCase.vars).toEqual({
defaultVar: 'defaultValue',
testVar: 'testValue',
});
expect(result.testCase.threshold).toBe(0.8);
expect(result.testCase.metadata).toEqual({ suite: 'test-suite' });
expect(result.testCase.options).toMatchObject({
provider: 'default-provider',
disableDefaultAsserts: true,
});
});
it('should handle invariant check for defaultTest.assert array', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
defaultTest: {
assert: 'not-an-array' as any, // Invalid type
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
// Should throw or handle gracefully
await expect(evaluate(testSuite, evalRecord, {})).rejects.toThrow(
'defaultTest.assert is not an array in test case #1',
);
});
it('should correctly merge defaultTest with test case when defaultTest is object', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test {{var}}', label: 'test' }],
tests: [
{
vars: { var: 'test1' },
options: { transformVars: 'vars.transformed = true; return vars;' },
},
],
defaultTest: {
vars: { defaultVar: 'default' },
options: {
provider: 'default-provider',
transformVars: 'vars.defaultTransform = true; return vars;',
},
assert: [{ type: 'not-equals' as const, value: '' }],
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// Test case transformVars should override defaultTest transformVars
const result = summary.results[0] as any;
expect(result.testCase.options?.transformVars).toBe('vars.transformed = true; return vars;');
// But other options should be merged
expect(result.testCase.options?.provider).toBe('default-provider');
});
it('should preserve metrics from existing prompts when resuming evaluation', async () => {
// Store original resume state and ensure it's false
const originalResume = cliState.resume;
cliState.resume = false;
try {
// Create a test suite with 2 prompts and 1 test
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [
{ raw: 'Test prompt 1', label: 'test1' },
{ raw: 'Test prompt 2', label: 'test2' },
],
tests: [{ vars: { var: 'value1' } }],
};
// Create initial eval record
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
// Simulate that the eval was already partially completed with some metrics
const initialMetrics1 = {
score: 10,
testPassCount: 1,
testFailCount: 0,
testErrorCount: 0,
assertPassCount: 1,
assertFailCount: 0,
totalLatencyMs: 100,
tokenUsage: createEmptyTokenUsage(),
namedScores: {},
namedScoresCount: {},
cost: 0.001,
};
const initialMetrics2 = {
score: 5,
testPassCount: 0,
testFailCount: 1,
testErrorCount: 0,
assertPassCount: 0,
assertFailCount: 1,
totalLatencyMs: 150,
tokenUsage: createEmptyTokenUsage(),
namedScores: {},
namedScoresCount: {},
cost: 0.002,
};
evalRecord.prompts = [
{
raw: 'Test prompt 1',
label: 'test1',
id: 'prompt-test1',
provider: 'test-provider',
metrics: { ...initialMetrics1 },
},
{
raw: 'Test prompt 2',
label: 'test2',
id: 'prompt-test2',
provider: 'test-provider',
metrics: { ...initialMetrics2 },
},
];
evalRecord.persisted = true;
// Enable resume mode
cliState.resume = true;
// Run evaluation with resume - this will run the test on both prompts
await evaluate(testSuite, evalRecord, {});
// Verify the prompts still exist and have the right IDs
expect(evalRecord.prompts).toHaveLength(2);
expect(evalRecord.prompts[0].id).toBe('prompt-test1');
expect(evalRecord.prompts[1].id).toBe('prompt-test2');
// Check that the prompts have preserved metrics
// When resuming, the metrics should be accumulated with the initial values
// The key test is that metrics are not reset to 0
// For prompt 1 which had testPassCount=1 initially
expect(evalRecord.prompts[0].metrics?.testPassCount).toBeGreaterThanOrEqual(1);
// For prompt 2, at least verify metrics exist and aren't completely reset
expect(evalRecord.prompts[1].metrics).toBeDefined();
// The combined pass/fail count should be greater than 0, showing metrics weren't reset
const prompt2TotalTests =
(evalRecord.prompts[1].metrics?.testPassCount || 0) +
(evalRecord.prompts[1].metrics?.testFailCount || 0);
expect(prompt2TotalTests).toBeGreaterThan(0);
} finally {
// Always restore original state
cliState.resume = originalResume;
}
});
it('should backfill legacy named score weights when resuming evaluation', async () => {
const originalResume = cliState.resume;
cliState.resume = false;
try {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt 1', label: 'test1' }],
tests: [
{
assert: [
{
type: 'equals',
value: 'Test output',
metric: 'accuracy',
weight: 3,
},
{
type: 'contains',
value: 'Missing output',
metric: 'accuracy',
weight: 1,
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
evalRecord.prompts = [
{
raw: 'Test prompt 1',
label: 'test1',
id: 'prompt-test1',
provider: 'test-provider',
metrics: {
score: 1,
testPassCount: 1,
testFailCount: 0,
testErrorCount: 0,
assertPassCount: 1,
assertFailCount: 0,
totalLatencyMs: 100,
tokenUsage: createEmptyTokenUsage(),
namedScores: { accuracy: 1 },
namedScoresCount: { accuracy: 1 },
cost: 0.001,
},
},
];
evalRecord.persisted = true;
cliState.resume = true;
await evaluate(testSuite, evalRecord, {});
expect(evalRecord.prompts[0].metrics?.namedScores.accuracy).toBeCloseTo(4, 10);
expect(evalRecord.prompts[0].metrics?.namedScoresCount.accuracy).toBe(3);
expect(evalRecord.prompts[0].metrics?.namedScoreWeights?.accuracy).toBe(5);
} finally {
cliState.resume = originalResume;
}
});
});
describe('defaultTest normalization for extensions', () => {
beforeAll(async () => {
await runDbMigrations();
});
beforeEach(() => {
vi.clearAllMocks();
// Reset runExtensionHook to default implementation (other tests may have overridden it)
vi.mocked(runExtensionHook).mockReset();
vi.mocked(runExtensionHook).mockImplementation(
async (_extensions, _hookName, context) => context,
);
});
it('should initialize defaultTest when undefined and extensions are present', async () => {
const mockExtension = 'file://test-extension.js';
let capturedSuite: TestSuite | undefined;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'beforeAll') {
capturedSuite = (context as { suite: TestSuite }).suite;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
extensions: [mockExtension],
// No defaultTest defined
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedSuite).toBeDefined();
expect(capturedSuite!.defaultTest).toBeDefined();
expect(capturedSuite!.defaultTest).toEqual({ assert: [] });
});
it('should initialize defaultTest.assert when defaultTest exists but assert is undefined', async () => {
const mockExtension = 'file://test-extension.js';
let capturedSuite: TestSuite | undefined;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'beforeAll') {
capturedSuite = (context as { suite: TestSuite }).suite;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
extensions: [mockExtension],
defaultTest: {
vars: { defaultVar: 'defaultValue' },
// No assert defined
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedSuite).toBeDefined();
expect(capturedSuite!.defaultTest).toBeDefined();
const defaultTest = capturedSuite!.defaultTest as Record<string, unknown>;
expect(defaultTest.vars).toEqual({ defaultVar: 'defaultValue' });
expect(defaultTest.assert).toEqual([]);
});
it('should preserve existing defaultTest.assert when extensions are present', async () => {
const mockExtension = 'file://test-extension.js';
let capturedSuite: TestSuite | undefined;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'beforeAll') {
capturedSuite = (context as { suite: TestSuite }).suite;
}
return context;
});
const existingAssertions = [
{ type: 'contains' as const, value: 'expected' },
{ type: 'not-contains' as const, value: 'unexpected' },
];
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
extensions: [mockExtension],
defaultTest: {
assert: existingAssertions,
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedSuite).toBeDefined();
const defaultTest = capturedSuite!.defaultTest as Record<string, unknown>;
expect(defaultTest.assert).toBe(existingAssertions); // Same reference
expect(defaultTest.assert).toHaveLength(2);
});
it('should not modify defaultTest when no extensions are present', async () => {
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockClear();
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
// No extensions
// No defaultTest
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
// runExtensionHook should still be called (with empty/undefined extensions)
// but the beforeAll hook call should receive the original suite without normalization
const beforeAllCall = mockedRunExtensionHook.mock.calls.find((call) => call[1] === 'beforeAll');
expect(beforeAllCall).toBeDefined();
const suite = (beforeAllCall?.[2] as { suite: TestSuite } | undefined)?.suite;
expect(suite?.defaultTest).toBeUndefined();
});
it('should allow extensions to push to defaultTest.assert safely', async () => {
const mockExtension = 'file://test-extension.js';
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'beforeAll') {
// Simulate what an extension would do - push to assert array
// This should work because defaultTest.assert is guaranteed to be an array
const suite = (context as { suite: TestSuite }).suite;
const defaultTest = suite.defaultTest as Exclude<typeof suite.defaultTest, string>;
defaultTest!.assert!.push({ type: 'is-json' as const });
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{ vars: { var: 'value' } }],
extensions: [mockExtension],
// No defaultTest - will be initialized by evaluator
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
// The assertion added by the extension should be present in the results
const summary = await evalRecord.toEvaluateSummary();
expect(summary.results[0].testCase.assert).toContainEqual({ type: 'is-json' });
});
});
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { evaluate, PromptSuggestionsRejectedError } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { generatePrompts } from '../../src/suggestions';
import { promptYesNo } from '../../src/util/readline';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
import { createMockProvider } from '../factories/provider';
import type { TestSuite } from '../../src/types';
vi.mock('../../src/suggestions', () => ({
generatePrompts: vi.fn(),
}));
vi.mock('../../src/util/readline', () => ({
promptYesNo: vi.fn(),
}));
describe('generated prompt selection', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(generatePrompts).mockResolvedValue({
prompts: ['Generated prompt'],
tokensUsed: createEmptyTokenUsage(),
});
vi.mocked(promptYesNo).mockResolvedValue(false);
});
afterEach(() => {
vi.resetAllMocks();
});
function createTestSuite(): TestSuite {
return {
providers: [createMockProvider()],
prompts: [{ raw: 'Original prompt', label: 'Original prompt' }],
tests: [{}],
};
}
it('throws without mutating process.exitCode for reusable callers', async () => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
await expect(
evaluate(createTestSuite(), new Eval({}), {
eventSource: 'library',
generateSuggestions: true,
}),
).rejects.toEqual(expect.any(PromptSuggestionsRejectedError));
expect(process.exitCode).toBeUndefined();
} finally {
process.exitCode = previousExitCode;
}
});
it('preserves the CLI exit code when all suggested prompts are rejected', async () => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
await evaluate(createTestSuite(), new Eval({}), {
eventSource: 'cli',
generateSuggestions: true,
});
expect(process.exitCode).toBe(1);
} finally {
process.exitCode = previousExitCode;
}
});
it('does not throw or mutate process.exitCode when the user accepts a suggestion', async () => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
vi.mocked(promptYesNo).mockResolvedValue(true);
try {
const testSuite = createTestSuite();
// Library mode would throw PromptSuggestionsRejectedError if a regression
// flipped the boolean — covers the accepted branch that the rejection
// tests above intentionally don't exercise.
await expect(
evaluate(testSuite, new Eval({}), {
eventSource: 'library',
generateSuggestions: true,
}),
).resolves.toBeDefined();
expect(process.exitCode).toBeUndefined();
} finally {
process.exitCode = previousExitCode;
}
});
it('passes the requested suggestion count to the generator', async () => {
vi.mocked(promptYesNo).mockResolvedValueOnce(true);
await evaluate(createTestSuite(), new Eval({}), {
eventSource: 'library',
generateSuggestions: true,
suggestionsCount: 3,
});
expect(generatePrompts).toHaveBeenCalledWith('Original prompt', 3);
});
it('defaults suggestionsCount to 1 when omitted', async () => {
vi.mocked(promptYesNo).mockResolvedValueOnce(true);
await evaluate(createTestSuite(), new Eval({}), {
eventSource: 'library',
generateSuggestions: true,
});
expect(generatePrompts).toHaveBeenCalledWith('Original prompt', 1);
});
it('clamps over-cap suggestionsCount to MAX_SUGGESTIONS_COUNT', async () => {
vi.mocked(promptYesNo).mockResolvedValueOnce(true);
await evaluate(createTestSuite(), new Eval({}), {
eventSource: 'library',
generateSuggestions: true,
suggestionsCount: 1_000,
});
expect(generatePrompts).toHaveBeenCalledWith('Original prompt', 50);
});
it('coerces invalid suggestionsCount values to 1', async () => {
vi.mocked(promptYesNo).mockResolvedValueOnce(true);
await evaluate(createTestSuite(), new Eval({}), {
eventSource: 'library',
generateSuggestions: true,
suggestionsCount: 0,
});
expect(generatePrompts).toHaveBeenCalledWith('Original prompt', 1);
});
});
+952
View File
@@ -0,0 +1,952 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { clearCache, getCache } from '../../src/cache';
import { evaluate, runEval } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import {
type ApiProvider,
type RateLimitRegistryRef,
ResultFailureReason,
type TestSuite,
} from '../../src/types/index';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
import { toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator grading concurrency', () => {
it('schedules model-graded assertion provider calls through the rate limit registry', async () => {
const abortController = new AbortController();
const execute = vi.fn(async (_provider: ApiProvider, callFn: () => Promise<unknown>) =>
callFn(),
);
const rateLimitRegistry = {
execute,
dispose: vi.fn(),
} as RateLimitRegistryRef;
const targetProvider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test response',
tokenUsage: createEmptyTokenUsage(),
}),
};
const gradingProvider: ApiProvider = {
id: vi.fn().mockReturnValue('grading-provider'),
callApi: vi.fn().mockResolvedValue({
output: JSON.stringify({ pass: true, reason: 'Scheduled grading passed' }),
tokenUsage: createEmptyTokenUsage(),
}),
};
const results = await runEval({
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
provider: targetProvider,
prompt: { raw: 'Test prompt', label: 'test-label' },
test: {
assert: [
{
type: 'llm-rubric',
value: 'Output should be valid',
provider: gradingProvider,
},
],
},
conversations: {},
registers: {},
abortSignal: abortController.signal,
rateLimitRegistry,
});
expect(results[0].success).toBe(true);
expect(execute).toHaveBeenCalledTimes(2);
expect(execute.mock.calls.map(([provider]) => provider.id())).toEqual([
'target-provider',
'grading-provider',
]);
expect(gradingProvider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Output should be valid'),
expect.objectContaining({
prompt: { raw: expect.any(String), label: 'llm-rubric' },
vars: expect.objectContaining({
output: 'Test response',
rubric: 'Output should be valid',
}),
}),
{ abortSignal: abortController.signal },
);
});
it('groups model-graded assertion calls by provider id when maxConcurrency is 1', async () => {
const callOrder: string[] = [];
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judgeOne: ApiProvider = {
id: vi.fn().mockReturnValue('judge-one'),
callApi: vi.fn(async () => {
callOrder.push('judge-one');
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'judge one passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const judgeTwo: ApiProvider = {
id: vi.fn().mockReturnValue('judge-two'),
callApi: vi.fn(async () => {
callOrder.push('judge-two');
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'judge two passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [
{ type: 'llm-rubric', value: 'Judge alpha one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge alpha two', provider: judgeTwo },
],
},
{
vars: { topic: 'beta' },
assert: [
{ type: 'llm-rubric', value: 'Judge beta one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge beta two', provider: judgeTwo },
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(callOrder).toEqual(['judge-one', 'judge-one', 'judge-two', 'judge-two']);
});
it('keeps model-graded assertions row-first when prompts use _conversation', async () => {
const callOrder: string[] = [];
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => {
const topic = prompt.endsWith('Current: alpha') ? 'alpha' : 'beta';
callOrder.push(`target:${topic}`);
return {
output: `Target output for ${topic}`,
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => {
callOrder.push('judge');
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'judge passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [
toPrompt(
'{% for turn in _conversation %}{{ turn.input }} => {{ turn.output }}\n{% endfor %}Current: {{topic}}',
),
],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
{
vars: { topic: 'beta' },
assert: [{ type: 'llm-rubric', value: 'Judge beta', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(callOrder).toEqual(['target:alpha', 'judge', 'target:beta', 'judge']);
});
it('records deferred model-graded provider failures as row errors when maxConcurrency is 1', async () => {
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async (prompt: string) => {
if (prompt.includes('Judge alpha')) {
throw new Error('grader exploded');
}
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'judge passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
{
vars: { topic: 'beta' },
assert: [{ type: 'llm-rubric', value: 'Judge beta', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
const failedResult = summary.results.find((result) => result.vars.topic === 'alpha');
expect(summary.stats.errors).toBe(1);
expect(summary.stats.successes).toBe(1);
expect(summary.results).toHaveLength(2);
expect(failedResult?.failureReason).toBe(ResultFailureReason.ERROR);
expect(failedResult?.error).toContain('grader exploded');
});
it('stops grouped serial evals after a non-transient target status', async () => {
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
metadata: {
http: {
status: 403,
statusText: 'Forbidden',
},
},
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => ({
output: JSON.stringify({ pass: true, score: 1, reason: 'judge passed' }),
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
{
vars: { topic: 'beta' },
assert: [{ type: 'llm-rubric', value: 'Judge beta', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(provider.callApi).toHaveBeenCalledTimes(1);
expect(judge.callApi).toHaveBeenCalledTimes(1);
expect(summary.results).toHaveLength(1);
expect(summary.results[0].vars.topic).toBe('alpha');
});
it('groups model-graded assert-set children by provider id when maxConcurrency is 1', async () => {
const callOrder: string[] = [];
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judgeOne: ApiProvider = {
id: vi.fn().mockReturnValue('judge-one'),
callApi: vi.fn(async () => {
callOrder.push('judge-one');
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'judge one passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const judgeTwo: ApiProvider = {
id: vi.fn().mockReturnValue('judge-two'),
callApi: vi.fn(async () => {
callOrder.push('judge-two');
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'judge two passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [
{
type: 'assert-set',
assert: [
{ type: 'llm-rubric', value: 'Judge alpha one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge alpha two', provider: judgeTwo },
],
},
],
},
{
vars: { topic: 'beta' },
assert: [
{
type: 'assert-set',
assert: [
{ type: 'llm-rubric', value: 'Judge beta one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge beta two', provider: judgeTwo },
],
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(callOrder).toEqual(['judge-one', 'judge-one', 'judge-two', 'judge-two']);
});
it('keeps multi-call model-graded assertions grouped by provider id when maxConcurrency is 1', async () => {
const callOrder: string[] = [];
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const createJudge = (id: string): ApiProvider => ({
id: vi.fn().mockReturnValue(id),
callApi: vi.fn(async (_prompt: string, context?: { prompt?: { label?: string } }) => {
const label = context?.prompt?.label ?? 'unknown';
callOrder.push(`${id}:${label}`);
return {
output:
label === 'g-eval-steps'
? JSON.stringify({ steps: ['Check the answer'] })
: JSON.stringify({ score: 10, reason: 'passed' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
});
const judgeOne = createJudge('judge-one');
const judgeTwo = createJudge('judge-two');
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [
{ type: 'g-eval', value: 'Judge alpha one', provider: judgeOne },
{ type: 'g-eval', value: 'Judge alpha two', provider: judgeTwo },
],
},
{
vars: { topic: 'beta' },
assert: [
{ type: 'g-eval', value: 'Judge beta one', provider: judgeOne },
{ type: 'g-eval', value: 'Judge beta two', provider: judgeTwo },
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(callOrder).toEqual([
'judge-one:g-eval-steps',
'judge-one:g-eval-steps',
'judge-one:g-eval',
'judge-one:g-eval',
'judge-two:g-eval-steps',
'judge-two:g-eval-steps',
'judge-two:g-eval',
'judge-two:g-eval',
]);
});
it('isolates deferred grading cache entries by repeat index', async () => {
await clearCache();
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi.fn().mockResolvedValue({
cached: false,
output: 'target output',
tokenUsage: createEmptyTokenUsage(),
}),
};
let gradingCacheMissCount = 0;
const gradingProvider: ApiProvider = {
id: () => 'grading-provider',
callApi: vi.fn().mockImplementation(async () => {
const cache = getCache();
const cachedOutput = await cache.get<string>('deferred-grading-key');
if (cachedOutput) {
return {
cached: true,
output: cachedOutput,
tokenUsage: createEmptyTokenUsage(),
};
}
gradingCacheMissCount += 1;
const output = JSON.stringify({
pass: true,
reason: `grader-repeat-${gradingCacheMissCount}`,
score: 1,
});
await cache.set('deferred-grading-key', output);
return {
cached: false,
output,
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [{ type: 'llm-rubric', value: 'Output should pass', provider: gradingProvider }],
},
],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
expect(gradingCacheMissCount).toBe(2);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
expect(gradingCacheMissCount).toBe(2);
expect(gradingProvider.callApi).toHaveBeenCalledTimes(4);
});
it('serializes async assert-set judges when provider call queue is active', async () => {
// Regression: without forcing assertion concurrency to 1 when the grouping
// queue is active, async graders inside an assert-set would interleave
// judges across rows (judge-one row1, judge-two row1, judge-one row2, ...)
// and defeat the single-judge-drain guarantee.
const callOrder: string[] = [];
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const makeAsyncJudge = (id: string): ApiProvider => ({
id: vi.fn().mockReturnValue(id),
callApi: vi.fn(async () => {
// Intentionally yield through both microtask and macrotask queues so
// this regression test can expose the cross-row interleaving that
// assertion concurrency caused.
await Promise.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
callOrder.push(id);
return {
output: JSON.stringify({ pass: true, score: 1, reason: `${id} passed` }),
tokenUsage: createEmptyTokenUsage(),
};
}),
});
const judgeOne = makeAsyncJudge('judge-one');
const judgeTwo = makeAsyncJudge('judge-two');
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [
{
type: 'assert-set',
assert: [
{ type: 'llm-rubric', value: 'Judge alpha one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge alpha two', provider: judgeTwo },
],
},
],
},
{
vars: { topic: 'beta' },
assert: [
{
type: 'assert-set',
assert: [
{ type: 'llm-rubric', value: 'Judge beta one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge beta two', provider: judgeTwo },
],
},
],
},
{
vars: { topic: 'gamma' },
assert: [
{
type: 'assert-set',
assert: [
{ type: 'llm-rubric', value: 'Judge gamma one', provider: judgeOne },
{ type: 'llm-rubric', value: 'Judge gamma two', provider: judgeTwo },
],
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(3);
expect(callOrder).toEqual([
'judge-one',
'judge-one',
'judge-one',
'judge-two',
'judge-two',
'judge-two',
]);
});
it('does not log error-level message when deferred grading is aborted', async () => {
const { default: logger } = await import('../../src/logger');
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => logger);
const abortController = new AbortController();
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => {
abortController.abort();
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
throw abortError;
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {
maxConcurrency: 1,
abortSignal: abortController.signal,
});
const gradingErrorLogs = errorSpy.mock.calls.filter(
([message]) => typeof message === 'string' && message.includes('Assertion grading failed'),
);
expect(gradingErrorLogs).toHaveLength(0);
const failedResult = (await evalRecord.toEvaluateSummary()).results.find(
(result) => result.vars.topic === 'alpha',
);
expect(failedResult?.error).toMatch(/^Aborted: /);
errorSpy.mockRestore();
});
it('suppresses the error log when deferred grading throws AbortException under abort', async () => {
const { default: logger } = await import('../../src/logger');
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => logger);
const abortController = new AbortController();
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => {
abortController.abort();
const abortException = new Error('Python provider cancelled');
abortException.name = 'AbortException';
throw abortException;
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {
maxConcurrency: 1,
abortSignal: abortController.signal,
});
const gradingErrorLogs = errorSpy.mock.calls.filter(
([message]) => typeof message === 'string' && message.includes('Assertion grading failed'),
);
expect(gradingErrorLogs).toHaveLength(0);
errorSpy.mockRestore();
});
it('still logs error-level when abort-shaped error fires WITHOUT an aborted signal', async () => {
// Regression guard: third-party SDKs sometimes surface unrelated cancellation
// as AbortError. If the evaluator's abort signal is NOT tripped, these are
// real bugs and must still surface at error level.
const { default: logger } = await import('../../src/logger');
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => logger);
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => {
const sdkAbortLookalike = new Error('fetch aborted by keepalive');
sdkAbortLookalike.name = 'AbortError';
throw sdkAbortLookalike;
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const gradingErrorLogs = errorSpy.mock.calls.filter(
([message]) => typeof message === 'string' && message.includes('Assertion grading failed'),
);
expect(gradingErrorLogs.length).toBeGreaterThanOrEqual(1);
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.errors).toBe(1);
expect(summary.results[0].failureReason).toBe(ResultFailureReason.ERROR);
expect(summary.results[0].error).not.toMatch(/^Aborted: /);
errorSpy.mockRestore();
});
it('still logs error when a non-abort-shaped error fires during an unrelated abort', async () => {
// Regression guard: if abort is in flight but the caught error is a real
// bug (SyntaxError, TypeError, etc.), we must not silently suppress it.
const { default: logger } = await import('../../src/logger');
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => logger);
const abortController = new AbortController();
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => {
abortController.abort();
throw new SyntaxError('Unexpected token in grader output');
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {
maxConcurrency: 1,
abortSignal: abortController.signal,
});
const gradingErrorLogs = errorSpy.mock.calls.filter(
([message]) => typeof message === 'string' && message.includes('Assertion grading failed'),
);
expect(gradingErrorLogs.length).toBeGreaterThanOrEqual(1);
errorSpy.mockRestore();
});
it('logs grouping-active info when serial eval contains model-graded assertions', async () => {
const { default: logger } = await import('../../src/logger');
const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => ({
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const groupingLogs = infoSpy.mock.calls.filter(
([message]) =>
typeof message === 'string' &&
message.includes('Grouping model-graded assertions by provider'),
);
expect(groupingLogs).toHaveLength(1);
infoSpy.mockRestore();
});
it('does not log grouping info when there are no model-graded assertions', async () => {
const { default: logger } = await import('../../src/logger');
const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'contains', value: 'Target' }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const groupingLogs = infoSpy.mock.calls.filter(
([message]) =>
typeof message === 'string' &&
(message.includes('Grouping model-graded assertions by provider') ||
message.includes('Serial grading grouping disabled')),
);
expect(groupingLogs).toHaveLength(0);
infoSpy.mockRestore();
});
it('logs grouping-disabled reason when conversation var forces per-row ordering', async () => {
const { default: logger } = await import('../../src/logger');
const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async () => ({
output: 'Target output',
tokenUsage: createEmptyTokenUsage(),
})),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => ({
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [
toPrompt('{% for turn in _conversation %}{{ turn.input }}{% endfor %}Current: {{topic}}'),
],
tests: [
{
vars: { topic: 'alpha' },
assert: [{ type: 'llm-rubric', value: 'Judge alpha', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
const disabledLogs = infoSpy.mock.calls.filter(
([message]) =>
typeof message === 'string' &&
message.includes('Serial grading grouping disabled') &&
message.includes('conversation variables'),
);
expect(disabledLogs).toHaveLength(1);
infoSpy.mockRestore();
});
it('keeps parallel assertion dispatch when the grouping queue is NOT active', async () => {
// Regression guard for the `? 1 : ASSERTIONS_MAX_CONCURRENCY` ternary in
// runAssertions. Without the queue present (non-deferred concurrent eval),
// per-test assertions must still fan out so we don't silently 3x-throttle
// normal eval users.
let inFlight = 0;
let maxInFlight = 0;
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string) => ({
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
// Concurrency barrier: hold every in-flight judge until at least two have
// started, then release all of them together. This makes maxInFlight
// observation independent of wall-clock pacing.
let releaseHold: (() => void) | undefined;
const holdPromise = new Promise<void>((resolve) => {
releaseHold = resolve;
});
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
if (inFlight >= 2) {
releaseHold?.();
}
let resolveFallback!: () => void;
const fallback = new Promise<void>((r) => {
resolveFallback = r;
});
const fallbackHandle = setTimeout(() => resolveFallback(), 100);
try {
await Promise.race([holdPromise, fallback]);
} finally {
clearTimeout(fallbackHandle);
}
inFlight -= 1;
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'ok' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{ type: 'llm-rubric', value: 'Judge one', provider: judge },
{ type: 'llm-rubric', value: 'Judge two', provider: judge },
{ type: 'llm-rubric', value: 'Judge three', provider: judge },
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
// maxConcurrency > 1 takes the non-grouped path where the queue is absent.
await evaluate(testSuite, evalRecord, { maxConcurrency: 5 });
expect(inFlight).toBe(0);
expect(maxInFlight).toBeGreaterThanOrEqual(2);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { vi } from 'vitest';
import type { ApiProvider, Prompt, ProviderResponse } from '../../src/types/index';
const mockApiProvider: ApiProvider = {
id: vi.fn<ApiProvider['id']>(),
callApi: vi.fn<ApiProvider['callApi']>(),
};
const mockApiProvider2: ApiProvider = {
id: vi.fn<ApiProvider['id']>(),
callApi: vi.fn<ApiProvider['callApi']>(),
};
const mockReasoningApiProvider: ApiProvider = {
id: vi.fn<ApiProvider['id']>(),
callApi: vi.fn<ApiProvider['callApi']>(),
};
const mockGradingApiProviderPasses: ApiProvider = {
id: vi.fn<ApiProvider['id']>(),
callApi: vi.fn<ApiProvider['callApi']>(),
};
const mockGradingApiProviderFails: ApiProvider = {
id: vi.fn<ApiProvider['id']>(),
callApi: vi.fn<ApiProvider['callApi']>(),
};
function defaultTokenUsage() {
return { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 };
}
function defaultProviderResponse(): ProviderResponse {
return {
output: 'Test output',
tokenUsage: defaultTokenUsage(),
};
}
function resetProvider(provider: ApiProvider, id: string, response: ProviderResponse) {
vi.mocked(provider.id).mockReset().mockReturnValue(id);
vi.mocked(provider.callApi).mockReset().mockResolvedValue(response);
}
function resetMockProviders() {
resetProvider(mockApiProvider, 'test-provider', defaultProviderResponse());
resetProvider(mockApiProvider2, 'test-provider-2', defaultProviderResponse());
resetProvider(mockReasoningApiProvider, 'test-reasoning-provider', {
output: 'Test output',
tokenUsage: {
total: 21,
prompt: 9,
completion: 12,
cached: 0,
numRequests: 1,
completionDetails: { reasoning: 11, acceptedPrediction: 12, rejectedPrediction: 13 },
},
});
resetProvider(mockGradingApiProviderPasses, 'test-grading-provider', {
output: JSON.stringify({ pass: true, reason: 'Test grading output' }),
tokenUsage: defaultTokenUsage(),
});
resetProvider(mockGradingApiProviderFails, 'test-grading-provider', {
output: JSON.stringify({ pass: false, reason: 'Grading failed reason' }),
tokenUsage: defaultTokenUsage(),
});
}
function toPrompt(text: string): Prompt {
return { raw: text, label: text };
}
resetMockProviders();
export {
mockApiProvider,
mockApiProvider2,
mockGradingApiProviderFails,
mockGradingApiProviderPasses,
mockReasoningApiProvider,
resetMockProviders,
toPrompt,
};
+177
View File
@@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest';
import {
type InMemoryEvaluation,
InMemoryEvaluationStore,
} from '../../src/evaluator/inMemoryStore';
import { ResultFailureReason } from '../../src/types/index';
import { createCompletedPrompt, createEvaluateResult } from '../factories/eval';
function createEvaluation(overrides: Partial<InMemoryEvaluation> = {}): InMemoryEvaluation {
return {
id: 'in-memory-eval',
config: { description: 'In-memory evaluation' },
persisted: false,
prompts: [],
results: [],
vars: [],
resultPersistenceFailed: false,
finalResults: [],
failedResults: [],
...overrides,
};
}
describe('InMemoryEvaluationStore', () => {
it('exposes the evaluation record and its core properties', () => {
const evaluation = createEvaluation({
persisted: true,
prompts: [createCompletedPrompt('Initial prompt')],
results: [createEvaluateResult()],
});
const store = new InMemoryEvaluationStore(evaluation);
expect(store.evaluation).toBe(evaluation);
expect(store.id).toBe(evaluation.id);
expect(store.config).toBe(evaluation.config);
expect(store.persisted).toBe(true);
expect(store.prompts).toBe(evaluation.prompts);
expect(store.results).toBe(evaluation.results);
expect(store.resultPersistenceFailed).toBe(false);
});
it('appends results, replaces prompts, and reads results by test index', async () => {
const evaluation = createEvaluation();
const store = new InMemoryEvaluationStore(evaluation);
const first = createEvaluateResult({ testIdx: 1, promptIdx: 0 });
const second = createEvaluateResult({ testIdx: 2, promptIdx: 0 });
const third = createEvaluateResult({ testIdx: 1, promptIdx: 1 });
const prompts = [createCompletedPrompt('First'), createCompletedPrompt('Second')];
await store.appendResult(first);
await store.appendResult(second);
await store.appendResult(third);
await store.appendPrompts(prompts);
expect(await store.readResults()).toBe(evaluation.results);
expect(await store.readResults()).toEqual([first, second, third]);
expect(await store.readResultsByTestIdx(1)).toEqual([first, third]);
expect(evaluation.prompts).toBe(prompts);
});
it('uses deterministic testIdx:promptIdx pairs for resume filtering', async () => {
const evaluation = createEvaluation({
results: [
createEvaluateResult({
testIdx: 3,
promptIdx: 4,
failureReason: ResultFailureReason.NONE,
}),
createEvaluateResult({
testIdx: 3,
promptIdx: 5,
failureReason: ResultFailureReason.ERROR,
}),
createEvaluateResult({
testIdx: 3,
promptIdx: 4,
failureReason: ResultFailureReason.ASSERT,
}),
],
});
const store = new InMemoryEvaluationStore(evaluation);
expect(await store.readCompletedIndexPairs()).toEqual(new Set(['3:4', '3:5']));
expect(await store.readCompletedIndexPairs({ excludeErrors: false })).toEqual(
new Set(['3:4', '3:5']),
);
expect(await store.readCompletedIndexPairs({ excludeErrors: true })).toEqual(new Set(['3:4']));
});
it('records final results with last-write-wins index semantics', () => {
const initial = createEvaluateResult({ testIdx: 0, promptIdx: 1, score: 0 });
const replacement = createEvaluateResult({ testIdx: 0, promptIdx: 1, score: 1 });
const other = createEvaluateResult({ testIdx: 1, promptIdx: 0 });
const evaluation = createEvaluation({ finalResults: [initial] });
const store = new InMemoryEvaluationStore(evaluation);
store.recordFinalResult(other);
store.recordFinalResult(replacement);
expect(evaluation.finalResults).toEqual([replacement, other]);
});
it('tracks persistence failures by index and preserves result mutations across reads', async () => {
const first = createEvaluateResult({ testIdx: 2, promptIdx: 0, score: 1 });
const otherTest = createEvaluateResult({ testIdx: 3, promptIdx: 0 });
const evaluation = createEvaluation();
const store = new InMemoryEvaluationStore(evaluation);
store.recordResultPersistenceFailure(first);
store.recordResultPersistenceFailure(otherTest);
expect(store.resultPersistenceFailed).toBe(true);
expect(store.hasResultPersistenceFailure({ testIdx: 2, promptIdx: 0 })).toBe(true);
expect(store.hasResultPersistenceFailure({ testIdx: 2, promptIdx: 1 })).toBe(false);
const [failed] = await store.readFailedResultsByTestIdx(2);
failed.score = 0;
expect((await store.readFailedResultsByTestIdx(2))[0]).toBe(failed);
expect((await store.readFailedResultsByTestIdx(2))[0].score).toBe(0);
});
it('replaces a re-recorded persistence failure for the same index', async () => {
const first = createEvaluateResult({ testIdx: 2, promptIdx: 0, score: 0 });
const replacement = createEvaluateResult({ testIdx: 2, promptIdx: 0, score: 1 });
const evaluation = createEvaluation({ failedResults: [first] });
const store = new InMemoryEvaluationStore(evaluation);
expect(store.resultPersistenceFailed).toBe(true);
store.recordResultPersistenceFailure(replacement);
expect(await store.readFailedResultsByTestIdx(2)).toEqual([replacement]);
expect(evaluation.failedResults).toEqual([replacement]);
});
it('saves the evaluation and upserts saved results by index', async () => {
const original = createEvaluateResult({ testIdx: 0, promptIdx: 0, score: 0 });
const replacement = createEvaluateResult({ testIdx: 0, promptIdx: 0, score: 1 });
const additional = createEvaluateResult({ testIdx: 0, promptIdx: 1 });
const evaluation = createEvaluation({ results: [original] });
const store = new InMemoryEvaluationStore(evaluation);
await store.saveResult(replacement);
await store.saveResult(additional);
await store.save();
expect(evaluation.persisted).toBe(true);
expect(evaluation.results).toEqual([replacement, additional]);
});
it('updates vars and valid durations while retaining generation duration', () => {
const evaluation = createEvaluation({
generationDurationMs: 25,
durationMs: 25,
});
const store = new InMemoryEvaluationStore(evaluation);
store.setVars(['topic', 'language']);
store.setDurationMs(75);
expect(evaluation.vars).toEqual(['topic', 'language']);
expect(evaluation.evaluationDurationMs).toBe(75);
expect(evaluation.durationMs).toBe(100);
store.setDurationMs(-1);
store.setDurationMs(Number.NaN);
store.setDurationMs(Number.POSITIVE_INFINITY);
expect(evaluation.evaluationDurationMs).toBe(75);
expect(evaluation.durationMs).toBe(100);
});
it('returns EvaluateResult values unchanged', () => {
const store = new InMemoryEvaluationStore(createEvaluation());
const result = createEvaluateResult();
expect(store.toEvaluateResult(result)).toBe(result);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, vi } from 'vitest';
import { clearCache } from '../../src/cache';
import cliState from '../../src/cliState';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import { runDbMigrations } from '../../src/migrate';
import { resetMockProviders } from './helpers';
export function describeEvaluator(name: string, defineTests: () => void) {
describe(name, () => {
beforeAll(async () => {
await runDbMigrations();
});
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
resetMockProviders();
vi.mocked(runExtensionHook).mockReset();
vi.mocked(runExtensionHook).mockImplementation(
async (_extensions, _hookName, context) => context,
);
cliState.resume = false;
cliState.retryMode = false;
cliState.basePath = '';
cliState.webUI = false;
});
afterEach(async () => {
vi.useRealTimers();
resetMockProviders();
vi.clearAllMocks();
cliState.resume = false;
cliState.retryMode = false;
cliState.basePath = '';
cliState.webUI = false;
await clearCache();
if (global.gc) {
global.gc();
}
});
afterAll(() => {
vi.restoreAllMocks();
vi.resetModules();
});
defineTests();
});
}
+579
View File
@@ -0,0 +1,579 @@
import './setup';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { expect, it, vi } from 'vitest';
import { FILE_METADATA_KEY } from '../../src/constants';
import { evaluate } from '../../src/evaluator';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import Eval from '../../src/models/eval';
import { type ApiProvider, type EvaluateSummaryV3, type TestSuite } from '../../src/types/index';
import { mockApiProvider, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator metadata', () => {
it('evaluate with metadata passed to test transform', async () => {
const mockApiProviderWithMetadata: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-metadata'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
metadata: { responseTime: 123, modelVersion: 'v1.0' },
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithMetadata],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Output: Test output, Metadata: {"responseTime":123,"modelVersion":"v1.0"}',
},
],
options: {
transform:
'context?.metadata ? `Output: ${output}, Metadata: ${JSON.stringify(context.metadata)}` : output',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProviderWithMetadata.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe(
'Output: Test output, Metadata: {"responseTime":123,"modelVersion":"v1.0"}',
);
});
it('evaluate with metadata passed to test transform - no metadata case', async () => {
const mockApiProviderNoMetadata: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-no-metadata'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderNoMetadata],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'No metadata: Test output',
},
],
options: {
transform: 'context?.metadata ? `Has metadata: ${output}` : `No metadata: ${output}`',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProviderNoMetadata.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('No metadata: Test output');
});
it('evaluate with metadata passed to test transform - empty metadata', async () => {
const mockApiProviderEmptyMetadata: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-empty-metadata'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
metadata: {},
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderEmptyMetadata],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Empty metadata: Test output',
},
],
options: {
transform:
'(context?.metadata && Object.keys(context.metadata).length === 0) ? `Empty metadata: ${output}` : output',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProviderEmptyMetadata.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Empty metadata: Test output');
});
it('evaluate with metadata preserved alongside other context properties', async () => {
const mockApiProviderWithMetadata: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-metadata-context'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
metadata: { modelInfo: 'gpt-4' },
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithMetadata],
prompts: [toPrompt('Test {{ var }}')],
tests: [
{
vars: { var: 'value' },
assert: [
{
type: 'equals',
value: 'All context: Test output',
},
],
options: {
transform:
'(Boolean(context?.vars) && Boolean(context?.prompt) && Boolean(context?.metadata)) ? `All context: ${output}` : `Missing context: ${output}`',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProviderWithMetadata.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('All context: Test output');
});
it('merges metadata correctly for regular tests', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
defaultTest: {
metadata: { defaultKey: 'defaultValue' },
},
tests: [
{
metadata: { testKey: 'testValue' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const results = await evalRecord.getResults();
expect(results[0].testCase.metadata).toEqual({
defaultKey: 'defaultValue',
testKey: 'testValue',
});
});
it('merges response metadata with test metadata', async () => {
const mockProviderWithMetadata: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-with-metadata'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
metadata: { responseKey: 'responseValue' },
}),
};
const testSuite: TestSuite = {
providers: [mockProviderWithMetadata],
prompts: [toPrompt('Test prompt')],
tests: [
{
metadata: { testKey: 'testValue' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const results = await evalRecord.getResults();
// Check that both test metadata and response metadata are present in the result
expect(results[0].metadata).toEqual({
testKey: 'testValue',
responseKey: 'responseValue',
[FILE_METADATA_KEY]: {},
});
});
it('evaluate with _conversation variable', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockImplementation((prompt) =>
Promise.resolve({
output: prompt,
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('{{ var1 }} {{ _conversation[0].output }}')],
tests: [
{
vars: { var1: 'First run' },
},
{
vars: { var1: 'Second run' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
expect(summary.stats.successes).toBe(2);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('First run ');
expect(summary.results[1].response?.output).toBe('Second run First run ');
});
it('should maintain separate conversation histories based on metadata.conversationId', async () => {
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockImplementation((_prompt) => ({
output: 'Test output',
})),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [
{
raw: '{% for completion in _conversation %}User: {{ completion.input }}\nAssistant: {{ completion.output }}\n{% endfor %}User: {{ question }}',
label: 'Conversation test',
},
],
tests: [
// First conversation
{
vars: { question: 'Question 1A' },
metadata: { conversationId: 'conversation1' },
},
{
vars: { question: 'Question 1B' },
metadata: { conversationId: 'conversation1' },
},
// Second conversation
{
vars: { question: 'Question 2A' },
metadata: { conversationId: 'conversation2' },
},
{
vars: { question: 'Question 2B' },
metadata: { conversationId: 'conversation2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
// Check that the API was called with the correct prompts
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(4);
// First conversation, first question
expect(mockApiProvider.callApi).toHaveBeenNthCalledWith(
1,
expect.stringContaining('User: Question 1A'),
expect.anything(),
undefined,
);
// First conversation, second question (should include history)
expect(mockApiProvider.callApi).toHaveBeenNthCalledWith(
2,
expect.stringContaining('User: Question 1A\nAssistant: Test output\nUser: Question 1B'),
expect.anything(),
undefined,
);
// Second conversation, first question (should NOT include first conversation)
expect(mockApiProvider.callApi).toHaveBeenNthCalledWith(
3,
expect.stringContaining('User: Question 2A'),
expect.anything(),
undefined,
);
// Second conversation, second question (should only include second conversation history)
expect(mockApiProvider.callApi).toHaveBeenNthCalledWith(
4,
expect.stringContaining('User: Question 2A\nAssistant: Test output\nUser: Question 2B'),
expect.anything(),
undefined,
);
});
it('should include sessionId in metadata for afterEach hook', async () => {
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
sessionId: 'test-session-123',
}),
};
const mockExtension = 'file://test-extension.js';
let capturedContext: any;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'afterEach') {
capturedContext = context;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { var1: 'value1' },
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedContext).toBeDefined();
expect(capturedContext.result.metadata.sessionId).toBe('test-session-123');
});
it('should include sessionIds array from test metadata for iterative providers', async () => {
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
}),
};
const mockExtension = 'file://test-extension.js';
let capturedContext: any;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'afterEach') {
capturedContext = context;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { var1: 'value1' },
metadata: {
sessionIds: ['iter-session-1', 'iter-session-2', 'iter-session-3'],
},
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedContext).toBeDefined();
expect(capturedContext.result.metadata.sessionIds).toEqual([
'iter-session-1',
'iter-session-2',
'iter-session-3',
]);
expect(capturedContext.result.metadata.sessionId).toBeUndefined();
});
it('should persist afterEach hook namedScores, metadata, and response.metadata into result and metrics', async () => {
const mockExtension = 'file://test-extension.js:afterEach';
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'afterEach') {
const ctx = context as { test: any; result: any };
return {
...ctx,
result: {
...ctx.result,
namedScores: {
...ctx.result.namedScores,
hook_metric: 42,
},
metadata: {
...ctx.result.metadata,
hook_key: 'hook_value',
},
response: {
...ctx.result.response,
metadata: {
...ctx.result.response?.metadata,
session_url: 'https://example.com/session/123',
},
},
},
};
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: {},
assert: [{ type: 'equals', value: 'Test output' }],
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = (await evalRecord.toEvaluateSummary()) as EvaluateSummaryV3;
// Verify hook's namedScores flowed into prompt metrics
expect(summary.prompts[0].metrics?.namedScores).toHaveProperty('hook_metric', 42);
// Verify hook's metadata and namedScores are in the persisted result
const result = summary.results[0];
expect(result.metadata).toHaveProperty('hook_key', 'hook_value');
expect(result.namedScores).toHaveProperty('hook_metric', 42);
expect(result.response?.metadata).toHaveProperty(
'session_url',
'https://example.com/session/123',
);
});
it('should persist row without hook modifications when afterEach hook throws', async () => {
const mockExtension = 'file://test-extension.js:afterEach';
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, _context) => {
if (hookName === 'afterEach') {
throw new Error('Hook exploded');
}
return _context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: {},
assert: [{ type: 'equals', value: 'Test output' }],
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = (await evalRecord.toEvaluateSummary()) as EvaluateSummaryV3;
// Row should still be persisted despite hook failure
expect(summary.results).toHaveLength(1);
expect(summary.results[0].success).toBe(true);
expect(summary.stats.successes).toBe(1);
});
it('drops stale provider headers when afterEach replaces response metadata', async () => {
const outputPath = path.join(os.tmpdir(), `promptfoo-evaluator-${randomUUID()}.jsonl`);
const mockExtension = 'file://test-extension.js:afterEach';
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('metadata-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
metadata: {
headers: {
authorization: 'Bearer provider-secret',
'x-safe-debug': 'provider-debug',
},
},
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
vi.mocked(runExtensionHook).mockImplementation(async (_extensions, hookName, context) => {
if (hookName !== 'afterEach') {
return context;
}
const ctx = context as { test: any; result: any };
return {
...ctx,
result: {
...ctx.result,
response: {
...ctx.result.response,
metadata: {
hook_key: 'hook_value',
},
},
},
};
});
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
extensions: [mockExtension],
};
try {
const evalRecord = await Eval.create({ outputPath }, testSuite.prompts, {
id: randomUUID(),
});
await evaluate(testSuite, evalRecord, {});
const summary = (await evalRecord.toEvaluateSummary()) as EvaluateSummaryV3;
const [result] = summary.results;
const [artifactResult] = fs
.readFileSync(outputPath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line));
// The hook replaced response.metadata, so the legacy top-level metadata.headers copied
// from the original transport is stale and must be dropped (not persisted).
expect(result.metadata?.headers).toBeUndefined();
expect(result.response?.metadata).toEqual({ hook_key: 'hook_value' });
expect(artifactResult.metadata.headers).toBeUndefined();
expect(artifactResult.response.metadata).toEqual({ hook_key: 'hook_value' });
expect(JSON.stringify({ artifactResult, result })).not.toContain('provider-secret');
} finally {
fs.rmSync(outputPath, { force: true });
}
});
});
+496
View File
@@ -0,0 +1,496 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type ApiProvider, ResultFailureReason, type TestSuite } from '../../src/types/index';
import { mockApiProvider, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator metrics and scoring', () => {
it('evaluator should count named score assertions per metric', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt for namedScoresCount')],
tests: [
{
vars: { var1: 'value1' },
assert: [
{
type: 'equals',
value: 'Test output',
metric: 'Accuracy',
},
{
type: 'contains',
value: 'Test',
metric: 'Accuracy',
},
{
type: 'javascript',
value: 'output.length > 0',
metric: 'Completeness',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.results).toHaveLength(1);
const result = summary.results[0];
// Use toMatchObject pattern to avoid conditional expects
expect(evalRecord.prompts).toEqual(
expect.arrayContaining([
expect.objectContaining({
provider: result.provider.id,
metrics: expect.objectContaining({
namedScoresCount: expect.objectContaining({
Accuracy: 2,
Completeness: 1,
}),
}),
}),
]),
);
});
it('evaluator should count named scores with template metric variables per assertion', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt for template metrics')],
tests: [
{
vars: { metricCategory: 'Accuracy' },
assert: [
{
type: 'equals',
value: 'Test output',
metric: '{{metricCategory}}',
},
{
type: 'contains',
value: 'Test',
metric: '{{metricCategory}}',
},
],
},
{
vars: { metricCategory: 'Accuracy' },
assert: [
{
type: 'javascript',
value: 'output.length > 0',
metric: '{{metricCategory}}',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(evalRecord.prompts).toEqual(
expect.arrayContaining([
expect.objectContaining({
metrics: expect.objectContaining({
namedScores: expect.objectContaining({
Accuracy: expect.any(Number),
}),
namedScoresCount: expect.objectContaining({
Accuracy: 3, // 2 assertions in the first test + 1 in the second
}),
}),
}),
]),
);
});
it('evaluator should preserve weighted named score totals alongside prompt denominators', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt for weighted metrics')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Test output',
metric: 'Accuracy',
weight: 3,
},
{
type: 'contains',
value: 'Missing output',
metric: 'Accuracy',
weight: 1,
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const results = await evalRecord.getResults();
const promptMetrics = evalRecord.prompts[0]?.metrics;
expect(results[0].namedScores?.Accuracy).toBeCloseTo(0.75, 10);
expect(results[0].gradingResult?.namedScoreWeights?.Accuracy).toBe(4);
expect(promptMetrics?.namedScores.Accuracy).toBeCloseTo(3, 10);
expect(promptMetrics?.namedScoresCount.Accuracy).toBe(2);
expect(promptMetrics?.namedScoreWeights?.Accuracy).toBe(4);
expect(
(promptMetrics?.namedScores.Accuracy ?? 0) /
(promptMetrics?.namedScoreWeights?.Accuracy ?? 1),
).toBeCloseTo(0.75, 10);
});
it('evaluator should handle mixed static and template metrics correctly', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test mixed metrics')],
tests: [
{
vars: { category: 'Dynamic' },
assert: [
{
type: 'equals',
value: 'Test output',
metric: '{{category}}',
},
{
type: 'contains',
value: 'Test',
metric: 'Static',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(evalRecord.prompts).toEqual(
expect.arrayContaining([
expect.objectContaining({
metrics: expect.objectContaining({
namedScoresCount: expect.objectContaining({
Dynamic: 1,
Static: 1,
}),
}),
}),
]),
);
});
it('evaluator should calculate derived metrics with __count variable for averages', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt for derived metrics')],
tests: [
{
vars: { errorValue: 0.1 },
assert: [
{
type: 'javascript',
value: 'context.vars.errorValue',
metric: 'APE',
},
],
},
{
vars: { errorValue: 0.2 },
assert: [
{
type: 'javascript',
value: 'context.vars.errorValue',
metric: 'APE',
},
],
},
{
vars: { errorValue: 0.3 },
assert: [
{
type: 'javascript',
value: 'context.vars.errorValue',
metric: 'APE',
},
],
},
],
derivedMetrics: [
{
name: 'APE_sum',
value: 'APE', // Should be 0.1 + 0.2 + 0.3 = 0.6
},
{
name: 'MAPE',
value: 'APE / __count', // Should be 0.6 / 3 = 0.2
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const metrics = evalRecord.prompts[0]?.metrics;
expect(metrics).toBeDefined();
expect(metrics!.namedScores.APE).toBeCloseTo(0.6, 10);
expect(metrics!.namedScores.APE_sum).toBeCloseTo(0.6, 10);
expect(metrics!.namedScores.MAPE).toBeCloseTo(0.2, 10);
});
it('evaluator should pass __count to JavaScript function derived metrics', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt for function derived metrics')],
tests: [
{
vars: { score: 10 },
assert: [{ type: 'javascript', value: 'context.vars.score', metric: 'Score' }],
},
{
vars: { score: 20 },
assert: [{ type: 'javascript', value: 'context.vars.score', metric: 'Score' }],
},
],
derivedMetrics: [
{
name: 'AverageScore',
value: (namedScores: Record<string, number>) => {
// __count should be available in namedScores
const count = namedScores.__count || 1;
return namedScores.Score / count;
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(evalRecord.prompts).toEqual(
expect.arrayContaining([
expect.objectContaining({
metrics: expect.objectContaining({
namedScores: expect.objectContaining({
Score: 30, // 10 + 20
AverageScore: 15, // 30 / 2
}),
}),
}),
]),
);
});
it('evaluator should calculate __count per-prompt with multiple prompts', async () => {
// With 2 prompts and 3 tests, each prompt should have __count = 3 (not 6)
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('First prompt'), toPrompt('Second prompt')],
tests: [
{
vars: { errorValue: 0.1 },
assert: [{ type: 'javascript', value: 'context.vars.errorValue', metric: 'APE' }],
},
{
vars: { errorValue: 0.2 },
assert: [{ type: 'javascript', value: 'context.vars.errorValue', metric: 'APE' }],
},
{
vars: { errorValue: 0.3 },
assert: [{ type: 'javascript', value: 'context.vars.errorValue', metric: 'APE' }],
},
],
derivedMetrics: [
{
name: 'MAPE',
value: 'APE / __count', // Should be 0.6 / 3 = 0.2 for each prompt
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
// Both prompts should have the same MAPE since they each get 3 test evaluations
const metrics0 = evalRecord.prompts[0]?.metrics;
const metrics1 = evalRecord.prompts[1]?.metrics;
expect(metrics0).toBeDefined();
expect(metrics1).toBeDefined();
expect(metrics0!.namedScores.APE).toBeCloseTo(0.6, 10);
expect(metrics0!.namedScores.MAPE).toBeCloseTo(0.2, 10); // 0.6 / 3, not 0.6 / 6
expect(metrics1!.namedScores.APE).toBeCloseTo(0.6, 10);
expect(metrics1!.namedScores.MAPE).toBeCloseTo(0.2, 10); // 0.6 / 3, not 0.6 / 6
});
it('evaluator should calculate __count per-prompt with multiple providers', async () => {
// With 1 prompt and 2 providers, there are 2 prompt entries (one per provider).
// Each prompt entry gets 2 test evaluations, so __count = 2 for each.
const mockProvider1: ApiProvider = {
id: () => 'provider1',
callApi: async () => ({ output: 'response1' }),
};
const mockProvider2: ApiProvider = {
id: () => 'provider2',
callApi: async () => ({ output: 'response2' }),
};
const testSuite: TestSuite = {
providers: [mockProvider1, mockProvider2],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { errorValue: 0.1 },
assert: [{ type: 'javascript', value: 'context.vars.errorValue', metric: 'APE' }],
},
{
vars: { errorValue: 0.2 },
assert: [{ type: 'javascript', value: 'context.vars.errorValue', metric: 'APE' }],
},
],
derivedMetrics: [
{
name: 'MAPE',
value: 'APE / __count', // 0.3 / 2 = 0.15 for each provider's prompt
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
// With 2 providers, there are 2 prompt entries (one per provider)
expect(evalRecord.prompts).toHaveLength(2);
// Each prompt entry has its own metrics from its 2 test evaluations
const metrics0 = evalRecord.prompts[0]?.metrics;
const metrics1 = evalRecord.prompts[1]?.metrics;
expect(metrics0).toBeDefined();
expect(metrics1).toBeDefined();
// Each provider's prompt gets APE = 0.3 (0.1 + 0.2)
expect(metrics0!.namedScores.APE).toBeCloseTo(0.3, 10);
expect(metrics1!.namedScores.APE).toBeCloseTo(0.3, 10);
// __count = 2 (tests per provider), so MAPE = 0.3 / 2 = 0.15
expect(metrics0!.namedScores.MAPE).toBeCloseTo(0.15, 10);
expect(metrics1!.namedScores.MAPE).toBeCloseTo(0.15, 10);
});
it('should apply max-score to overall pass/fail and stats', async () => {
const maxScoreProvider: ApiProvider = {
id: vi.fn().mockReturnValue('max-score-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'hello world',
tokenUsage: { total: 1, prompt: 1, completion: 0, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [maxScoreProvider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [{ type: 'contains', value: 'hello' }, { type: 'max-score' }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
const results = summary.results
.filter((result) => result.testIdx === 0)
.sort((a, b) => a.promptIdx - b.promptIdx);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false);
expect(results[1].failureReason).toBe(ResultFailureReason.ASSERT);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(1);
expect(summary.stats.errors).toBe(0);
});
it('should apply select-best to overall pass/fail and stats', async () => {
// Mock matchesSelectBest to return deterministic results (first wins, second loses)
const matchers = await import('../../src/matchers/comparison');
const matchesSelectBestSpy = vi.spyOn(matchers, 'matchesSelectBest').mockResolvedValue([
{ pass: true, score: 1, reason: 'Selected as best' },
{ pass: false, score: 0, reason: 'Not selected' },
]);
try {
const selectBestProvider: ApiProvider = {
id: vi.fn().mockReturnValue('select-best-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'hello world',
tokenUsage: { total: 1, prompt: 1, completion: 0, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [selectBestProvider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [
{ type: 'contains', value: 'hello' },
{ type: 'select-best', value: 'choose the best one' },
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
const results = summary.results
.filter((result) => result.testIdx === 0)
.sort((a, b) => a.promptIdx - b.promptIdx);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false);
expect(results[1].failureReason).toBe(ResultFailureReason.ASSERT);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(1);
expect(summary.stats.errors).toBe(0);
} finally {
matchesSelectBestSpy.mockRestore();
}
});
it('evaluate with assertScoringFunction', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assertScoringFunction: 'file://path/to/scoring.js:customScore',
assert: [
{
type: 'equals',
value: 'Test output',
metric: 'accuracy',
},
{
type: 'contains',
value: 'output',
metric: 'relevance',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].score).toBe(0.75);
});
});
+265
View File
@@ -0,0 +1,265 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import { mockApiProvider, mockApiProvider2, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator options and hooks', () => {
it('should use the options from the test if they exist', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { var1: 'value1', var2: 'value2' },
options: {
transform: 'output + " postprocessed"',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Test output postprocessed');
});
it('should apply prompt config to provider call', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [
{
raw: 'You are a helpful math tutor. Solve {{problem}}',
label: 'Math problem',
config: {
response_format: {
type: 'json_schema',
json_schema: {
name: 'math_response',
strict: true,
schema: {
type: 'object',
properties: {
final_answer: { type: 'string' },
},
required: ['final_answer'],
additionalProperties: false,
},
},
},
},
},
],
tests: [{ vars: { problem: '8x + 31 = 2' } }],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(mockApiProvider.callApi).toHaveBeenCalledWith(
'You are a helpful math tutor. Solve 8x + 31 = 2',
expect.objectContaining({
prompt: expect.objectContaining({
config: {
response_format: {
type: 'json_schema',
json_schema: {
name: 'math_response',
schema: {
type: 'object',
properties: { final_answer: { type: 'string' } },
required: ['final_answer'],
additionalProperties: false,
},
strict: true,
},
},
},
}),
}),
undefined,
);
});
it('should apply dynamic prompt function config to provider call', async () => {
const mockDynamicConfigProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockDynamicConfigProvider],
prompts: [
{
raw: 'ignored by prompt function',
label: 'Dynamic prompt with config',
config: {
temperature: 0.1,
top_p: 0.9,
},
function: async () => ({
prompt: 'Solve this problem: {{problem}}',
config: {
temperature: 0.3,
response_format: { type: 'json_object' },
},
}),
},
],
tests: [
{
vars: { problem: '8x + 31 = 2' },
options: { temperature: 0.7 },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(mockDynamicConfigProvider.callApi).toHaveBeenCalledTimes(1);
expect(mockDynamicConfigProvider.callApi).toHaveBeenCalledWith(
'Solve this problem: 8x + 31 = 2',
expect.objectContaining({
prompt: expect.objectContaining({
config: {
temperature: 0.7,
top_p: 0.9,
response_format: { type: 'json_object' },
},
}),
}),
undefined,
);
});
it('should call runExtensionHook with correct parameters at appropriate times', async () => {
const mockExtension = 'file:./path/to/extension.js:extensionFunction';
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ var1 }}')],
tests: [
{
vars: { var1: 'value1' },
assert: [{ type: 'equals', value: 'Test output' }],
},
],
extensions: [mockExtension],
};
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockClear();
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
// Check if runExtensionHook was called 4 times (beforeAll, beforeEach, afterEach, afterAll)
expect(mockedRunExtensionHook).toHaveBeenCalledTimes(4);
// Check beforeAll call
expect(mockedRunExtensionHook).toHaveBeenNthCalledWith(
1,
[mockExtension],
'beforeAll',
expect.objectContaining({ suite: testSuite }),
);
// Check beforeEach call
expect(mockedRunExtensionHook).toHaveBeenNthCalledWith(
2,
[mockExtension],
'beforeEach',
expect.objectContaining({
test: expect.objectContaining({
vars: { var1: 'value1' },
assert: [{ type: 'equals', value: 'Test output' }],
}),
}),
);
// Check afterEach call
expect(mockedRunExtensionHook).toHaveBeenNthCalledWith(
3,
[mockExtension],
'afterEach',
expect.objectContaining({
test: expect.objectContaining({
vars: { var1: 'value1' },
assert: [{ type: 'equals', value: 'Test output' }],
}),
result: expect.objectContaining({
success: true,
score: 1,
response: expect.objectContaining({
output: 'Test output',
}),
}),
}),
);
// Check afterAll call
expect(mockedRunExtensionHook).toHaveBeenNthCalledWith(
4,
[mockExtension],
'afterAll',
expect.objectContaining({
prompts: expect.arrayContaining([
expect.objectContaining({
raw: 'Test prompt {{ var1 }}',
metrics: expect.objectContaining({
assertPassCount: 1,
assertFailCount: 0,
}),
}),
expect.objectContaining({
raw: 'Test prompt {{ var1 }}',
metrics: expect.objectContaining({
assertPassCount: 1,
assertFailCount: 0,
}),
}),
]),
results: expect.any(Array),
suite: testSuite,
}),
);
});
it('should handle multiple providers', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider, mockApiProvider2],
prompts: [toPrompt('Test prompt')],
tests: [],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(summary.stats.failures).toBe(0);
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(mockApiProvider2.callApi).toHaveBeenCalledTimes(1);
});
});
+452
View File
@@ -0,0 +1,452 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { clearCache, getCache } from '../../src/cache';
import cliState from '../../src/cliState';
import { evaluate, runEval } from '../../src/evaluator';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import Eval from '../../src/models/eval';
import {
type ApiProvider,
type Prompt,
type ProviderResponse,
ResultFailureReason,
type TestSuite,
} from '../../src/types/index';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
import { toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator repeat cache isolation', () => {
it('preserves provider cache settings for repeat iterations', async () => {
const contexts: Array<Record<string, any> | undefined> = [];
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (_prompt: string, context?: Record<string, any>) => {
contexts.push(context);
return {
output: 'result',
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const baseOptions = {
provider,
prompt: { raw: 'Test prompt', label: 'test-label' } as Prompt,
delay: 0,
nunjucksFilters: undefined,
evaluateOptions: {},
testIdx: 0,
promptIdx: 0,
conversations: {},
registers: {},
isRedteam: false,
};
await runEval({
...baseOptions,
test: { assert: [] },
repeatIndex: 0,
});
expect(contexts).toHaveLength(1);
expect(contexts[0]).toBeDefined();
expect(contexts[0]!.bustCache).toBeFalsy();
contexts.length = 0;
await runEval({
...baseOptions,
test: { assert: [] },
repeatIndex: 1,
});
expect(contexts).toHaveLength(1);
expect(contexts[0]).toBeDefined();
expect(contexts[0]!.bustCache).toBeFalsy();
});
it('isolates per-test repeat provider cache entries from the default namespace', async () => {
await clearCache();
const baselineResponse: ProviderResponse = {
output: 'baseline-result',
tokenUsage: createEmptyTokenUsage(),
};
await getCache().set('manual-provider-key', baselineResponse);
let cacheMissCount = 0;
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (_prompt: string, context?: Record<string, any>) => {
const cache = await context?.getCache();
const cachedResponse = await cache?.get('manual-provider-key');
if (cachedResponse) {
return {
...(cachedResponse as ProviderResponse),
cached: true,
};
}
cacheMissCount += 1;
const response = {
cached: false,
output: `result-repeat-${context?.repeatIndex}-miss-${cacheMissCount}`,
tokenUsage: createEmptyTokenUsage(),
};
await cache?.set('manual-provider-key', response);
return response;
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{ options: { repeat: 2 } }],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1 });
const firstSummary = await firstEval.toEvaluateSummary();
expect(cacheMissCount).toBe(2);
expect(firstSummary.results.map((result) => result.response?.output)).toEqual([
'result-repeat-0-miss-1',
'result-repeat-1-miss-2',
]);
expect(firstSummary.results.map((result) => result.response?.cached)).toEqual([false, false]);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1 });
const secondSummary = await secondEval.toEvaluateSummary();
expect(cacheMissCount).toBe(2);
expect(secondSummary.results.map((result) => result.response?.output)).toEqual([
'result-repeat-0-miss-1',
'result-repeat-1-miss-2',
]);
expect(secondSummary.results.map((result) => result.response?.cached)).toEqual([true, true]);
expect(await getCache().get('manual-provider-key')).toEqual(baselineResponse);
});
it('isolates beforeEach extension cache entries by repeat index', async () => {
await clearCache();
let extensionCacheMissCount = 0;
vi.mocked(runExtensionHook).mockImplementation(async (_extensions, hookName, context) => {
if (hookName !== 'beforeEach' || !('test' in context)) {
return context;
}
const hookContext = context as typeof context & {
test: {
vars?: Record<string, unknown>;
};
};
const cache = getCache();
const cachedHookValue = await cache.get<string>('extension-hook-key');
if (cachedHookValue) {
return {
...hookContext,
test: {
...hookContext.test,
vars: {
...hookContext.test.vars,
hookValue: cachedHookValue,
},
},
};
}
extensionCacheMissCount += 1;
const hookValue = `hook-repeat-${extensionCacheMissCount}`;
await cache.set('extension-hook-key', hookValue);
return {
...hookContext,
test: {
...hookContext.test,
vars: {
...hookContext.test.vars,
hookValue,
},
},
};
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (_prompt: string, context?: Record<string, any>) => ({
cached: false,
output: context?.vars?.hookValue,
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{ vars: {} }],
extensions: ['file://hook.js'],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
const firstSummary = await firstEval.toEvaluateSummary();
expect(extensionCacheMissCount).toBe(2);
expect(firstSummary.results.map((result) => result.response?.output)).toEqual([
'hook-repeat-1',
'hook-repeat-2',
]);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
const secondSummary = await secondEval.toEvaluateSummary();
expect(extensionCacheMissCount).toBe(2);
expect(secondSummary.results.map((result) => result.response?.output)).toEqual([
'hook-repeat-1',
'hook-repeat-2',
]);
});
it('isolates afterEach extension cache entries by repeat index', async () => {
await clearCache();
let extensionCacheMissCount = 0;
const afterEachHookValues: string[] = [];
vi.mocked(runExtensionHook).mockImplementation(async (_extensions, hookName, context) => {
if (hookName !== 'afterEach' || !('result' in context)) {
return context;
}
const cache = getCache();
let hookValue = await cache.get<string>('after-each-extension-key');
if (!hookValue) {
extensionCacheMissCount += 1;
hookValue = `after-hook-repeat-${extensionCacheMissCount}`;
await cache.set('after-each-extension-key', hookValue);
}
afterEachHookValues.push(hookValue);
return context;
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi.fn().mockResolvedValue({
cached: false,
output: 'provider-output',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
extensions: ['file://hook.js'],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
expect(extensionCacheMissCount).toBe(2);
expect(afterEachHookValues).toEqual(['after-hook-repeat-1', 'after-hook-repeat-2']);
afterEachHookValues.length = 0;
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
expect(extensionCacheMissCount).toBe(2);
expect(afterEachHookValues).toEqual(['after-hook-repeat-1', 'after-hook-repeat-2']);
});
it('isolates select-best comparison cache entries by repeat index', async () => {
await clearCache();
const matchers = await import('../../src/matchers/comparison');
let comparisonCacheMissCount = 0;
const comparisonRepeatIndexes: Array<number | undefined> = [];
const matchesSelectBestSpy = vi
.spyOn(matchers, 'matchesSelectBest')
.mockImplementation(async (_criteria, outputs, _grading, _vars, context) => {
comparisonRepeatIndexes.push(context?.repeatIndex);
const cache = context?.getCache?.() ?? getCache();
const cachedResult = (await cache.get('select-best-comparison-key')) as string | undefined;
if (!cachedResult) {
comparisonCacheMissCount += 1;
await cache.set('select-best-comparison-key', `repeat-${context?.repeatIndex}`);
}
return outputs.map((_output, index) => ({
pass: index === 0,
score: index === 0 ? 1 : 0,
reason: index === 0 ? 'Selected as best' : 'Not selected',
}));
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (prompt: string, context?: Record<string, any>) => ({
cached: false,
output: `${prompt}-repeat-${context?.repeatIndex}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [
{
type: 'select-best',
value: 'choose the best one',
},
],
},
],
};
try {
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
expect(comparisonCacheMissCount).toBe(2);
expect(matchesSelectBestSpy).toHaveBeenCalledTimes(2);
expect(comparisonRepeatIndexes).toEqual([0, 1]);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
expect(comparisonCacheMissCount).toBe(2);
expect(matchesSelectBestSpy).toHaveBeenCalledTimes(4);
expect(comparisonRepeatIndexes).toEqual([0, 1, 0, 1]);
} finally {
matchesSelectBestSpy.mockRestore();
}
});
it('isolates resumed select-best comparison cache entries by original repeat index', async () => {
await clearCache();
const originalResume = cliState.resume;
const { default: EvalResultModel } = await import('../../src/models/evalResult');
const getCompletedIndexPairsSpy = vi
.spyOn(EvalResultModel, 'getCompletedIndexPairs')
.mockResolvedValue(new Set(['0:0', '0:1', '1:0', '1:1']));
const matchers = await import('../../src/matchers/comparison');
let comparisonCacheMissCount = 0;
const comparisonRepeatIndexes: Array<number | undefined> = [];
const matchesSelectBestSpy = vi
.spyOn(matchers, 'matchesSelectBest')
.mockImplementation(async (_criteria, outputs, _grading, _vars, context) => {
comparisonRepeatIndexes.push(context?.repeatIndex);
const cache = context?.getCache?.() ?? getCache();
const cachedResult = (await cache.get('resume-select-best-key')) as string | undefined;
if (!cachedResult) {
comparisonCacheMissCount += 1;
await cache.set('resume-select-best-key', `repeat-${context?.repeatIndex}`);
}
return outputs.map((_output, index) => ({
pass: index === 0,
score: index === 0 ? 1 : 0,
reason: index === 0 ? 'Selected as best' : 'Not selected',
}));
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi.fn().mockResolvedValue({
cached: false,
output: 'should be skipped by resume',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [
{
type: 'select-best',
value: 'choose the best one',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const fetchResultsByTestIdxSpy = vi
.spyOn(evalRecord, 'fetchResultsByTestIdx')
.mockImplementation(
async (testIdx: number) =>
testSuite.prompts.map((prompt, promptIdx) => ({
failureReason: ResultFailureReason.NONE,
gradingResult: {
componentResults: [],
pass: true,
reason: 'Existing result',
score: 1,
},
prompt,
promptIdx,
provider: { id: provider.id() },
response: {
output: `${prompt.raw}-test-${testIdx}`,
tokenUsage: createEmptyTokenUsage(),
},
save: vi.fn().mockResolvedValue(undefined),
score: 1,
success: true,
testCase: {
...testSuite.tests![0],
vars: {},
},
testIdx,
})) as any,
);
evalRecord.persisted = true;
cliState.resume = true;
try {
await evaluate(testSuite, evalRecord, { maxConcurrency: 1, repeat: 2 });
expect(getCompletedIndexPairsSpy).toHaveBeenCalledWith(evalRecord.id, {
excludeErrors: cliState.retryMode,
});
expect(fetchResultsByTestIdxSpy).toHaveBeenCalledTimes(2);
expect(provider.callApi).not.toHaveBeenCalled();
expect(comparisonCacheMissCount).toBe(2);
expect(matchesSelectBestSpy).toHaveBeenCalledTimes(2);
expect(comparisonRepeatIndexes).toEqual([0, 1]);
} finally {
cliState.resume = originalResume;
getCompletedIndexPairsSpy.mockRestore();
matchesSelectBestSpy.mockRestore();
fetchResultsByTestIdxSpy.mockRestore();
}
});
});
+499
View File
@@ -0,0 +1,499 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import { mockApiProvider, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator prompt and provider routing', () => {
it('evaluate with providerPromptMap', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt 1'), toPrompt('Test prompt 2')],
providerPromptMap: {
'test-provider': ['Test prompt 1'],
},
tests: [
{
vars: { var1: 'value1', var2: 'value2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.stats.tokenUsage).toEqual({
total: 10,
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
expect(summary.results[0].prompt.raw).toBe('Test prompt 1');
expect(summary.results[0].prompt.label).toBe('Test prompt 1');
expect(summary.results[0].response?.output).toBe('Test output');
});
it('evaluate with allowed prompts filtering', async () => {
const mockApiProvider: 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 testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [
{ raw: 'Test prompt 1', label: 'prompt1' },
{ raw: 'Test prompt 2', label: 'prompt2' },
{ raw: 'Test prompt 3', label: 'group1:prompt3' },
],
providerPromptMap: {
'test-provider': ['prompt1', 'group1'],
},
tests: [
{
vars: { var1: 'value1', var2: 'value2' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
expect(summary).toMatchObject({
stats: {
successes: 2,
failures: 0,
},
results: [{ prompt: { label: 'prompt1' } }, { prompt: { label: 'group1:prompt3' } }],
});
});
it('evaluate with labeled and unlabeled providers and providerPromptMap', async () => {
const mockLabeledProvider: ApiProvider = {
id: () => 'labeled-provider-id',
label: 'Labeled Provider',
callApi: vi.fn().mockResolvedValue({
output: 'Labeled Provider Output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const mockUnlabeledProvider: ApiProvider = {
id: () => 'unlabeled-provider-id',
callApi: vi.fn().mockResolvedValue({
output: 'Unlabeled Provider Output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockLabeledProvider, mockUnlabeledProvider],
prompts: [
{
raw: 'Prompt 1',
label: 'prompt1',
},
{
raw: 'Prompt 2',
label: 'prompt2',
},
],
providerPromptMap: {
'Labeled Provider': ['prompt1'],
'unlabeled-provider-id': ['prompt2'],
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toMatchObject({
stats: expect.objectContaining({
successes: 2,
failures: 0,
}),
results: [
expect.objectContaining({
provider: expect.objectContaining({
id: 'labeled-provider-id',
label: 'Labeled Provider',
}),
response: expect.objectContaining({
output: 'Labeled Provider Output',
}),
}),
expect.objectContaining({
provider: expect.objectContaining({
id: 'unlabeled-provider-id',
label: undefined,
}),
response: expect.objectContaining({
output: 'Unlabeled Provider Output',
}),
}),
],
});
expect(evalRecord.prompts).toEqual(
expect.arrayContaining([
expect.objectContaining({
provider: 'Labeled Provider',
}),
expect.objectContaining({
provider: 'unlabeled-provider-id',
}),
]),
);
expect(mockLabeledProvider.callApi).toHaveBeenCalledTimes(1);
expect(mockUnlabeledProvider.callApi).toHaveBeenCalledTimes(1);
});
it('evaluate with test-level providers filter', async () => {
const mockProvider1: ApiProvider = {
id: () => 'provider-1',
label: 'fast-model',
callApi: vi.fn().mockResolvedValue({
output: 'Fast Output',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const mockProvider2: ApiProvider = {
id: () => 'provider-2',
label: 'smart-model',
callApi: vi.fn().mockResolvedValue({
output: 'Smart Output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockProvider1, mockProvider2],
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
tests: [
{
description: 'fast test',
vars: { input: 'simple' },
providers: ['fast-model'], // Only run on fast-model
},
{
description: 'smart test',
vars: { input: 'complex' },
providers: ['smart-model'], // Only run on smart-model
},
{
description: 'all providers test',
vars: { input: 'general' },
// No providers filter - runs on both
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// 1 test on fast-model + 1 test on smart-model + 2 tests (1 on each provider) = 4 total
expect(summary.stats.successes).toBe(4);
expect(mockProvider1.callApi).toHaveBeenCalledTimes(2); // fast test + all providers test
expect(mockProvider2.callApi).toHaveBeenCalledTimes(2); // smart test + all providers test
});
it('evaluate with test-level providers filter using wildcard', async () => {
const openaiProvider: ApiProvider = {
id: () => 'openai:gpt-4',
callApi: vi.fn().mockResolvedValue({
output: 'OpenAI Output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const anthropicProvider: ApiProvider = {
id: () => 'anthropic:claude-3',
callApi: vi.fn().mockResolvedValue({
output: 'Anthropic Output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [openaiProvider, anthropicProvider],
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
tests: [
{
vars: { input: 'test' },
providers: ['openai:*'], // Wildcard - only run on openai providers
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(1);
expect(openaiProvider.callApi).toHaveBeenCalledTimes(1);
expect(anthropicProvider.callApi).toHaveBeenCalledTimes(0);
});
it('evaluate inherits providers filter from defaultTest', async () => {
const provider1: ApiProvider = {
id: () => 'provider-1',
label: 'default-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Output 1',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const provider2: ApiProvider = {
id: () => 'provider-2',
label: 'other-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Output 2',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [provider1, provider2],
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
defaultTest: {
providers: ['default-provider'], // Default to only default-provider
},
tests: [
{
vars: { input: 'test1' },
// Inherits providers filter from defaultTest
},
{
vars: { input: 'test2' },
providers: ['other-provider'], // Override defaultTest
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(provider1.callApi).toHaveBeenCalledTimes(1); // test1 only
expect(provider2.callApi).toHaveBeenCalledTimes(1); // test2 only
});
it('evaluate with empty providers array blocks all providers', async () => {
const mockProvider: ApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Output',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockProvider],
prompts: [{ raw: 'Test prompt', label: 'prompt1' }],
tests: [
{
vars: { input: 'test' },
providers: [], // Empty array = block all
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(0);
expect(mockProvider.callApi).toHaveBeenCalledTimes(0);
});
it('evaluate with providers filter and providerPromptMap combined', async () => {
const provider1: ApiProvider = {
id: () => 'provider-1',
label: 'provider-one',
callApi: vi.fn().mockResolvedValue({
output: 'Output 1',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const provider2: ApiProvider = {
id: () => 'provider-2',
label: 'provider-two',
callApi: vi.fn().mockResolvedValue({
output: 'Output 2',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [provider1, provider2],
prompts: [
{ raw: 'Prompt A', label: 'prompt-a' },
{ raw: 'Prompt B', label: 'prompt-b' },
],
providerPromptMap: {
'provider-one': ['prompt-a'], // provider-one only runs prompt-a
'provider-two': ['prompt-b'], // provider-two only runs prompt-b
},
tests: [
{
vars: { input: 'test' },
providers: ['provider-one'], // Only run on provider-one
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// providers filter limits to provider-one
// providerPromptMap limits provider-one to prompt-a
// Result: 1 test case (provider-one + prompt-a)
expect(summary.stats.successes).toBe(1);
expect(provider1.callApi).toHaveBeenCalledTimes(1);
expect(provider2.callApi).toHaveBeenCalledTimes(0);
});
it('promptIdx aligns with prompts array when test-level providers filter skips providers', async () => {
// This test verifies that promptIdx correctly maps to the prompts array
// even when test-level provider filtering causes some providers to be skipped.
// Before the fix, promptIdx was a sequential counter that could misalign with
// the prompts array when filters caused gaps.
const provider1: ApiProvider = {
id: () => 'provider-1',
label: 'model-a',
callApi: vi.fn().mockResolvedValue({
output: 'Output from model-a',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const provider2: ApiProvider = {
id: () => 'provider-2',
label: 'model-b',
callApi: vi.fn().mockResolvedValue({
output: 'Output from model-b',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [provider1, provider2],
prompts: [toPrompt('Prompt X'), toPrompt('Prompt Y')],
tests: [
{
vars: { input: 'test' },
providers: ['model-b'], // Only run on provider2, skip provider1
},
],
};
// prompts array should be: [model-a+PromptX(0), model-a+PromptY(1), model-b+PromptX(2), model-b+PromptY(3)]
// With provider filter, only model-b runs, so promptIdx should be 2 and 3 (not 0 and 1)
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(provider1.callApi).toHaveBeenCalledTimes(0);
expect(provider2.callApi).toHaveBeenCalledTimes(2);
// Verify promptIdx values map to the correct provider in the prompts array
const results = summary.results.sort((a, b) => a.promptIdx - b.promptIdx);
expect(results).toHaveLength(2);
// Both results should have promptIdx >= 2 (mapping to provider2's entries)
for (const result of results) {
expect(result.promptIdx).toBeGreaterThanOrEqual(2);
expect(result.provider.id).toBe('provider-2');
}
});
it('promptIdx aligns with prompts array when test-level prompts filter skips prompts', async () => {
const provider1: ApiProvider = {
id: () => 'provider-1',
label: 'model-a',
callApi: vi.fn().mockResolvedValue({
output: 'Output from model-a',
tokenUsage: { total: 5, prompt: 2, completion: 3, cached: 0, numRequests: 1 },
}),
};
const provider2: ApiProvider = {
id: () => 'provider-2',
label: 'model-b',
callApi: vi.fn().mockResolvedValue({
output: 'Output from model-b',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [provider1, provider2],
prompts: [
{ raw: 'Prompt X', label: 'promptX' },
{ raw: 'Prompt Y', label: 'promptY' },
],
tests: [
{
vars: { input: 'test' },
prompts: ['promptY'], // Only run with Prompt Y, skip Prompt X
},
],
};
// prompts array: [model-a+promptX(0), model-a+promptY(1), model-b+promptX(2), model-b+promptY(3)]
// With prompt filter, only promptY runs, so promptIdx should be 1 and 3 (not 0 and 1)
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(2);
expect(provider1.callApi).toHaveBeenCalledTimes(1);
expect(provider2.callApi).toHaveBeenCalledTimes(1);
// Verify promptIdx values map to the correct prompt entries (odd indices for promptY)
const results = summary.results.sort((a, b) => a.promptIdx - b.promptIdx);
expect(results).toHaveLength(2);
// promptIdx should be 1 (model-a+promptY) and 3 (model-b+promptY)
expect(results[0].promptIdx).toBe(1);
expect(results[1].promptIdx).toBe(3);
});
});
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
import './setup';
import { randomUUID } from 'node:crypto';
import { expect, it, vi } from 'vitest';
import cliState from '../../src/cliState';
import { evaluate } from '../../src/evaluator';
import {
type InMemoryEvaluation,
InMemoryEvaluationStore,
} from '../../src/evaluator/inMemoryStore';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import { EvalEvaluationStore } from '../../src/node/evaluationStore';
import { ResultFailureReason, type TestSuite } from '../../src/types/index';
import { mockApiProvider, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
import type { EvaluationStore, EvaluatorRuntime } from '../../src/evaluator/runtime';
import type EvalResult from '../../src/models/evalResult';
import type { ApiProvider, EvaluateResult } from '../../src/types/index';
function createResultWriter() {
return {
write: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}
function createRuntime(resultWriters = [createResultWriter()]): EvaluatorRuntime<Eval, EvalResult> {
return {
createEvaluationStore: vi.fn((evaluation) => new EvalEvaluationStore(evaluation)),
createResultWriters: vi.fn().mockReturnValue(resultWriters),
};
}
function createEvalRecord(): Eval {
return new Eval({ outputPath: 'results.jsonl' }, { id: randomUUID(), persisted: false });
}
function createInMemoryRuntime(
store: EvaluationStore<InMemoryEvaluation, EvaluateResult>,
): EvaluatorRuntime<InMemoryEvaluation, EvaluateResult> {
return {
createEvaluationStore: vi.fn().mockReturnValue(store),
createResultWriters: vi.fn().mockReturnValue([]),
};
}
function createInMemoryEvaluation(overrides: Partial<InMemoryEvaluation> = {}): InMemoryEvaluation {
return {
id: 'in-memory-eval',
config: {},
persisted: false,
prompts: [],
results: [],
vars: [],
resultPersistenceFailed: false,
finalResults: [],
failedResults: [],
...overrides,
};
}
describeEvaluator('evaluator runtime ports', () => {
it('evaluates with an in-memory store and preserves evaluation identity', async () => {
const evaluation = createInMemoryEvaluation();
const store = new InMemoryEvaluationStore(evaluation);
const runtime = createInMemoryRuntime(store);
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
await expect(evaluate(testSuite, evaluation, {}, runtime)).resolves.toBe(evaluation);
expect(evaluation.results).toHaveLength(1);
expect(evaluation.results[0]).toMatchObject({
success: true,
testIdx: 0,
promptIdx: 0,
});
expect(evaluation.prompts).toHaveLength(1);
});
it('uses the store resume lookup without importing a concrete result model', async () => {
const evaluation = createInMemoryEvaluation({
persisted: true,
results: [
{
...({} as EvaluateResult),
failureReason: ResultFailureReason.NONE,
promptIdx: 0,
testIdx: 0,
},
],
});
const store = new InMemoryEvaluationStore(evaluation);
const readCompletedIndexPairs = vi.spyOn(store, 'readCompletedIndexPairs');
const runtime = createInMemoryRuntime(store);
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
cliState.resume = true;
cliState.retryMode = false;
await evaluate(testSuite, evaluation, {}, runtime);
expect(readCompletedIndexPairs).toHaveBeenCalledWith({ excludeErrors: false });
expect(evaluation.results).toHaveLength(1);
});
it('persists comparison updates through an explicit in-memory runtime', async () => {
const evaluation = createInMemoryEvaluation({ persisted: true });
const store = new InMemoryEvaluationStore(evaluation);
const runtime = createInMemoryRuntime(store);
const maxScoreProvider: ApiProvider = {
id: () => 'max-score-provider',
callApi: vi.fn().mockResolvedValue({ output: 'hello world' }),
};
const testSuite: TestSuite = {
providers: [maxScoreProvider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [{ type: 'contains', value: 'hello' }, { type: 'max-score' }],
},
],
};
await evaluate(testSuite, evaluation, {}, runtime);
const results = [...evaluation.results].sort((left, right) => left.promptIdx - right.promptIdx);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1]).toMatchObject({
success: false,
failureReason: ResultFailureReason.ASSERT,
});
});
it('requires an explicit runtime for non-default evaluation records', () => {
if (false) {
// @ts-expect-error Custom evaluation records must provide their own runtime.
void evaluate({} as TestSuite, createInMemoryEvaluation(), {});
}
});
it('delegates result side effects and closes writers during cleanup', async () => {
const resultWriter = createResultWriter();
const runtime = createRuntime([resultWriter]);
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
const evalRecord = createEvalRecord();
const appendResult = vi.spyOn(evalRecord, 'addResult');
await evaluate(testSuite, evalRecord, {}, runtime);
expect(runtime.createEvaluationStore).toHaveBeenCalledWith(evalRecord);
expect(runtime.createResultWriters).toHaveBeenCalledWith('results.jsonl', { append: false });
expect(appendResult).toHaveBeenCalledOnce();
expect(resultWriter.write).toHaveBeenCalledOnce();
expect(appendResult.mock.invocationCallOrder[0]).toBeLessThan(
resultWriter.write.mock.invocationCallOrder[0],
);
expect(resultWriter.close).toHaveBeenCalledOnce();
});
it('passes resume append semantics to result writers', async () => {
const runtime = createRuntime([]);
const testSuite: TestSuite = {
providers: [],
prompts: [],
tests: [],
};
cliState.resume = true;
await evaluate(testSuite, createEvalRecord(), {}, runtime);
expect(runtime.createResultWriters).toHaveBeenCalledWith('results.jsonl', { append: true });
});
it('continues streaming output when result persistence fails', async () => {
const resultWriter = createResultWriter();
const runtime = createRuntime([resultWriter]);
const evalRecord = createEvalRecord();
vi.spyOn(evalRecord, 'addResult').mockRejectedValue(new Error('database unavailable'));
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
await expect(evaluate(testSuite, evalRecord, {}, runtime)).resolves.toBeDefined();
expect(resultWriter.write).toHaveBeenCalledOnce();
expect(resultWriter.close).toHaveBeenCalledOnce();
});
it('rejects output failures after closing writers', async () => {
const resultWriter = createResultWriter();
resultWriter.write.mockRejectedValue(new Error('output unavailable'));
const runtime = createRuntime([resultWriter]);
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
await expect(evaluate(testSuite, createEvalRecord(), {}, runtime)).rejects.toThrow(
'output unavailable',
);
expect(resultWriter.close).toHaveBeenCalledOnce();
});
it('attempts every writer and recovers a close failure when results persisted', async () => {
const failingWriter = createResultWriter();
failingWriter.close.mockRejectedValue(new Error('close unavailable'));
const healthyWriter = createResultWriter();
const runtime = createRuntime([failingWriter, healthyWriter]);
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
// Results persisted to the DB, so the post-run rewrite regenerates the JSONL from it;
// a close error is logged, not fatal to an otherwise-successful run.
await expect(evaluate(testSuite, createEvalRecord(), {}, runtime)).resolves.toBeDefined();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('close unavailable'));
} finally {
warnSpy.mockRestore();
}
expect(failingWriter.close).toHaveBeenCalledOnce();
expect(healthyWriter.close).toHaveBeenCalledOnce();
});
it('surfaces a close failure when result persistence also failed', async () => {
const failingWriter = createResultWriter();
failingWriter.close.mockRejectedValue(new Error('close unavailable'));
const runtime = createRuntime([failingWriter]);
const evalRecord = createEvalRecord();
// Persistence failed, so the streamed JSONL is the only copy of the results and a
// close error (possible truncation) must not be swallowed.
vi.spyOn(evalRecord, 'addResult').mockRejectedValue(new Error('database unavailable'));
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
await expect(evaluate(testSuite, evalRecord, {}, runtime)).rejects.toThrow('close unavailable');
expect(failingWriter.close).toHaveBeenCalledOnce();
});
it('persists per-call timeout rows without streaming them', async () => {
vi.useFakeTimers();
const resultWriter = createResultWriter();
const runtime = createRuntime([resultWriter]);
const evalRecord = createEvalRecord();
const appendResult = vi.spyOn(evalRecord, 'addResult');
const slowProvider: ApiProvider = {
id: () => 'slow-provider',
callApi: vi.fn<ApiProvider['callApi']>((_prompt, _context, options) => {
return new Promise<never>((_resolve, reject) => {
const rejectAbort = () => {
const error = new Error('Operation aborted');
error.name = 'AbortError';
reject(error);
};
if (options?.abortSignal?.aborted) {
rejectAbort();
return;
}
options?.abortSignal?.addEventListener('abort', rejectAbort, { once: true });
});
}),
};
const testSuite: TestSuite = {
providers: [slowProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
const evaluation = evaluate(testSuite, evalRecord, { timeoutMs: 10 }, runtime);
await vi.advanceTimersByTimeAsync(10);
await evaluation;
expect(appendResult).toHaveBeenCalledOnce();
expect(resultWriter.write).not.toHaveBeenCalled();
expect(resultWriter.close).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
});
+398
View File
@@ -0,0 +1,398 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import { toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator scenarios and conversations', () => {
it('evaluate with scenarios', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi
.fn()
.mockResolvedValueOnce({
output: 'Hola mundo',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'Bonjour le monde',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ language }}')],
scenarios: [
{
config: [
{
vars: {
language: 'Spanish',
expectedHelloWorld: 'Hola mundo',
},
},
{
vars: {
language: 'French',
expectedHelloWorld: 'Bonjour le monde',
},
},
],
tests: [
{
assert: [
{
type: 'equals',
value: '{{expectedHelloWorld}}',
},
],
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
expect(summary.stats.successes).toBe(2);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Hola mundo');
expect(summary.results[1].response?.output).toBe('Bonjour le monde');
});
it('applies repeat from scenario config options', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Hello',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
scenarios: [
{
config: [{ options: { repeat: 3 } }],
tests: [{}],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { repeat: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(3);
expect(summary.results).toHaveLength(3);
});
it('lets scenario test options.repeat override scenario config options.repeat', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Hello',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
scenarios: [
{
config: [{ options: { repeat: 5 } }],
tests: [{ options: { repeat: 2 } }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { repeat: 1 });
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
expect(summary.results).toHaveLength(2);
});
it('evaluate with scenarios and multiple vars', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi
.fn()
.mockResolvedValueOnce({
output: 'Spanish Hola',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'Spanish Bonjour',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'French Hola',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'French Bonjour',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ language }} {{ greeting }}')],
scenarios: [
{
config: [
{
vars: {
language: ['Spanish', 'French'],
greeting: ['Hola', 'Bonjour'],
},
},
],
tests: [
{
assert: [
{
type: 'equals',
value: '{{language}} {{greeting}}',
},
],
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(4);
expect(summary.stats.successes).toBe(4);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Spanish Hola');
expect(summary.results[1].response?.output).toBe('Spanish Bonjour');
expect(summary.results[2].response?.output).toBe('French Hola');
expect(summary.results[3].response?.output).toBe('French Bonjour');
});
it('evaluate with scenarios and defaultTest', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Hello, World',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
defaultTest: {
metadata: { defaultKey: 'defaultValue' },
assert: [
{
type: 'starts-with',
value: 'Hello',
},
],
},
scenarios: [
{
config: [{ metadata: { configKey: 'configValue' } }],
tests: [{ metadata: { testKey: 'testValue' } }],
},
{
config: [
{
assert: [
{
type: 'contains',
value: ',',
},
],
},
],
tests: [
{
assert: [
{
type: 'icontains',
value: 'world',
},
],
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toMatchObject({
stats: {
successes: 2,
failures: 0,
},
results: expect.arrayContaining([
expect.objectContaining({
gradingResult: expect.objectContaining({
componentResults: expect.arrayContaining([expect.anything()]),
}),
}),
expect.objectContaining({
gradingResult: expect.objectContaining({
componentResults: expect.arrayContaining([
expect.anything(),
expect.anything(),
expect.anything(),
]),
}),
}),
]),
});
expect(summary.results[0].testCase.metadata).toEqual({
defaultKey: 'defaultValue',
configKey: 'configValue',
testKey: 'testValue',
conversationId: '__scenario_0__', // Auto-generated for scenario isolation
});
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
});
it('should maintain separate conversation histories between scenarios without explicit conversationId', async () => {
// This test verifies the fix for GitHub issue #384:
// Scenarios should have isolated _conversation state by default
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockImplementation((_prompt) => ({
output: 'Test output',
})),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [
{
raw: '{% for completion in _conversation %}Previous: {{ completion.input }} -> {{ completion.output }}\n{% endfor %}Current: {{ question }}',
label: 'Conversation test',
},
],
scenarios: [
{
// First scenario - conversation about books
config: [{}],
tests: [
{ vars: { question: 'Recommend a sci-fi book' } },
{ vars: { question: 'Tell me more about it' } },
],
},
{
// Second scenario - conversation about recipes
// Should NOT include history from first scenario
config: [{}],
tests: [
{ vars: { question: 'Suggest a pasta recipe' } },
{ vars: { question: 'How long does it take?' } },
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(4);
// First scenario, first question - no history
const firstCall = mockApiProvider.callApi.mock.calls[0][0];
expect(firstCall).toContain('Current: Recommend a sci-fi book');
expect(firstCall).not.toContain('Previous:');
// First scenario, second question - should have first scenario's history
const secondCall = mockApiProvider.callApi.mock.calls[1][0];
expect(secondCall).toContain('Previous: ');
expect(secondCall).toContain('Recommend a sci-fi book');
expect(secondCall).toContain('Current: Tell me more about it');
// Second scenario, first question - should NOT have first scenario's history
// This is the key assertion that verifies the fix for issue #384
const thirdCall = mockApiProvider.callApi.mock.calls[2][0];
expect(thirdCall).toContain('Current: Suggest a pasta recipe');
expect(thirdCall).not.toContain('Previous:');
expect(thirdCall).not.toContain('sci-fi');
expect(thirdCall).not.toContain('Recommend');
// Second scenario, second question - should only have second scenario's history
const fourthCall = mockApiProvider.callApi.mock.calls[3][0];
expect(fourthCall).toContain('Previous: ');
expect(fourthCall).toContain('Suggest a pasta recipe');
expect(fourthCall).toContain('Current: How long does it take?');
expect(fourthCall).not.toContain('sci-fi');
});
it('should allow scenarios to share conversation history with explicit conversationId', async () => {
// This test verifies that users can still explicitly share conversations across scenarios
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockImplementation((_prompt) => ({
output: 'Test output',
})),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [
{
raw: '{% for completion in _conversation %}Previous: {{ completion.input }}\n{% endfor %}Current: {{ question }}',
label: 'Conversation test',
},
],
scenarios: [
{
config: [{}],
tests: [
{
vars: { question: 'Question from scenario 1' },
metadata: { conversationId: 'shared-conversation' },
},
],
},
{
config: [{}],
tests: [
{
vars: { question: 'Question from scenario 2' },
metadata: { conversationId: 'shared-conversation' },
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
// First scenario - no history
const firstCall = mockApiProvider.callApi.mock.calls[0][0];
expect(firstCall).not.toContain('Previous:');
// Second scenario - SHOULD have first scenario's history because they share conversationId
const secondCall = mockApiProvider.callApi.mock.calls[1][0];
expect(secondCall).toContain('Previous: ');
expect(secondCall).toContain('Question from scenario 1');
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { runCompareAssertion } from '../../src/assertions/index';
import { createMockProvider, createProviderResponse } from '../factories/provider';
import type {
ApiProvider,
Assertion,
AtomicTestCase,
CallApiContextParams,
} from '../../src/types/index';
describe('select-best context propagation', () => {
it('should pass context with originalProvider to grading provider', async () => {
let capturedContext: CallApiContextParams | undefined;
// Create a grading provider that captures the context
const gradingProvider = createMockProvider({
id: 'test-grading-provider',
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation(async (_prompt, context) => {
capturedContext = context;
return {
output: '0', // Select first option
tokenUsage: {},
};
}),
});
const originalProvider = createMockProvider({
id: 'original-provider',
response: createProviderResponse({ output: '', tokenUsage: {} }),
});
const test: AtomicTestCase = {
vars: { foo: 'bar' },
options: {
provider: gradingProvider,
},
};
const assertion: Assertion = {
type: 'select-best',
value: 'Pick the best output',
};
const outputs = ['Output 1', 'Output 2'];
const contextToPass: CallApiContextParams = {
originalProvider,
prompt: { raw: 'test prompt', label: 'test' },
vars: { foo: 'bar' },
};
// Call runCompareAssertion with context
await runCompareAssertion(test, assertion, outputs, contextToPass);
// Verify the grading provider was called
expect(gradingProvider.callApi).toHaveBeenCalled();
// Verify it received the context with originalProvider
expect(capturedContext).toBeDefined();
expect(capturedContext?.originalProvider).toBeDefined();
expect(capturedContext?.originalProvider?.id()).toBe('original-provider');
});
});
+130
View File
@@ -0,0 +1,130 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import Eval from '../../src/models/eval';
import { type TestSuite } from '../../src/types/index';
import { toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator session metadata', () => {
it('should use sessionId from vars if not in response', async () => {
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
// No sessionId in response
}),
};
const mockExtension = 'file://test-extension.js';
let capturedContext: any;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'afterEach') {
capturedContext = context;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { var1: 'value1', sessionId: 'vars-session-456' },
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedContext).toBeDefined();
expect(capturedContext.result.metadata.sessionId).toBe('vars-session-456');
});
it('should prioritize response sessionId over vars sessionId', async () => {
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
sessionId: 'response-session-priority',
}),
};
const mockExtension = 'file://test-extension.js';
let capturedContext: any;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'afterEach') {
capturedContext = context;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { var1: 'value1', sessionId: 'vars-session-ignored' },
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedContext).toBeDefined();
expect(capturedContext.result.metadata.sessionId).toBe('response-session-priority');
expect(capturedContext.result.metadata.sessionId).not.toBe('vars-session-ignored');
});
it('should handle empty sessionIds array', async () => {
const mockApiProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
}),
};
const mockExtension = 'file://test-extension.js';
let capturedContext: any;
const mockedRunExtensionHook = vi.mocked(runExtensionHook);
mockedRunExtensionHook.mockImplementation(async (_extensions, hookName, context) => {
if (hookName === 'afterEach') {
capturedContext = context;
}
return context;
});
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { var1: 'value1' },
metadata: {
sessionIds: [],
},
},
],
extensions: [mockExtension],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(capturedContext).toBeDefined();
expect(capturedContext.result.metadata.sessionIds).toEqual([]);
});
});
+238
View File
@@ -0,0 +1,238 @@
import { vi } from 'vitest';
const exactTransformHandlers = new Map<string, (input: any) => any>([
['output + " postprocessed"', (input) => input + ' postprocessed'],
['JSON.parse(output).value', parseJsonValueTransform],
['`Transformed: ${output}`', (input) => `Transformed: ${input}`],
['`ProviderTransformed: ${output}`', (input) => `ProviderTransformed: ${input}`],
['`Provider: ${output}`', (input) => `Provider: ${input}`],
['`Test: ${output}`', (input) => `Test: ${input}`],
['"testTransformed " + output', (input) => 'testTransformed ' + input],
['"defaultTestTransformed " + output', (input) => 'defaultTestTransformed ' + input],
['"Test: " + output', (input) => 'Test: ' + input],
['"Provider: " + output', (input) => 'Provider: ' + input],
['"Transform: " + output', (input) => 'Transform: ' + input],
['output + "-provider-test"', (input) => input + '-provider-test'],
['output + "-provider"', (input) => input + '-provider'],
['output + "-test"', (input) => input + '-test'],
['`Transform: ${output}`', (input) => `Transform: ${input}`],
['`Postprocess: ${output}`', (input) => `Postprocess: ${input}`],
]);
function parseJsonValueTransform(input: any) {
try {
return JSON.parse(input).value;
} catch {
return input;
}
}
function mockTransformVars(code: string, input: any, context?: any) {
if (code.includes('vars.transformed = true')) {
return { ...input, transformed: true };
}
if (code.includes('vars.defaultTransform = true')) {
return { ...input, defaultTransform: true };
}
if (code.includes('{ ...vars') && code.includes('toUpperCase()')) {
return { ...input, name: input.name?.toUpperCase() };
}
if (code.includes('{ ...vars') && code.includes('vars.age + 5')) {
return { ...input, age: (input.age ?? 0) + 5 };
}
if (code.includes('return {') && code.includes('context.uuid')) {
return {
...input,
id: context?.uuid || 'mock-uuid',
hasPrompt: Boolean(context?.prompt),
};
}
if (code.includes('test2UpperCase: vars.test2.toUpperCase()')) {
return { ...input, test2UpperCase: input.test2?.toUpperCase() };
}
}
function mockMetadataTransform(code: string, input: any, context?: any) {
if (!code.includes('context?.metadata')) {
return undefined;
}
if (code.includes('Output:') && context?.metadata) {
return `Output: ${input}, Metadata: ${JSON.stringify(context.metadata)}`;
}
if (code.includes('Has metadata') && context?.metadata) {
return `Has metadata: ${input}`;
}
if (code.includes('No metadata') && !context?.metadata) {
return `No metadata: ${input}`;
}
if (
code.includes('Empty metadata') &&
context?.metadata &&
Object.keys(context.metadata).length === 0
) {
return `Empty metadata: ${input}`;
}
if (code.includes('All context') && context?.vars && context?.prompt && context?.metadata) {
return `All context: ${input}`;
}
if (
code.includes('Missing context') &&
!(context?.vars && context?.prompt && context?.metadata)
) {
return `Missing context: ${input}`;
}
}
async function mockTransform(code: unknown, input: any, context?: any) {
if (typeof code !== 'string') {
return input;
}
const exactHandler = exactTransformHandlers.get(code);
if (exactHandler) {
return exactHandler(input);
}
return (
mockTransformVars(code, input, context) ?? mockMetadataTransform(code, input, context) ?? input
);
}
vi.mock('../../src/util/transform', () => ({
TransformInputType: {
OUTPUT: 'output',
VARS: 'vars',
},
// Provide a process shim for ESM compatibility in inline JavaScript code
getProcessShim: vi.fn().mockReturnValue(process),
transform: vi.fn().mockImplementation(mockTransform),
}));
vi.mock('../../src/util/fileReference', async () => {
const actual = await vi.importActual<typeof import('../../src/util/fileReference')>(
'../../src/util/fileReference',
);
return {
...actual,
processConfigFileReferences: vi.fn().mockImplementation(async (config) => {
if (
typeof config === 'object' &&
config !== null &&
config.tests &&
Array.isArray(config.tests)
) {
const result = {
...config,
tests: config.tests.map((test: any) => {
return {
...test,
vars:
test.vars.var1 === 'file://test/fixtures/test_file.txt'
? {
var1: '<h1>Sample Report</h1><p>This is a test report with some data for the year 2023.</p>',
}
: test.vars,
};
}),
};
return result;
}
return config;
}),
};
});
vi.mock('proxy-agent', () => ({
ProxyAgent: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('glob', () => {
const globSync = vi.fn().mockImplementation((pattern) => {
if (pattern.includes('test/fixtures/test_file.txt')) {
return [pattern];
}
return [];
});
const hasMagic = vi.fn((pattern: string | string[]) => {
const p = Array.isArray(pattern) ? pattern.join('') : pattern;
return p.includes('*') || p.includes('?') || p.includes('[') || p.includes('{');
});
const glob = Object.assign(vi.fn(), { globSync, hasMagic, sync: globSync });
return {
default: { globSync, hasMagic },
glob,
globSync,
hasMagic,
};
});
vi.mock('../../src/esm', async (importOriginal) => {
return {
...(await importOriginal()),
importModule: vi.fn(),
};
});
vi.mock('../../src/evaluatorHelpers', async () => {
const actual = await vi.importActual<typeof import('../../src/evaluatorHelpers')>(
'../../src/evaluatorHelpers',
);
return {
...actual,
runExtensionHook: vi.fn().mockImplementation((_extensions, _hookName, context) => context),
};
});
vi.mock('../../src/cliState', () => ({
__esModule: true,
default: {
resume: false,
basePath: '',
webUI: false,
},
}));
vi.mock('../../src/models/prompt', () => ({
generateIdFromPrompt: vi.fn((prompt) => `prompt-${prompt.label || 'default'}`),
}));
vi.mock('../../src/util/time', async () => {
const actual = await vi.importActual<typeof import('../../src/util/time')>('../../src/util/time');
return {
...actual,
sleep: vi.fn(),
};
});
vi.mock('../../src/util/fileExtensions', async () => {
const actual = await vi.importActual<typeof import('../../src/util/fileExtensions')>(
'../../src/util/fileExtensions',
);
return {
...actual,
isImageFile: vi
.fn()
.mockImplementation((filePath) => filePath.endsWith('.jpg') || filePath.endsWith('.png')),
isVideoFile: vi.fn().mockImplementation((filePath) => filePath.endsWith('.mp4')),
isAudioFile: vi.fn().mockImplementation((filePath) => filePath.endsWith('.mp3')),
isJavascriptFile: vi.fn().mockReturnValue(false),
};
});
vi.mock('../../src/util/functions/loadFunction', async () => {
const actual = await vi.importActual<typeof import('../../src/util/functions/loadFunction')>(
'../../src/util/functions/loadFunction',
);
return {
...actual,
loadFunction: vi.fn().mockImplementation((options) => {
if (options.filePath.includes('scoring')) {
return Promise.resolve((_metrics: Record<string, number>) => ({
pass: true,
score: 0.75,
reason: 'Custom scoring reason',
}));
}
return Promise.resolve(() => {});
}),
};
});
+162
View File
@@ -0,0 +1,162 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate, runEval } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import { mockApiProvider, mockGradingApiProviderPasses, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator token usage', () => {
it('should accumulate token usage correctly', async () => {
const mockOptions = {
delay: 0,
testIdx: 0,
promptIdx: 0,
repeatIndex: 0,
isRedteam: false,
};
const results = await runEval({
...mockOptions,
provider: mockApiProvider,
prompt: { raw: 'Test prompt', label: 'test-label' },
test: {
assert: [
{
type: 'llm-rubric',
value: 'Test output',
},
],
options: { provider: mockGradingApiProviderPasses },
},
conversations: {},
registers: {},
});
expect(results[0].tokenUsage).toEqual({
total: 10, // Only provider tokens, NOT assertion tokens
prompt: 5, // Only provider tokens
completion: 5, // Only provider tokens
cached: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
numRequests: 1, // Only provider requests
assertions: {
total: 10, // Assertion tokens tracked separately
prompt: 5,
completion: 5,
cached: 0,
numRequests: 1, // Assertion requests tracked separately
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
});
it('should NOT include assertion tokens in main token totals', async () => {
// Mock provider that returns fixed token usage
const providerWithTokens: ApiProvider = {
id: vi.fn().mockReturnValue('provider-with-tokens'),
callApi: vi.fn().mockResolvedValue({
output: 'Test response',
tokenUsage: {
total: 100,
prompt: 60,
completion: 40,
cached: 10,
numRequests: 1,
},
}),
};
// Mock grading provider that also returns token usage
const gradingProviderWithTokens: ApiProvider = {
id: vi.fn().mockReturnValue('grading-provider'),
callApi: vi.fn().mockResolvedValue({
output: JSON.stringify({
pass: true,
score: 1,
reason: 'Test passed',
}),
tokenUsage: {
total: 50,
prompt: 30,
completion: 20,
cached: 5,
numRequests: 1,
},
}),
};
const testSuite: TestSuite = {
providers: [providerWithTokens],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'llm-rubric',
value: 'Output should be valid',
provider: gradingProviderWithTokens,
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
// Verify main totals only include provider tokens, NOT assertion tokens
expect(summary.stats.tokenUsage).toEqual({
total: 100, // Only provider tokens
prompt: 60,
completion: 40,
cached: 10,
numRequests: 1,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
assertions: {
total: 50, // Assertion tokens tracked separately
prompt: 30,
completion: 20,
cached: 5,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
},
});
// Also verify at the result level - the result should pass
const result = summary.results[0];
expect(result).toHaveProperty('success', true);
expect(result).toHaveProperty('score', 1);
// The main verification is at the stats level (already done above)
// Individual results may not always have tokenUsage populated in the summary
});
});
+371
View File
@@ -0,0 +1,371 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import { nodeEvaluatorRuntime } from '../../src/node/evaluatorRuntime';
import * as evaluatorTracing from '../../src/tracing/evaluatorTracing';
import { getTraceStore } from '../../src/tracing/store';
import { createMockProvider } from '../factories/provider';
import type { EvaluatorRuntime } from '../../src/evaluator/runtime';
import type Eval from '../../src/models/eval';
import type EvalResult from '../../src/models/evalResult';
import type { EvaluateOptions, TestSuite } from '../../src/types/index';
// Mock dependencies
vi.mock('../../src/tracing/store');
const mockFlushOtel = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockInitializeOtel = vi.hoisted(() => vi.fn());
const mockShutdownOtel = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('../../src/tracing/otelSdk', () => ({
flushOtel: mockFlushOtel,
initializeOtel: mockInitializeOtel,
shutdownOtel: mockShutdownOtel,
}));
vi.mock('../../src/tracing/otlpReceiver', () => ({
startOTLPReceiver: vi.fn(),
stopOTLPReceiver: vi.fn(),
}));
// Mock evaluatorTracing module
vi.mock('../../src/tracing/evaluatorTracing', () => ({
generateTraceId: vi.fn(() => 'abcdef1234567890abcdef1234567890'),
generateSpanId: vi.fn(() => '0123456789abcdef'),
generateTraceparent: vi.fn((traceId, spanId) => `00-${traceId}-${spanId}-01`),
generateTraceContextIfNeeded: vi.fn(),
startOtlpReceiverIfNeeded: vi.fn(),
stopOtlpReceiverIfNeeded: vi.fn(),
isOtlpReceiverStarted: vi.fn(() => false),
isTracingEnabled: vi.fn((test) => test.metadata?.tracingEnabled === true),
}));
describe('evaluator trace integration', () => {
const mockTraceStore = {
createTrace: vi.fn(),
getTrace: vi.fn(),
};
const mockEval = {
id: 'test-eval-id',
addResult: vi.fn(),
addPrompts: vi.fn(),
fetchResultsByTestIdx: vi.fn(),
setVars: vi.fn(),
setDurationMs: vi.fn(),
results: [],
prompts: [],
persisted: false,
config: {
outputPath: undefined,
},
} as unknown as Eval;
beforeEach(() => {
vi.resetAllMocks();
(getTraceStore as Mock).mockReturnValue(mockTraceStore);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should pass traceId through to assertions when tracing is enabled', async () => {
// Mock trace creation and retrieval
const testTraceId = 'abcdef1234567890abcdef1234567890';
mockTraceStore.createTrace.mockResolvedValue(undefined);
mockTraceStore.getTrace.mockResolvedValue({
traceId: testTraceId,
spans: [
{
spanId: 'test-span',
name: 'test.operation',
startTime: 1000,
endTime: 2000,
},
],
});
// Mock generateTraceContextIfNeeded
vi.mocked(evaluatorTracing.generateTraceContextIfNeeded).mockResolvedValue({
traceparent: `00-${testTraceId}-0123456789abcdef-01`,
evaluationId: 'test-eval-id',
testCaseId: 'test-case-id',
});
const testSuite: TestSuite = {
providers: [
createMockProvider({
id: 'mock-provider',
response: { output: 'Test response', tokenUsage: {} },
}),
],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [
{
vars: { input: 'test' },
metadata: {
tracingEnabled: true,
evaluationId: 'test-eval-id',
},
assert: [
{
type: 'javascript',
value: `
// Verify trace data is available
if (!context.trace) return false;
return context.trace.spans.length > 0 &&
context.trace.spans[0].name === 'test.operation';
`,
},
],
},
],
tracing: {
enabled: true,
otlp: {
http: {
enabled: true,
port: 4318,
host: '0.0.0.0',
acceptFormats: ['protobuf'],
},
},
},
};
const options: EvaluateOptions = {
maxConcurrency: 1,
};
// Run evaluation
await evaluate(testSuite, mockEval, options);
// Verify trace context was generated
expect(evaluatorTracing.generateTraceContextIfNeeded).toHaveBeenCalled();
expect(evaluatorTracing.startOtlpReceiverIfNeeded).toHaveBeenCalledWith(
testSuite,
'test-eval-id',
);
// Verify trace was fetched for assertion
expect(mockTraceStore.getTrace).toHaveBeenCalledWith(testTraceId, {
sanitizeAttributes: false,
});
expect(mockFlushOtel).toHaveBeenCalled();
expect(mockShutdownOtel).toHaveBeenCalledOnce();
// Verify result was added with passing assertion
expect(mockEval.addResult).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
score: 1,
}),
);
});
it('closes writers but skips OTEL shutdown when initialization fails', async () => {
const writer = {
write: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
const runtime: EvaluatorRuntime<Eval, EvalResult> = {
createEvaluationStore: nodeEvaluatorRuntime.createEvaluationStore,
createResultWriters: vi.fn().mockReturnValue([writer]),
};
mockInitializeOtel.mockImplementationOnce(() => {
throw new Error('otel unavailable');
});
await expect(
evaluate(
{
providers: [],
prompts: [],
tests: [],
tracing: { enabled: true },
},
mockEval,
{},
runtime,
),
).rejects.toThrow('otel unavailable');
expect(writer.close).toHaveBeenCalledOnce();
expect(mockFlushOtel).not.toHaveBeenCalled();
expect(mockShutdownOtel).not.toHaveBeenCalled();
});
it('flushes and shuts down OTEL after successful tracing initialization', async () => {
await evaluate(
{
providers: [
createMockProvider({
id: 'mock-provider',
response: { output: 'Test response', tokenUsage: {} },
}),
],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [{}],
tracing: { enabled: true },
},
mockEval,
{},
);
expect(mockInitializeOtel).toHaveBeenCalledOnce();
expect(mockFlushOtel).toHaveBeenCalledOnce();
expect(mockShutdownOtel).toHaveBeenCalledOnce();
});
it('should handle assertions gracefully when tracing is disabled', async () => {
// Mock generateTraceContextIfNeeded to return null when tracing is disabled
vi.mocked(evaluatorTracing.generateTraceContextIfNeeded).mockResolvedValue(null);
const testSuite: TestSuite = {
providers: [
createMockProvider({
id: 'mock-provider',
response: { output: 'Test response', tokenUsage: {} },
}),
],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [
{
vars: { input: 'test' },
// No tracingEnabled in metadata
assert: [
{
type: 'javascript',
value: `
// Should pass when trace is undefined
return context.trace === undefined && output === 'Test response';
`,
},
],
},
],
// Tracing not enabled in test suite
};
const options: EvaluateOptions = {
maxConcurrency: 1,
};
// Run evaluation
await evaluate(testSuite, mockEval, options);
// Verify trace was NOT created or fetched
expect(mockTraceStore.createTrace).not.toHaveBeenCalled();
expect(mockTraceStore.getTrace).not.toHaveBeenCalled();
expect(mockFlushOtel).not.toHaveBeenCalled();
expect(mockShutdownOtel).not.toHaveBeenCalled();
// Verify result was added with passing assertion
expect(mockEval.addResult).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
score: 1,
}),
);
});
it('should extract traceId correctly from traceparent header', async () => {
const testTraceId = '0af7651916cd43dd8448eb211c80319c';
const testSpanId = 'b7ad6b7169203331';
// Mock the trace context generation
vi.mocked(evaluatorTracing.generateTraceContextIfNeeded).mockResolvedValue({
traceparent: `00-${testTraceId}-${testSpanId}-01`,
evaluationId: 'test-eval-id',
testCaseId: 'test-case-id',
});
mockTraceStore.createTrace.mockResolvedValue(undefined);
mockTraceStore.getTrace.mockResolvedValue({
traceId: testTraceId,
spans: [
{
spanId: 'test-span',
name: 'extracted.correctly',
startTime: 1000,
endTime: 2000,
},
],
});
const testSuite: TestSuite = {
providers: [
createMockProvider({
id: 'mock-provider',
response: { output: 'Test response', tokenUsage: {} },
}),
],
prompts: [{ raw: 'Test prompt', label: 'test' }],
tests: [
{
vars: { input: 'test' },
metadata: {
tracingEnabled: true,
evaluationId: 'test-eval-id',
},
assert: [
{
type: 'javascript',
value: `
// Verify the extracted traceId matches
return context.trace && context.trace.traceId === '${testTraceId}';
`,
},
],
},
],
};
const options: EvaluateOptions = {
maxConcurrency: 1,
};
// Run evaluation
await evaluate(testSuite, mockEval, options);
// Verify trace was fetched with the correct traceId
expect(mockTraceStore.getTrace).toHaveBeenCalledWith(testTraceId, {
sanitizeAttributes: false,
});
// Verify result was added with passing assertion
expect(mockEval.addResult).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
}),
);
});
it('should still run evaluator cleanup when receiver startup is fatal', async () => {
vi.mocked(evaluatorTracing.startOtlpReceiverIfNeeded).mockRejectedValueOnce(
new Error('receiver failed'),
);
const testSuite: TestSuite = {
providers: [],
prompts: [],
tests: [],
tracing: {
enabled: true,
failOnReceiverStartFailure: true,
otlp: {
http: {
enabled: true,
port: 4318,
host: '127.0.0.1',
},
},
},
};
await expect(evaluate(testSuite, mockEval, {})).rejects.toThrow('receiver failed');
expect(evaluatorTracing.stopOtlpReceiverIfNeeded).toHaveBeenCalled();
expect(mockFlushOtel).not.toHaveBeenCalled();
expect(mockShutdownOtel).not.toHaveBeenCalled();
});
});
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTraceId, getTraceLinkage } from '../../src/evaluator';
import logger from '../../src/logger';
const TRACE_ID = 'a'.repeat(32);
const SPAN_ID = 'b'.repeat(16);
// Well-formed W3C v00 traceparent (4 parts) and a forward-compatible 5+ part variant.
const TP_V00 = `00-${TRACE_ID}-${SPAN_ID}-01`;
const TP_FUTURE = `01-${TRACE_ID}-${SPAN_ID}-01-extra-field`;
describe('getTraceId', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('returns undefined when no trace context or traceparent is present', () => {
expect(getTraceId(undefined)).toBeUndefined();
expect(getTraceId(null)).toBeUndefined();
expect(getTraceId({})).toBeUndefined();
expect(getTraceId({ evaluationId: 'eval-x' })).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();
});
it('extracts the trace id from a well-formed v00 traceparent (4 parts)', () => {
expect(getTraceId({ traceparent: TP_V00 })).toBe(TRACE_ID);
expect(warnSpy).not.toHaveBeenCalled();
});
it('accepts forward-compatible traceparents with more than 4 parts', () => {
// W3C allows future versions to append fields after flags; still read trace-id at index 1.
expect(getTraceId({ traceparent: TP_FUTURE })).toBe(TRACE_ID);
expect(warnSpy).not.toHaveBeenCalled();
});
it.each([
['too few fields', `00-${TRACE_ID}-01`],
['unparseable value', 'garbage'],
['extra fields on version 00', `${TP_V00}-extra-field`],
['forbidden ff version', `ff-${TRACE_ID}-${SPAN_ID}-01`],
['empty trace id', `00--${SPAN_ID}-01`],
['all-zero trace id', `00-${'0'.repeat(32)}-${SPAN_ID}-01`],
['uppercase trace id', `00-${TRACE_ID.toUpperCase()}-${SPAN_ID}-01`],
['all-zero parent id', `00-${TRACE_ID}-${'0'.repeat(16)}-01`],
['invalid flags', `00-${TRACE_ID}-${SPAN_ID}-0g`],
])('drops linkage and warns for %s', (_description, traceparent) => {
expect(getTraceId({ traceparent })).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('Malformed traceparent');
});
});
describe('getTraceLinkage', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('returns an empty object when there is no trace context', () => {
expect(getTraceLinkage(undefined)).toEqual({});
expect(getTraceLinkage(null, 'eval-fallback')).toEqual({});
});
it('pairs the trace id and evaluation id from the context', () => {
const linkage = getTraceLinkage({ traceparent: TP_V00, evaluationId: 'eval-1' });
expect(linkage).toEqual({ traceId: TRACE_ID, evaluationId: 'eval-1' });
});
it('falls back to the eval id when the context lacks an evaluation id', () => {
const linkage = getTraceLinkage({ traceparent: TP_V00 }, 'eval-fallback');
expect(linkage).toEqual({ traceId: TRACE_ID, evaluationId: 'eval-fallback' });
});
it('emits evaluationId without traceId when the traceparent is malformed', () => {
// A trace context exists (the row was traced) but the trace id could not be parsed.
const linkage = getTraceLinkage({ traceparent: 'malformed', evaluationId: 'eval-2' });
expect(linkage).toEqual({ evaluationId: 'eval-2' });
});
it('omits both fields when a traced context has neither a parseable id nor an eval id', () => {
expect(getTraceLinkage({ traceparent: 'malformed' })).toEqual({});
});
});
+527
View File
@@ -0,0 +1,527 @@
import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { evaluate } from '../../src/evaluator';
import Eval from '../../src/models/eval';
import { type ApiProvider, type TestSuite } from '../../src/types/index';
import { mockApiProvider, toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator transforms', () => {
it('evaluate with transform option - default test', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
defaultTest: {
options: {
transform: 'output + " postprocessed"',
},
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Test output postprocessed');
});
it('evaluate with transform option - single test', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Test output postprocessed',
},
],
options: {
transform: 'output + " postprocessed"',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Test output postprocessed');
});
it('evaluate with transform option - json provider', async () => {
const mockApiJsonProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-json'),
callApi: vi.fn().mockResolvedValue({
output: '{"output": "testing", "value": 123}',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiJsonProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: '123',
},
],
options: {
transform: `JSON.parse(output).value`,
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiJsonProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe(123);
});
it('evaluate with provider transform', async () => {
const mockApiProviderWithTransform: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-transform'),
callApi: vi.fn().mockResolvedValue({
output: 'Original output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
transform: '`Transformed: ${output}`',
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithTransform],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Transformed: Original output',
},
],
options: {}, // No test transform, relying on provider's transform
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProviderWithTransform.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Transformed: Original output');
});
it('evaluate with vars transform', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Hello {{ name }}, your age is {{ age }}')],
tests: [
{
vars: { name: 'Alice', age: 30 },
},
{
vars: { name: 'Bob', age: 25 },
options: {
transformVars: '{ ...vars, age: vars.age + 5 }',
},
},
],
defaultTest: {
options: {
transformVars: '{ ...vars, name: vars.name.toUpperCase() }',
},
},
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toEqual(
expect.objectContaining({
stats: expect.objectContaining({
successes: 2,
failures: 0,
}),
results: expect.arrayContaining([
expect.objectContaining({
prompt: expect.objectContaining({
raw: 'Hello ALICE, your age is 30',
label: 'Hello {{ name }}, your age is {{ age }}',
}),
response: expect.objectContaining({
output: 'Test output',
}),
}),
expect.objectContaining({
// NOTE: test overrides defaultTest transform. Bob not BOB
prompt: expect.objectContaining({
raw: 'Hello Bob, your age is 30',
}),
response: expect.objectContaining({
output: 'Test output',
}),
vars: {
name: 'Bob',
age: 30,
},
}),
]),
}),
);
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
});
it('evaluate with context in vars transform in defaultTest', async () => {
const mockApiProvider: 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 testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Hello {{ name }}, your age is {{ age }}')],
defaultTest: {
options: {
transformVars: `return {
...vars,
// Test that context.uuid is available
id: context.uuid,
// Test that context.prompt is available but empty
hasPrompt: Boolean(context.prompt)
}`,
},
},
tests: [
{
vars: {
name: 'Alice',
age: 25,
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toEqual(
expect.objectContaining({
stats: expect.objectContaining({
successes: 1,
failures: 0,
}),
results: expect.arrayContaining([
expect.objectContaining({
vars: expect.objectContaining({
id: expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
),
hasPrompt: true,
}),
}),
]),
}),
);
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
});
it('evaluate with provider transform and test transform', async () => {
const mockApiProviderWithTransform: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-transform'),
callApi: vi.fn().mockResolvedValue({
output: 'Original output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
transform: '`ProviderTransformed: ${output}`',
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithTransform],
prompts: [toPrompt('Test prompt')],
defaultTest: {
options: {
// overridden by the test transform
transform: '"defaultTestTransformed " + output',
},
},
tests: [
{
assert: [
{
type: 'equals',
// Order of transforms: 1. Provider transform 2. Test transform (or defaultTest transform, if test transform unset)
value: 'testTransformed ProviderTransformed: Original output',
},
],
// This transform overrides the defaultTest transform
options: { transform: '"testTransformed " + output' },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toEqual(
expect.objectContaining({
stats: expect.objectContaining({
successes: 1,
failures: 0,
}),
results: expect.arrayContaining([
expect.objectContaining({
response: expect.objectContaining({
output: 'testTransformed ProviderTransformed: Original output',
}),
}),
]),
}),
);
expect(mockApiProviderWithTransform.callApi).toHaveBeenCalledTimes(1);
});
it('evaluate with multiple transforms', async () => {
const mockApiProviderWithTransform: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-transform'),
callApi: vi.fn().mockResolvedValue({
output: 'Original output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
transform: '`Provider: ${output}`',
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithTransform],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Test: Provider: Original output',
},
],
options: {
transform: '`Test: ${output}`',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProviderWithTransform.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].response?.output).toBe('Test: Provider: Original output');
});
it('evaluate with provider transform and test postprocess (deprecated)', async () => {
const mockApiProviderWithTransform: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-transform'),
callApi: vi.fn().mockResolvedValue({
output: 'Original output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
transform: '`Provider: ${output}`',
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithTransform],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Postprocess: Provider: Original output',
},
],
options: {
postprocess: '`Postprocess: ${output}`',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toMatchObject({
stats: expect.objectContaining({
successes: 1,
failures: 0,
}),
});
expect(summary.results[0].response?.output).toBe('Postprocess: Provider: Original output');
expect(mockApiProviderWithTransform.callApi).toHaveBeenCalledTimes(1);
});
it('evaluate with provider transform, test transform, and test postprocess (deprecated)', async () => {
const mockApiProviderWithTransform: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-transform'),
callApi: vi.fn().mockResolvedValue({
output: 'Original output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
transform: '`Provider: ${output}`',
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithTransform],
prompts: [toPrompt('Test prompt')],
tests: [
{
assert: [
{
type: 'equals',
value: 'Transform: Provider: Original output',
},
],
options: {
transform: '`Transform: ${output}`',
postprocess: '`Postprocess: ${output}`', // This should be ignored
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toMatchObject({
stats: expect.objectContaining({
successes: 1,
failures: 0,
}),
results: expect.arrayContaining([
expect.objectContaining({
response: expect.objectContaining({
output: 'Transform: Provider: Original output',
}),
}),
]),
});
expect(mockApiProviderWithTransform.callApi).toHaveBeenCalledTimes(1);
});
it('evaluate with no output', async () => {
const mockApiProviderNoOutput: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-no-output'),
callApi: vi.fn().mockResolvedValue({
output: null,
tokenUsage: { total: 5, prompt: 5, completion: 0, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderNoOutput],
prompts: [toPrompt('Test prompt')],
tests: [],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(0);
expect(summary.stats.failures).toBe(1);
expect(summary.results[0].error).toBe('No output');
expect(summary.results[0].success).toBe(false);
expect(summary.results[0].score).toBe(0);
expect(mockApiProviderNoOutput.callApi).toHaveBeenCalledTimes(1);
});
it('evaluate with false output', async () => {
const mockApiProviderNoOutput: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-no-output'),
callApi: vi.fn().mockResolvedValue({
output: false,
tokenUsage: { total: 5, prompt: 5, completion: 0, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderNoOutput],
prompts: [toPrompt('Test prompt')],
tests: [],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
expect(summary.results[0].success).toBe(true);
expect(summary.results[0].score).toBe(1);
expect(mockApiProviderNoOutput.callApi).toHaveBeenCalledTimes(1);
});
it('merges defaultTest.vars before applying transformVars', async () => {
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt {{ test1 }} {{ test2 }} {{ test2UpperCase }}')],
defaultTest: {
vars: {
test2: 'bar',
},
options: {
transformVars: `
return {
...vars,
test2UpperCase: vars.test2.toUpperCase()
};
`,
},
},
tests: [
{
vars: {
test1: 'foo',
},
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
expect(summary.stats.successes).toBe(1);
expect(summary.stats.failures).toBe(0);
// Check that vars were merged correctly and transform was applied
expect(summary.results[0].vars).toEqual({
test1: 'foo',
test2: 'bar',
test2UpperCase: 'BAR',
});
// Verify the prompt was rendered with all variables
expect(summary.results[0].prompt.raw).toBe('Test prompt foo bar BAR');
});
});
+226
View File
@@ -0,0 +1,226 @@
import './setup';
import { globSync } from 'glob';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
formatVarsForDisplay,
generateVarCombinations,
isAllowedPrompt,
} from '../../src/evaluator';
import { type Prompt } from '../../src/types/index';
afterEach(() => {
vi.restoreAllMocks();
});
describe('generateVarCombinations', () => {
it('should generate combinations for simple variables', () => {
const vars = { language: 'English', greeting: 'Hello' };
const expected = [{ language: 'English', greeting: 'Hello' }];
expect(generateVarCombinations(vars)).toEqual(expected);
});
it('should generate combinations for array variables', () => {
const vars = { language: ['English', 'French'], greeting: 'Hello' };
const expected = [
{ language: 'English', greeting: 'Hello' },
{ language: 'French', greeting: 'Hello' },
];
expect(generateVarCombinations(vars)).toEqual(expected);
});
it('should handle file paths and expand them into combinations', () => {
const vars = { language: 'English', greeting: 'file:///path/to/greetings/*.txt' };
vi.mocked(globSync).mockReturnValue(['greeting1.txt', 'greeting2.txt']);
const expected = [
{ language: 'English', greeting: 'file://greeting1.txt' },
{ language: 'English', greeting: 'file://greeting2.txt' },
];
expect(generateVarCombinations(vars)).toEqual(expected);
});
it('should correctly handle nested array variables', () => {
const vars = {
options: [
['opt1', 'opt2'],
['opt3', 'opt4'],
],
};
const expected = [
{
options: [
['opt1', 'opt2'],
['opt3', 'opt4'],
],
},
];
expect(generateVarCombinations(vars)).toEqual(expected);
});
it('should return an empty array for empty input', () => {
const vars = {};
const expected = [{}];
expect(generateVarCombinations(vars)).toEqual(expected);
});
});
describe('isAllowedPrompt', () => {
const prompt1: Prompt = {
label: 'prompt1',
raw: '',
};
const prompt2: Prompt = {
label: 'group1:prompt2',
raw: '',
};
const prompt3: Prompt = {
label: 'group2:prompt3',
raw: '',
};
it('should return true if allowedPrompts is undefined', () => {
expect(isAllowedPrompt(prompt1, undefined)).toBe(true);
});
it('should return true if allowedPrompts includes the prompt label', () => {
expect(isAllowedPrompt(prompt1, ['prompt1', 'prompt2'])).toBe(true);
});
it('should return true if allowedPrompts includes a label that matches the start of the prompt label followed by a colon', () => {
expect(isAllowedPrompt(prompt2, ['group1'])).toBe(true);
});
it('should return true if allowedPrompts includes a wildcard prefix', () => {
expect(isAllowedPrompt(prompt2, ['group1:*'])).toBe(true);
});
it('should return false if a wildcard prefix does not match the prompt label', () => {
expect(isAllowedPrompt(prompt3, ['group1:*'])).toBe(false);
});
it('should return false if allowedPrompts does not include the prompt label or any matching start label with a colon', () => {
expect(isAllowedPrompt(prompt3, ['group1', 'prompt2'])).toBe(false);
});
it('should return false if allowedPrompts is an empty array', () => {
expect(isAllowedPrompt(prompt1, [])).toBe(false);
});
});
describe('formatVarsForDisplay', () => {
it('should return empty string for empty or undefined vars', () => {
expect(formatVarsForDisplay({}, 50)).toBe('');
expect(formatVarsForDisplay(undefined, 50)).toBe('');
expect(formatVarsForDisplay(null as any, 50)).toBe('');
});
it('should format simple variables correctly', () => {
const vars = { name: 'John', age: 25, city: 'NYC' };
const result = formatVarsForDisplay(vars, 50);
expect(result).toBe('name=John age=25 city=NYC');
});
it('should handle different variable types', () => {
const vars = {
string: 'hello',
number: 42,
boolean: true,
nullValue: null,
undefinedValue: undefined,
object: { nested: 'value' },
array: [1, 2, 3],
};
const result = formatVarsForDisplay(vars, 200);
expect(result).toContain('string=hello');
expect(result).toContain('number=42');
expect(result).toContain('boolean=true');
expect(result).toContain('nullValue=null');
expect(result).toContain('undefinedValue=undefined');
expect(result).toContain('object=[object Object]');
expect(result).toContain('array=1,2,3');
});
it('should truncate individual values to prevent memory issues', () => {
const bigValue = 'x'.repeat(200);
const vars = { bigVar: bigValue };
const result = formatVarsForDisplay(vars, 200);
// Should truncate the value to 100 chars
expect(result).toBe(`bigVar=${'x'.repeat(100)}`);
expect(result.length).toBeLessThanOrEqual(200);
});
it('should handle extremely large vars without crashing', () => {
// This would have caused RangeError before the fix
const megaString = 'x'.repeat(500 * 1024); // 500KB string (reduced from 5MB to prevent SIGSEGV on macOS/Node24)
const vars = {
mega1: megaString,
mega2: megaString,
small: 'normal',
};
expect(() => formatVarsForDisplay(vars, 50)).not.toThrow();
const result = formatVarsForDisplay(vars, 50);
expect(typeof result).toBe('string');
expect(result.length).toBeLessThanOrEqual(50);
});
it('should truncate final result to maxLength', () => {
const vars = {
var1: 'value1',
var2: 'value2',
var3: 'value3',
var4: 'value4',
};
const result = formatVarsForDisplay(vars, 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toBe('var1=value1 var2=val');
});
it('should replace newlines with spaces', () => {
const vars = {
multiline: 'line1\nline2\nline3',
};
const result = formatVarsForDisplay(vars, 100);
expect(result).toBe('multiline=line1 line2 line3');
expect(result).not.toContain('\n');
});
it('should return fallback message on any error', () => {
// Create a problematic object that might throw during String() conversion
const problematicVars = {
badProp: {
toString() {
throw new Error('Cannot convert to string');
},
},
};
const result = formatVarsForDisplay(problematicVars, 50);
expect(result).toBe('[vars unavailable]');
});
it('should handle multiple variables with space distribution', () => {
const vars = {
a: 'short',
b: 'medium_value',
c: 'a_very_long_value_that_exceeds_normal_length',
};
const result = formatVarsForDisplay(vars, 30);
expect(result.length).toBeLessThanOrEqual(30);
expect(result).toContain('a=short');
// Should fit as much as possible within the limit
});
});