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
1297 lines
42 KiB
TypeScript
1297 lines
42 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import Ajv from 'ajv';
|
|
import { globSync } from 'glob';
|
|
import * as yaml from 'js-yaml';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { z } from 'zod';
|
|
import {
|
|
AssertionSchema,
|
|
BaseAssertionTypesSchema,
|
|
CommandLineOptionsSchema,
|
|
GradingConfigSchema,
|
|
isGradingResult,
|
|
MAX_SUGGESTIONS_COUNT,
|
|
OutputConfigSchema,
|
|
TestCaseSchema,
|
|
TestSuiteConfigSchema,
|
|
TestSuiteSchema,
|
|
UnifiedConfigSchema,
|
|
VarsSchema,
|
|
} from '../../src/types/index';
|
|
import { dereferenceConfig } from '../../src/util/config/load';
|
|
import { PromptConfigSchema } from '../../src/validators/prompts';
|
|
import { createMockProvider } from '../factories/provider';
|
|
|
|
import type { TestSuite, TestSuiteConfig } from '../../src/types/index';
|
|
|
|
describe('AssertionSchema', () => {
|
|
it('should validate a basic assertion', () => {
|
|
const basicAssertion = {
|
|
type: 'equals',
|
|
value: 'expected value',
|
|
};
|
|
|
|
const result = AssertionSchema.safeParse(basicAssertion);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate an assertion with all optional fields', () => {
|
|
const fullAssertion = {
|
|
type: 'similar',
|
|
value: 'expected value',
|
|
threshold: 0.8,
|
|
weight: 2,
|
|
provider: 'openai:gpt-4o-mini',
|
|
rubricPrompt: 'Custom rubric prompt',
|
|
metric: 'similarity_score',
|
|
transform: 'toLowerCase()',
|
|
};
|
|
|
|
const result = AssertionSchema.safeParse(fullAssertion);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate all base assertion types', () => {
|
|
const baseTypes = BaseAssertionTypesSchema.options;
|
|
|
|
baseTypes.forEach((type) => {
|
|
const assertion = {
|
|
type,
|
|
value: 'test value',
|
|
};
|
|
|
|
const result = AssertionSchema.safeParse(assertion);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should validate "not-" prefixed assertion types', () => {
|
|
const notPrefixedAssertion = {
|
|
type: 'not-contains',
|
|
value: 'unwanted value',
|
|
};
|
|
|
|
const result = AssertionSchema.safeParse(notPrefixedAssertion);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate assertions with function values', () => {
|
|
const functionAssertion = {
|
|
type: 'equals',
|
|
value: (output: string) => output === 'expected value',
|
|
};
|
|
|
|
const result = AssertionSchema.safeParse(functionAssertion);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should validate assertions with array values', () => {
|
|
const arrayAssertion = {
|
|
type: 'contains-all',
|
|
value: ['value1', 'value2', 'value3'],
|
|
};
|
|
|
|
const result = AssertionSchema.safeParse(arrayAssertion);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('VarsSchema', () => {
|
|
it('should validate and transform various types of values', () => {
|
|
const testCases = [
|
|
{ input: { key: 'string value' }, expected: { key: 'string value' } },
|
|
{ input: { key: 42 }, expected: { key: 42 } },
|
|
{ input: { key: true }, expected: { key: true } },
|
|
{ input: { key: false }, expected: { key: false } },
|
|
{ input: { key: ['a', 'b', 'c'] }, expected: { key: ['a', 'b', 'c'] } },
|
|
{ input: { key: [1, 2, 3] }, expected: { key: [1, 2, 3] } },
|
|
{ input: { key: [true, false] }, expected: { key: [true, false] } },
|
|
{ input: { key: [{ nested: 'object' }] }, expected: { key: [{ nested: 'object' }] } },
|
|
{
|
|
input: { key: { arbitrary: 'value', nested: { object: true } } },
|
|
expected: { key: { arbitrary: 'value', nested: { object: true } } },
|
|
},
|
|
];
|
|
|
|
expect.assertions(testCases.length);
|
|
|
|
testCases.forEach(({ input, expected }) => {
|
|
expect(VarsSchema.safeParse(input)).toEqual({ success: true, data: expected });
|
|
});
|
|
});
|
|
|
|
it('should throw an error for invalid types', () => {
|
|
const invalidCases = [
|
|
{ key: null },
|
|
{ key: undefined },
|
|
{ key: Symbol('test') },
|
|
{ key: () => {} },
|
|
];
|
|
|
|
expect.assertions(invalidCases.length);
|
|
|
|
invalidCases.forEach((invalidInput) => {
|
|
expect(() => VarsSchema.parse(invalidInput)).toThrow(z.ZodError);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isGradingResult', () => {
|
|
it('should return true for valid grading result object', () => {
|
|
const validResult = {
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'Test passed',
|
|
};
|
|
expect(isGradingResult(validResult)).toBe(true);
|
|
});
|
|
|
|
it('should return true for grading result with optional fields', () => {
|
|
const resultWithOptional = {
|
|
pass: false,
|
|
score: 0.2,
|
|
reason: 'Test failed',
|
|
namedScores: { accuracy: 0.5 },
|
|
tokensUsed: { total: 100 },
|
|
componentResults: [],
|
|
assertion: { type: 'equals', value: 'expected' },
|
|
comment: 'Needs improvement',
|
|
};
|
|
expect(isGradingResult(resultWithOptional)).toBe(true);
|
|
});
|
|
|
|
it('should correctly identify valid GradingResult objects', () => {
|
|
const validResults = [
|
|
{ pass: true, score: 1, reason: 'Perfect' },
|
|
{ pass: false, score: 0, reason: 'Failed', namedScores: { accuracy: 0 } },
|
|
{ pass: true, score: 0.5, reason: 'Partial', tokensUsed: { total: 100 } },
|
|
{ pass: true, score: 1, reason: 'Good', componentResults: [] },
|
|
{ pass: false, score: 0, reason: 'Bad' },
|
|
{ pass: true, score: 1, reason: 'Excellent', comment: 'Great job!' },
|
|
];
|
|
|
|
validResults.forEach((result) => {
|
|
expect(isGradingResult(result)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should return false for null', () => {
|
|
expect(isGradingResult(null)).toBe(false);
|
|
});
|
|
|
|
it('should return false for non-object', () => {
|
|
expect(isGradingResult('not an object')).toBe(false);
|
|
expect(isGradingResult(123)).toBe(false);
|
|
expect(isGradingResult(undefined)).toBe(false);
|
|
});
|
|
|
|
it('should return false if missing required fields', () => {
|
|
expect(isGradingResult({ score: 1, reason: 'test' })).toBe(false);
|
|
expect(isGradingResult({ pass: true, reason: 'test' })).toBe(false);
|
|
expect(isGradingResult({ pass: true, score: 1 })).toBe(false);
|
|
});
|
|
|
|
it('should correctly identify invalid GradingResult objects', () => {
|
|
const invalidResults = [
|
|
{},
|
|
{ pass: 'true', score: 1, reason: 'Invalid pass type' },
|
|
{ pass: true, score: '1', reason: 'Invalid score type' },
|
|
{ pass: true, score: 1, reason: 42 },
|
|
{ pass: true, score: 1, reason: 'Valid', namedScores: 'invalid' },
|
|
{ pass: true, score: 1, reason: 'Valid', tokensUsed: 'invalid' },
|
|
{ pass: true, score: 1, reason: 'Valid', componentResults: 'invalid' },
|
|
{ pass: true, score: 1, reason: 'Valid', assertion: 'invalid' },
|
|
{ pass: true, score: 1, reason: 'Valid', comment: 42 },
|
|
];
|
|
|
|
invalidResults.forEach((result) => {
|
|
expect(isGradingResult(result)).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('should return false if fields have wrong types', () => {
|
|
expect(
|
|
isGradingResult({
|
|
pass: 'true',
|
|
score: '0.8',
|
|
reason: 123,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('should return false if optional fields have wrong types', () => {
|
|
expect(
|
|
isGradingResult({
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'test',
|
|
namedScores: 'invalid',
|
|
tokensUsed: 'invalid',
|
|
componentResults: 'invalid',
|
|
assertion: 'invalid',
|
|
comment: 123,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('TestCaseSchema assertScoringFunction', () => {
|
|
it('should validate test case with valid file-based scoring function', () => {
|
|
const testCase = {
|
|
description: 'Test with file scoring',
|
|
assertScoringFunction: 'file://path/to/score.js:scoreFunc',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with valid custom scoring function', () => {
|
|
const testCase = {
|
|
description: 'Test with custom scoring',
|
|
assertScoringFunction: async (scores: Record<string, number>) => {
|
|
return {
|
|
pass: scores.accuracy > 0.8,
|
|
score: scores.accuracy,
|
|
reason: 'Custom scoring applied',
|
|
};
|
|
},
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with missing assertScoringFunction', () => {
|
|
const testCase = {
|
|
description: 'No scoring function',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with python file scoring function', () => {
|
|
const testCase = {
|
|
description: 'Python scoring function',
|
|
assertScoringFunction: 'file://path/to/score.py:score_func',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with typescript file scoring function', () => {
|
|
const testCase = {
|
|
description: 'TypeScript scoring function',
|
|
assertScoringFunction: 'file://path/to/score.ts:scoreFunc',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with file path containing dots', () => {
|
|
const testCase = {
|
|
description: 'File path with dots',
|
|
assertScoringFunction: 'file://path/to/my.score.js:myNamespace.scoreFunc',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with absolute file path', () => {
|
|
const testCase = {
|
|
description: 'Absolute file path',
|
|
assertScoringFunction: 'file:///absolute/path/to/score.js:scoreFunc',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
|
|
it('should validate test case with relative file path', () => {
|
|
const testCase = {
|
|
description: 'Relative file path',
|
|
assertScoringFunction: 'file://./relative/path/score.js:scoreFunc',
|
|
vars: { input: 'test' },
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow('Invalid test case schema');
|
|
});
|
|
});
|
|
|
|
// Tests for #7096: Ensure TestCaseSchema.options accepts properties from all merged schemas
|
|
// This was broken when z.intersection() generated allOf with additionalProperties:false
|
|
describe('TestCaseSchema options (merged schema properties)', () => {
|
|
it('should validate options with properties from PromptConfigSchema', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
prefix: 'You are a helpful assistant.',
|
|
suffix: 'Please be concise.',
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should validate options with properties from OutputConfigSchema', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
transform: 'output.toUpperCase()',
|
|
storeOutputAs: 'myOutput',
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should validate options with properties from GradingConfigSchema', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
provider: 'openai:gpt-4o-mini',
|
|
rubricPrompt: 'Grade the response',
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should validate options with additional properties (disableVarExpansion, etc)', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
disableVarExpansion: true,
|
|
disableConversationVar: true,
|
|
repeat: 3,
|
|
runSerially: true,
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
0,
|
|
-1,
|
|
1.5,
|
|
Number.NaN,
|
|
Number.POSITIVE_INFINITY,
|
|
Number.MAX_SAFE_INTEGER + 1,
|
|
])('should reject invalid per-test repeat %s', (repeat) => {
|
|
expect(TestCaseSchema.safeParse({ options: { repeat } }).success).toBe(false);
|
|
});
|
|
|
|
it('should validate options combining properties from ALL merged schemas', () => {
|
|
// This is the critical test - properties from different sub-schemas must work together
|
|
const testCase = {
|
|
vars: { question: 'What is 2+2?' },
|
|
options: {
|
|
// From PromptConfigSchema
|
|
prefix: 'You are a helpful assistant.',
|
|
suffix: 'Be concise.',
|
|
// From OutputConfigSchema
|
|
transform: 'output.trim()',
|
|
storeOutputAs: 'answer',
|
|
// From GradingConfigSchema
|
|
provider: 'openai:gpt-4o-mini',
|
|
rubricPrompt: 'Check if the answer is correct',
|
|
// Additional properties
|
|
disableVarExpansion: false,
|
|
disableConversationVar: true,
|
|
runSerially: false,
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should validate options with provider as object config', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
provider: {
|
|
id: 'openai:gpt-4o-mini',
|
|
config: {
|
|
temperature: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should allow provider-specific properties in options via catchall', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
options: {
|
|
provider: 'openai:gpt-4o-mini',
|
|
// Provider-specific options like response_format, temperature, etc. should pass through
|
|
response_format: { type: 'json_object' },
|
|
custom_option: 'custom_value',
|
|
},
|
|
};
|
|
const result = TestCaseSchema.parse(testCase);
|
|
// catchall(z.any()) allows provider-specific options to pass through for per-test structured output
|
|
expect(result.options).toHaveProperty('provider', 'openai:gpt-4o-mini');
|
|
expect(result.options).toHaveProperty('response_format', { type: 'json_object' });
|
|
expect(result.options).toHaveProperty('custom_option', 'custom_value');
|
|
});
|
|
});
|
|
|
|
// Tests for metadata schema - ensure custom keys are allowed alongside internal properties
|
|
// This was broken when z.intersection() generated allOf with additionalProperties:false
|
|
describe('TestCaseSchema metadata (custom keys support)', () => {
|
|
it('should validate metadata with custom user-defined keys', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
metadata: {
|
|
customKey: 'custom value',
|
|
anotherKey: 123,
|
|
nestedData: { foo: 'bar' },
|
|
},
|
|
};
|
|
const result = TestCaseSchema.parse(testCase);
|
|
expect(result.metadata).toHaveProperty('customKey', 'custom value');
|
|
expect(result.metadata).toHaveProperty('anotherKey', 123);
|
|
expect(result.metadata).toHaveProperty('nestedData');
|
|
});
|
|
|
|
it('should validate metadata with internal pluginConfig property', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
metadata: {
|
|
pluginConfig: { someConfig: 'value' },
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should validate metadata with internal strategyConfig property', () => {
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
metadata: {
|
|
strategyConfig: { anotherConfig: 'value' },
|
|
},
|
|
};
|
|
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
|
|
});
|
|
|
|
it('should validate metadata combining internal properties with custom keys', () => {
|
|
// This is the critical test - internal properties and custom keys must coexist
|
|
const testCase = {
|
|
vars: { input: 'test' },
|
|
metadata: {
|
|
// Internal properties
|
|
pluginConfig: { pluginOption: true },
|
|
strategyConfig: { strategyOption: 'abc' },
|
|
// Custom user-defined keys
|
|
customTag: 'my-tag',
|
|
experimentId: 12345,
|
|
additionalInfo: { nested: { deeply: 'value' } },
|
|
},
|
|
};
|
|
const result = TestCaseSchema.parse(testCase);
|
|
expect(result.metadata).toHaveProperty('pluginConfig');
|
|
expect(result.metadata).toHaveProperty('strategyConfig');
|
|
expect(result.metadata).toHaveProperty('customTag', 'my-tag');
|
|
expect(result.metadata).toHaveProperty('experimentId', 12345);
|
|
expect(result.metadata).toHaveProperty('additionalInfo');
|
|
});
|
|
});
|
|
|
|
// Guard tests to ensure merged schemas don't have overlapping keys
|
|
// This prevents silent overwrites when spreading .shape properties
|
|
describe('TestCaseSchema options merged schema guard', () => {
|
|
it('should have no overlapping keys between PromptConfigSchema and OutputConfigSchema', () => {
|
|
const promptKeys = Object.keys(PromptConfigSchema.shape);
|
|
const outputKeys = Object.keys(OutputConfigSchema.shape);
|
|
const overlap = promptKeys.filter((key) => outputKeys.includes(key));
|
|
expect(overlap).toEqual([]);
|
|
});
|
|
|
|
it('should have no overlapping keys between PromptConfigSchema and GradingConfigSchema', () => {
|
|
const promptKeys = Object.keys(PromptConfigSchema.shape);
|
|
const gradingKeys = Object.keys(GradingConfigSchema.shape);
|
|
const overlap = promptKeys.filter((key) => gradingKeys.includes(key));
|
|
expect(overlap).toEqual([]);
|
|
});
|
|
|
|
it('should have no overlapping keys between OutputConfigSchema and GradingConfigSchema', () => {
|
|
const outputKeys = Object.keys(OutputConfigSchema.shape);
|
|
const gradingKeys = Object.keys(GradingConfigSchema.shape);
|
|
const overlap = outputKeys.filter((key) => gradingKeys.includes(key));
|
|
expect(overlap).toEqual([]);
|
|
});
|
|
|
|
it('should have no overlapping keys with additional options properties', () => {
|
|
const additionalKeys = ['disableVarExpansion', 'disableConversationVar', 'runSerially'];
|
|
const allSchemaKeys = [
|
|
...Object.keys(PromptConfigSchema.shape),
|
|
...Object.keys(OutputConfigSchema.shape),
|
|
...Object.keys(GradingConfigSchema.shape),
|
|
];
|
|
const overlap = additionalKeys.filter((key) => allSchemaKeys.includes(key));
|
|
expect(overlap).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// AJV-based regression test for #7096
|
|
// Validates that the generated JSON schema accepts combined options properties
|
|
describe('JSON Schema validation (AJV)', () => {
|
|
it('should validate defaultTest.options with combined properties against config-schema.json', () => {
|
|
const schemaPath = path.join(__dirname, '../../site/static/config-schema.json');
|
|
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
|
|
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
const validate = ajv.compile(schema);
|
|
|
|
// Config with combined options from all merged schemas
|
|
const config = {
|
|
prompts: ['test prompt'],
|
|
providers: ['echo'],
|
|
defaultTest: {
|
|
options: {
|
|
// From PromptConfigSchema
|
|
prefix: 'You are helpful.',
|
|
suffix: 'Be concise.',
|
|
// From OutputConfigSchema
|
|
transform: 'output.trim()',
|
|
// From GradingConfigSchema
|
|
provider: 'openai:gpt-4o-mini',
|
|
// Additional properties
|
|
disableVarExpansion: false,
|
|
},
|
|
},
|
|
tests: [{ vars: { input: 'test' } }],
|
|
};
|
|
|
|
const valid = validate(config);
|
|
if (!valid) {
|
|
// Filter to show only the relevant errors (not the anyOf noise)
|
|
const relevantErrors = validate.errors?.filter(
|
|
(e) => e.instancePath.includes('options') && e.keyword === 'additionalProperties',
|
|
);
|
|
expect(relevantErrors).toEqual([]);
|
|
}
|
|
expect(valid).toBe(true);
|
|
});
|
|
|
|
it('should validate test metadata with custom keys against config-schema.json', () => {
|
|
const schemaPath = path.join(__dirname, '../../site/static/config-schema.json');
|
|
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
|
|
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
const validate = ajv.compile(schema);
|
|
|
|
// Config with custom metadata keys
|
|
const config = {
|
|
prompts: ['test prompt'],
|
|
providers: ['echo'],
|
|
tests: [
|
|
{
|
|
vars: { input: 'test' },
|
|
metadata: {
|
|
customTag: 'my-tag',
|
|
experimentId: 12345,
|
|
pluginConfig: { option: true },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const valid = validate(config);
|
|
if (!valid) {
|
|
const relevantErrors = validate.errors?.filter(
|
|
(e) => e.instancePath.includes('metadata') && e.keyword === 'additionalProperties',
|
|
);
|
|
expect(relevantErrors).toEqual([]);
|
|
}
|
|
expect(valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('CommandLineOptionsSchema', () => {
|
|
it('should validate options with filterErrorsOnly string', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: 'true',
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should validate runtime tags', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
tags: {
|
|
runId: 'ci-123',
|
|
skillVersion: '1.2.3',
|
|
},
|
|
};
|
|
expect(CommandLineOptionsSchema.parse(options)).toMatchObject({
|
|
tags: {
|
|
runId: 'ci-123',
|
|
skillVersion: '1.2.3',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should validate options without filterErrorsOnly', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should validate options with empty filterErrorsOnly string', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: '',
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should validate options with non-boolean filterErrorsOnly string', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: 'errors-only',
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should reject options with non-string filterErrorsOnly', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: true,
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).toThrow(
|
|
'Invalid input: expected string, received boolean',
|
|
);
|
|
});
|
|
|
|
it('should validate options with filterErrorsOnly and other filter options', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: 'true',
|
|
filterFailing: 'true',
|
|
filterFirstN: 5,
|
|
filterMetadata: 'meta',
|
|
filterRange: '1:3',
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should validate options with filterErrorsOnly and minimal required fields', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: 'true',
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should validate options with all possible filter combinations', () => {
|
|
const options = {
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterErrorsOnly: 'true',
|
|
filterFailing: 'true',
|
|
filterFirstN: 10,
|
|
filterMetadata: 'metadata',
|
|
filterPattern: 'pattern',
|
|
filterProviders: 'provider1',
|
|
filterRange: '1:3',
|
|
filterSample: 5,
|
|
filterSampleSeed: 42,
|
|
filterTargets: 'target1',
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse(options)).not.toThrow(
|
|
'Invalid command line options',
|
|
);
|
|
});
|
|
|
|
it('should coerce numeric filter sample seed arguments to numbers', () => {
|
|
expect(
|
|
CommandLineOptionsSchema.parse({
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterSampleSeed: '42',
|
|
}),
|
|
).toMatchObject({
|
|
filterSampleSeed: 42,
|
|
});
|
|
});
|
|
|
|
it('should reject nonnumeric filter sample seeds', () => {
|
|
expect(() =>
|
|
CommandLineOptionsSchema.parse({
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterSampleSeed: 'repeatable-run',
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
it('should reject unsafe integer filter sample seeds', () => {
|
|
expect(() =>
|
|
CommandLineOptionsSchema.parse({
|
|
providers: ['provider1'],
|
|
output: ['output1'],
|
|
filterSampleSeed: Number.MAX_SAFE_INTEGER + 1,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
it('should reject invalid filterRange values', () => {
|
|
const baseOptions = {
|
|
output: ['output1'],
|
|
providers: ['provider1'],
|
|
};
|
|
expect(() => CommandLineOptionsSchema.parse({ ...baseOptions, filterRange: '3:1' })).toThrow(
|
|
'--filter-range start must be less than or equal to end, got: 3:1',
|
|
);
|
|
expect(() => CommandLineOptionsSchema.parse({ ...baseOptions, filterRange: ':' })).toThrow(
|
|
'--filter-range must be specified in start:end format using zero-based indices, got: :',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('CommandLineOptionsSchema suggestionsCount', () => {
|
|
const baseOptions = { providers: ['p'], output: ['o'] };
|
|
|
|
it('coerces string CLI input', () => {
|
|
expect(CommandLineOptionsSchema.parse({ ...baseOptions, suggestionsCount: '5' })).toMatchObject(
|
|
{ suggestionsCount: 5 },
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['zero', 0],
|
|
['negative', -1],
|
|
['above max', MAX_SUGGESTIONS_COUNT + 1],
|
|
['non-integer', 1.5],
|
|
['NaN string', 'abc'],
|
|
])('rejects %s (%s)', (_label, value) => {
|
|
expect(() =>
|
|
CommandLineOptionsSchema.parse({ ...baseOptions, suggestionsCount: value }),
|
|
).toThrow();
|
|
});
|
|
|
|
it('accepts the boundary values', () => {
|
|
expect(CommandLineOptionsSchema.parse({ ...baseOptions, suggestionsCount: 1 })).toMatchObject({
|
|
suggestionsCount: 1,
|
|
});
|
|
expect(
|
|
CommandLineOptionsSchema.parse({ ...baseOptions, suggestionsCount: MAX_SUGGESTIONS_COUNT }),
|
|
).toMatchObject({ suggestionsCount: MAX_SUGGESTIONS_COUNT });
|
|
});
|
|
});
|
|
|
|
describe('TestSuiteConfigSchema', () => {
|
|
const rootDir = path.join(__dirname, '../..');
|
|
const configFiles = globSync(`${rootDir}/examples/**/promptfooconfig.{yaml,yml,json}`, {
|
|
windowsPathsNoEscape: true,
|
|
});
|
|
|
|
it('should find configuration files', () => {
|
|
expect(configFiles.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
describe('env property', () => {
|
|
it('should validate config with string env values', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
env: {
|
|
API_KEY: 'abc123',
|
|
DEBUG: 'true',
|
|
},
|
|
};
|
|
expect(() => TestSuiteConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should validate config with number env values converted to strings', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
env: {
|
|
PORT: 3000,
|
|
TIMEOUT: 5000,
|
|
},
|
|
};
|
|
expect(() => TestSuiteConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should validate config with boolean env values converted to strings', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
env: {
|
|
DEBUG: true,
|
|
VERBOSE: false,
|
|
},
|
|
};
|
|
expect(() => TestSuiteConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should validate config with undefined env property', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
};
|
|
expect(() => TestSuiteConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should validate env field with primitive values that get transformed to strings', () => {
|
|
const customEnvVars = {
|
|
CUSTOM_STRING_VALUE: 'string-value',
|
|
CUSTOM_NUMBER_VALUE: 42,
|
|
CUSTOM_BOOLEAN_TRUE: true,
|
|
CUSTOM_BOOLEAN_FALSE: false,
|
|
};
|
|
|
|
const recordSchema = z.record(
|
|
z.string(),
|
|
z.union([z.string(), z.number(), z.boolean()]).transform(String),
|
|
);
|
|
const recordResult = recordSchema.safeParse(customEnvVars);
|
|
|
|
expect(recordResult.success).toBe(true);
|
|
|
|
const parsedData = recordResult.success ? recordResult.data : {};
|
|
|
|
expect(parsedData.CUSTOM_STRING_VALUE).toBe('string-value');
|
|
expect(parsedData.CUSTOM_NUMBER_VALUE).toBe('42');
|
|
expect(parsedData.CUSTOM_BOOLEAN_TRUE).toBe('true');
|
|
expect(parsedData.CUSTOM_BOOLEAN_FALSE).toBe('false');
|
|
|
|
const config = {
|
|
providers: [{ id: 'test-provider' }],
|
|
prompts: ['test prompt'],
|
|
env: customEnvVars,
|
|
};
|
|
|
|
const result = TestSuiteConfigSchema.safeParse(config);
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
const testProvider = result.success
|
|
? {
|
|
id: 'test-provider',
|
|
config: { someConfig: true },
|
|
env: result.data.env,
|
|
}
|
|
: { id: 'test-provider', config: { someConfig: true } };
|
|
|
|
expect(Object.keys(testProvider)).toContain('env');
|
|
});
|
|
});
|
|
|
|
describe('tracing property', () => {
|
|
it('defaults the OTLP HTTP receiver to loopback', () => {
|
|
const result = TestSuiteConfigSchema.parse({
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
tracing: {
|
|
enabled: true,
|
|
otlp: {
|
|
http: {},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.tracing?.otlp?.http?.host).toBe('127.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('extensions field', () => {
|
|
const minimalConfig = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
};
|
|
|
|
it('should accept null extensions', () => {
|
|
const config = {
|
|
...minimalConfig,
|
|
extensions: null,
|
|
};
|
|
const result = TestSuiteConfigSchema.safeParse(config);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept undefined extensions (optional field)', () => {
|
|
const config = { ...minimalConfig };
|
|
// Explicitly not setting extensions
|
|
const result = TestSuiteConfigSchema.safeParse(config);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept empty array extensions', () => {
|
|
const config = {
|
|
...minimalConfig,
|
|
extensions: [],
|
|
};
|
|
const result = TestSuiteConfigSchema.safeParse(config);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept array of extension strings', () => {
|
|
const config = {
|
|
...minimalConfig,
|
|
extensions: ['file://path/to/extension.js:functionName'],
|
|
};
|
|
const result = TestSuiteConfigSchema.safeParse(config);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should transform and remove null, undefined, and empty array extensions', () => {
|
|
// We need to use UnifiedConfigSchema for transformation tests
|
|
// since the transform is applied there
|
|
const configs = [
|
|
{ ...minimalConfig, extensions: null },
|
|
{ ...minimalConfig }, // undefined extensions
|
|
{ ...minimalConfig, extensions: [] },
|
|
];
|
|
|
|
configs.forEach((config) => {
|
|
const result = UnifiedConfigSchema.safeParse(config);
|
|
expect(result.success).toBe(true);
|
|
// Only access data properties if we're sure parsing succeeded
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
data: expect.not.objectContaining({ extensions: expect.anything() }),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should keep non-empty extensions arrays', () => {
|
|
const config = {
|
|
...minimalConfig,
|
|
extensions: ['file://path/to/extension.js:functionName'],
|
|
};
|
|
|
|
const result = UnifiedConfigSchema.safeParse(config);
|
|
expect(result.success).toBe(true);
|
|
// Only access data properties if we're sure parsing succeeded
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
data: expect.objectContaining({
|
|
extensions: ['file://path/to/extension.js:functionName'],
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('defaultTest validation', () => {
|
|
it('should accept string defaultTest starting with file://', () => {
|
|
const validConfig = {
|
|
providers: ['openai:gpt-4'],
|
|
prompts: ['Test prompt'],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: 'file://path/to/defaultTest.yaml',
|
|
};
|
|
|
|
const result = TestSuiteConfigSchema.safeParse(validConfig);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.defaultTest).toBe('file://path/to/defaultTest.yaml');
|
|
});
|
|
|
|
it('should reject string defaultTest not starting with file://', () => {
|
|
const invalidConfig = {
|
|
providers: ['openai:gpt-4'],
|
|
prompts: ['Test prompt'],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: 'invalid/path.yaml',
|
|
};
|
|
|
|
const result = TestSuiteConfigSchema.safeParse(invalidConfig);
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('should accept partial TestCase object for defaultTest', () => {
|
|
const validConfig = {
|
|
providers: ['openai:gpt-4'],
|
|
prompts: ['Test prompt'],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: {
|
|
assert: [{ type: 'contains', value: 'test' }],
|
|
vars: { default: true },
|
|
threshold: 0.8,
|
|
},
|
|
};
|
|
|
|
const result = TestSuiteConfigSchema.safeParse(validConfig);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.defaultTest).toEqual({
|
|
assert: [{ type: 'contains', value: 'test' }],
|
|
vars: { default: true },
|
|
threshold: 0.8,
|
|
});
|
|
});
|
|
|
|
it('should accept defaultTest with description field (it will be stripped)', () => {
|
|
const configWithDescription = {
|
|
providers: ['openai:gpt-4'],
|
|
prompts: ['Test prompt'],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: {
|
|
description: 'This will be stripped by omit()',
|
|
assert: [{ type: 'equals', value: 'test' }],
|
|
},
|
|
};
|
|
|
|
const result = TestSuiteConfigSchema.safeParse(configWithDescription);
|
|
expect(result.success).toBe(true);
|
|
// The description field should be stripped from the parsed result
|
|
expect(result.data!.defaultTest).toEqual({
|
|
assert: [{ type: 'equals', value: 'test' }],
|
|
});
|
|
expect((result.data!.defaultTest as any).description).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
for (const file of configFiles) {
|
|
it(`should validate ${path.relative(rootDir, file)}`, async () => {
|
|
const configContent = fs.readFileSync(file, 'utf8');
|
|
const config = yaml.load(configContent) as Record<string, unknown>;
|
|
const dereferencedConfig = await dereferenceConfig(config as TestSuiteConfig);
|
|
const extendedSchema = TestSuiteConfigSchema.extend({
|
|
targets: TestSuiteConfigSchema.shape.providers.optional(),
|
|
providers: TestSuiteConfigSchema.shape.providers.optional(),
|
|
...(typeof dereferencedConfig.redteam !== 'undefined' && {
|
|
prompts: z.optional(TestSuiteConfigSchema.shape.prompts),
|
|
}),
|
|
}).refine(
|
|
(data) => {
|
|
const hasTargets = Boolean(data.targets);
|
|
const hasProviders = Boolean(data.providers);
|
|
return (hasTargets && !hasProviders) || (!hasTargets && hasProviders);
|
|
},
|
|
{
|
|
error: "Exactly one of 'targets' or 'providers' must be provided, but not both",
|
|
},
|
|
);
|
|
|
|
const result = extendedSchema.safeParse(dereferencedConfig);
|
|
expect(
|
|
result.success,
|
|
`Validation failed for ${file}: ${result.success ? '' : result.error.message}`,
|
|
).toBe(true);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('UnifiedConfigSchema extensions handling', () => {
|
|
it('should remove null extensions', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
extensions: null,
|
|
};
|
|
const parsed = UnifiedConfigSchema.parse(config);
|
|
expect(parsed.extensions).toBeUndefined();
|
|
});
|
|
|
|
it('should remove undefined extensions', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
extensions: undefined,
|
|
};
|
|
const parsed = UnifiedConfigSchema.parse(config);
|
|
expect(parsed.extensions).toBeUndefined();
|
|
});
|
|
|
|
it('should remove empty array extensions', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
extensions: [],
|
|
};
|
|
const parsed = UnifiedConfigSchema.parse(config);
|
|
expect(parsed.extensions).toBeUndefined();
|
|
});
|
|
|
|
it('should preserve valid extensions array', () => {
|
|
const config = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
extensions: ['ext1', 'ext2'],
|
|
};
|
|
const parsed = UnifiedConfigSchema.parse(config);
|
|
expect(parsed.extensions).toEqual(['ext1', 'ext2']);
|
|
});
|
|
|
|
it('should transform targets to providers when only targets is present', () => {
|
|
const config = {
|
|
targets: ['target1', 'target2'],
|
|
prompts: ['prompt1'],
|
|
};
|
|
const parsed = UnifiedConfigSchema.parse(config);
|
|
expect(parsed.providers).toEqual(['target1', 'target2']);
|
|
expect(parsed.targets).toBeUndefined();
|
|
});
|
|
|
|
it('should throw an error when both targets and providers are present', () => {
|
|
const config = {
|
|
providers: ['provider1', 'provider2'],
|
|
targets: ['target1', 'target2'],
|
|
prompts: ['prompt1'],
|
|
};
|
|
expect(() => UnifiedConfigSchema.parse(config)).toThrow(
|
|
"Exactly one of 'targets' or 'providers' must be provided, but not both",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('TestSuiteSchema', () => {
|
|
const baseTestSuite: TestSuite = {
|
|
providers: [createMockProvider({ id: 'mock-provider', response: {} })],
|
|
prompts: [{ raw: 'Hello, world!', label: 'mock-prompt' }],
|
|
};
|
|
|
|
describe('extensions field', () => {
|
|
it('should allow null extensions', () => {
|
|
const suite = {
|
|
providers: [createMockProvider({ id: 'provider1', response: {} })],
|
|
prompts: [{ raw: 'prompt1', label: 'test' }],
|
|
extensions: null,
|
|
};
|
|
expect(() => TestSuiteSchema.parse(suite)).not.toThrow();
|
|
});
|
|
|
|
it('should allow undefined extensions', () => {
|
|
const suite = {
|
|
providers: [createMockProvider({ id: 'provider1', response: {} })],
|
|
prompts: [{ raw: 'prompt1', label: 'test' }],
|
|
};
|
|
expect(() => TestSuiteSchema.parse(suite)).not.toThrow();
|
|
});
|
|
|
|
it('should accept valid Python extension paths', () => {
|
|
const validExtensions = [
|
|
'file://path/to/file.py:function_name',
|
|
'file://./relative/path.py:function_name',
|
|
'file:///absolute/path.py:function_name',
|
|
];
|
|
|
|
validExtensions.forEach((extension) => {
|
|
const result = TestSuiteSchema.safeParse({ ...baseTestSuite, extensions: [extension] });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should accept valid JavaScript extension paths', () => {
|
|
const validExtensions = [
|
|
'file://path/to/file.js:function_name',
|
|
'file://./relative/path.ts:function_name',
|
|
'file:///absolute/path.mjs:function_name',
|
|
'file://path/to/file.cjs:function_name',
|
|
];
|
|
|
|
validExtensions.forEach((extension) => {
|
|
const result = TestSuiteSchema.safeParse({ ...baseTestSuite, extensions: [extension] });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['path/to/file.py:function_name', 'Missing file:// prefix'],
|
|
['file://path/to/file.txt:function_name', 'Invalid file extension'],
|
|
['file://path/to/file.py', 'Missing function name'],
|
|
['file://path/to/file.py:', 'Empty function name'],
|
|
['file://:function_name', 'Missing file path'],
|
|
['file://path/to/file.py:function_name:extra_arg', 'Extra argument'],
|
|
])('should reject invalid extension path: %s (%s)', (extension, _reason) => {
|
|
const result = TestSuiteSchema.safeParse({ ...baseTestSuite, extensions: [extension] });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.issues[0].message).toMatch(/Extension must/);
|
|
});
|
|
|
|
it('should allow an empty array of extensions', () => {
|
|
const result = TestSuiteSchema.safeParse({ ...baseTestSuite, extensions: [] });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('defaultTest validation', () => {
|
|
it('should accept string defaultTest starting with file://', () => {
|
|
const validConfig = {
|
|
providers: [createMockProvider({ id: 'openai:gpt-4', response: { output: 'test' } })],
|
|
prompts: [{ raw: 'Test prompt', label: 'test' }],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: 'file://path/to/defaultTest.yaml',
|
|
};
|
|
|
|
const result = TestSuiteSchema.safeParse(validConfig);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.defaultTest).toBe('file://path/to/defaultTest.yaml');
|
|
});
|
|
|
|
it('should reject string defaultTest not starting with file://', () => {
|
|
const invalidConfig = {
|
|
providers: [createMockProvider({ id: 'openai:gpt-4', response: { output: 'test' } })],
|
|
prompts: [{ raw: 'Test prompt', label: 'test' }],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: 'invalid/path.yaml',
|
|
};
|
|
|
|
const result = TestSuiteSchema.safeParse(invalidConfig);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error!.issues[0].message).toContain(
|
|
'defaultTest string must start with file://',
|
|
);
|
|
});
|
|
|
|
it('should accept object defaultTest', () => {
|
|
const validConfig = {
|
|
providers: [createMockProvider({ id: 'openai:gpt-4', response: { output: 'test' } })],
|
|
prompts: [{ raw: 'Test prompt', label: 'test' }],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
defaultTest: {
|
|
assert: [{ type: 'equals', value: 'test' }],
|
|
vars: { foo: 'bar' },
|
|
options: { provider: 'openai:gpt-4' },
|
|
},
|
|
};
|
|
|
|
const result = TestSuiteSchema.safeParse(validConfig);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.defaultTest).toEqual(
|
|
expect.objectContaining({
|
|
assert: [{ type: 'equals', value: 'test' }],
|
|
vars: { foo: 'bar' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should accept undefined defaultTest', () => {
|
|
const validConfig = {
|
|
providers: [createMockProvider({ id: 'openai:gpt-4', response: { output: 'test' } })],
|
|
prompts: [{ raw: 'Test prompt', label: 'test' }],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
};
|
|
|
|
const result = TestSuiteSchema.safeParse(validConfig);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.defaultTest).toBeUndefined();
|
|
});
|
|
});
|
|
});
|