0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
643 lines
22 KiB
TypeScript
643 lines
22 KiB
TypeScript
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' });
|
|
});
|
|
});
|