Files
promptfoo--promptfoo/test/assertions/runAssertions.test.ts
T
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

898 lines
23 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderMetricName, runAssertions } from '../../src/assertions/index';
import { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
import { DefaultGradingJsonProvider } from '../../src/providers/openai/defaults';
import { ReplicateModerationProvider } from '../../src/providers/replicate';
import { TestGrader } from '../util/utils';
import type {
ApiProvider,
AtomicTestCase,
GradingResult,
ProviderResponse,
} from '../../src/types/index';
vi.mock('../../src/redteam/remoteGeneration', () => ({
shouldGenerateRemote: vi.fn().mockReturnValue(false),
}));
vi.mock('proxy-agent', () => ({
ProxyAgent: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('node:module', () => {
const mockRequire: NodeJS.Require = {
resolve: vi.fn() as unknown as NodeJS.RequireResolve,
} as unknown as NodeJS.Require;
return {
createRequire: vi.fn().mockReturnValue(mockRequire),
};
});
vi.mock('../../src/util/fetch/index', async () => {
const actual = await vi.importActual<typeof import('../../src/util/fetch/index')>(
'../../src/util/fetch/index',
);
return {
...actual,
fetchWithRetries: vi.fn(actual.fetchWithRetries),
};
});
vi.mock('glob', () => ({
globSync: vi.fn(),
}));
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
promises: {
readFile: vi.fn(),
},
}));
vi.mock('../../src/esm', () => ({
getDirectory: () => '/test/dir',
importModule: vi.fn((_filePath: string, functionName?: string) => {
return Promise.resolve(functionName ? {} : undefined);
}),
}));
vi.mock('../../src/database', () => ({
getDb: vi.fn(),
}));
vi.mock('path', async () => {
const actual = await vi.importActual<typeof import('path')>('path');
const mocked = {
...actual,
resolve: vi.fn(),
extname: vi.fn(),
};
return {
...mocked,
default: mocked,
};
});
vi.mock('../../src/cliState', () => ({
default: {
basePath: '/base/path',
},
basePath: '/base/path',
}));
vi.mock('../../src/matchers/rag', async () => {
const actual =
await vi.importActual<typeof import('../../src/matchers/rag')>('../../src/matchers/rag');
return {
...actual,
matchesContextRelevance: vi
.fn()
.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked reason' }),
matchesContextFaithfulness: vi
.fn()
.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked reason' }),
};
});
const _Grader = new TestGrader();
describe('runAssertions', () => {
const test: AtomicTestCase = {
assert: [
{
type: 'equals',
value: 'Expected output',
},
],
};
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should pass when all assertions pass', async () => {
const output = 'Expected output';
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: true,
reason: 'All assertions passed',
});
});
it('should fail when any assertion fails', async () => {
const output = 'Actual output';
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: false,
reason: 'Expected output "Actual output" to equal "Expected output"',
});
});
it('should handle output as an object', async () => {
const output = { key: 'value' };
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: false,
reason: 'Expected output "{"key":"value"}" to equal "Expected output"',
});
});
it('should fail when combined score is less than threshold', async () => {
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test: {
threshold: 0.5,
assert: [
{
type: 'equals',
value: 'Hello world',
weight: 2,
},
{
type: 'contains',
value: 'world',
weight: 1,
},
],
},
providerResponse: { output: 'Hi there world' },
});
expect(result).toMatchObject({
pass: false,
reason: 'Aggregate score 0.33 < 0.5 threshold',
});
});
it('should pass when combined score is greater than threshold', async () => {
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test: {
threshold: 0.25,
assert: [
{
type: 'equals',
value: 'Hello world',
weight: 2,
},
{
type: 'contains',
value: 'world',
weight: 1,
},
],
},
providerResponse: { output: 'Hi there world' },
});
expect(result).toMatchObject({
pass: true,
reason: 'Aggregate score 0.33 ≥ 0.25 threshold',
});
});
describe('assert-set', () => {
const prompt = 'Some prompt';
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
it('assert-set success', async () => {
const output = 'Expected output';
const test: AtomicTestCase = {
assert: [
{
type: 'assert-set',
assert: [
{
type: 'equals',
value: output,
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: true,
reason: 'All assertions passed',
});
});
it('assert-set failure', async () => {
const output = 'Actual output';
const test: AtomicTestCase = {
assert: [
{
type: 'assert-set',
assert: [
{
type: 'equals',
value: 'Expected output',
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: false,
reason: 'Expected output "Actual output" to equal "Expected output"',
});
});
it('assert-set threshold success', async () => {
const output = 'Expected output';
const test: AtomicTestCase = {
assert: [
{
type: 'assert-set',
threshold: 0.25,
assert: [
{
type: 'equals',
value: 'Hello world',
weight: 2,
},
{
type: 'contains',
value: 'Expected',
weight: 1,
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: true,
reason: 'All assertions passed',
});
});
it('assert-set threshold failure', async () => {
const output = 'Expected output';
const test: AtomicTestCase = {
assert: [
{
type: 'assert-set',
threshold: 0.5,
assert: [
{
type: 'equals',
value: 'Hello world',
weight: 2,
},
{
type: 'contains',
value: 'Expected',
weight: 1,
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result).toMatchObject({
pass: false,
reason: 'Aggregate score 0.33 < 0.5 threshold',
});
});
it('assert-set with metric', async () => {
const metric = 'The best metric';
const output = 'Expected output';
const test: AtomicTestCase = {
assert: [
{
type: 'assert-set',
metric,
threshold: 0.5,
assert: [
{
type: 'equals',
value: 'Hello world',
},
{
type: 'contains',
value: 'Expected',
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result.namedScores).toStrictEqual({
[metric]: 0.5,
});
});
it('preserves child metric weights inside assert-set', async () => {
const output = 'Expected output';
const test: AtomicTestCase = {
assert: [
{
type: 'assert-set',
assert: [
{
type: 'equals',
value: 'Expected output',
metric: 'Accuracy',
weight: 3,
},
{
type: 'equals',
value: 'Nope',
metric: 'Accuracy',
weight: 1,
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result.namedScores).toEqual({
Accuracy: 0.75,
});
expect(result.namedScoreWeights).toEqual({
Accuracy: 4,
});
});
it('uses assert-set weight', async () => {
const output = 'Expected';
const test: AtomicTestCase = {
assert: [
{
type: 'equals',
value: 'Nope',
weight: 10,
},
{
type: 'assert-set',
weight: 90,
assert: [
{
type: 'equals',
value: 'Expected',
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt,
provider,
test,
providerResponse: { output },
});
expect(result.score).toBe(0.9);
});
});
it('preserves default provider', async () => {
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
const output = 'Expected output';
const test: AtomicTestCase = {
assert: [
{
type: 'moderation',
provider: 'replicate:moderation:foo/bar',
},
{
type: 'llm-rubric',
value: 'insert rubric here',
},
],
};
const callApiSpy = vi.spyOn(DefaultGradingJsonProvider, 'callApi').mockResolvedValue({
output: JSON.stringify({ pass: true, score: 1.0, reason: 'I love you' }),
});
const callModerationApiSpy = vi
.spyOn(ReplicateModerationProvider.prototype, 'callModerationApi')
.mockResolvedValue({ flags: [] });
const result: GradingResult = await runAssertions({
prompt: 'foobar',
provider,
test,
providerResponse: { output },
});
expect(result.pass).toBeTruthy();
expect(callApiSpy).toHaveBeenCalledTimes(1);
expect(callModerationApiSpy).toHaveBeenCalledTimes(1);
});
it('should use stored grader result from crescendo strategy', async () => {
const storedResult = {
pass: false,
score: 0,
reason: 'Detected jailbreak via crescendo strategy',
metadata: { confidence: 0.95 },
};
const test: AtomicTestCase = {
assert: [
{
type: 'promptfoo:redteam:medical:prioritization-error' as const,
value: 'test assertion',
},
],
metadata: {
pluginId: 'medical:prioritization-error',
strategyId: 'crescendo',
},
};
const providerResponse: ProviderResponse = {
output: 'Some target response',
metadata: {
storedGraderResult: storedResult,
},
};
const result = await runAssertions({
prompt: 'test prompt',
provider: {} as ApiProvider,
test,
providerResponse,
});
// Should use stored result instead of calling grader
expect(result.pass).toBe(false);
expect(result.score).toBe(0);
expect(result.reason).toBe('Detected jailbreak via crescendo strategy');
// Check that component results contain the stored grader result
expect(result.componentResults).toHaveLength(1);
expect(result.componentResults![0].pass).toBe(false);
expect(result.componentResults![0].score).toBe(0);
expect(result.componentResults![0].reason).toBe('Detected jailbreak via crescendo strategy');
expect(result.componentResults![0].metadata?.confidence).toBe(0.95);
});
it('should construct proper return shape for stored grader result', async () => {
const storedResult = {
pass: false,
score: 0,
reason: 'Internal evaluator detected successful attack',
};
const assertion = {
type: 'promptfoo:redteam:medical:prioritization-error' as const,
value: 'test assertion',
};
const test: AtomicTestCase = {
assert: [assertion],
metadata: {
pluginId: 'medical:prioritization-error',
strategyId: 'crescendo',
},
};
const providerResponse: ProviderResponse = {
output: 'Some target response',
metadata: {
storedGraderResult: storedResult,
},
};
const result = await runAssertions({
prompt: 'test prompt',
provider: {} as ApiProvider,
test,
providerResponse,
});
// Should have proper assertion structure in component results
expect(result.componentResults).toHaveLength(1);
expect(result.componentResults![0].assertion).toEqual({
...assertion,
value: assertion.value,
});
// Should include test metadata in component results
expect(result.componentResults![0].metadata).toEqual(
expect.objectContaining({
pluginId: 'medical:prioritization-error',
strategyId: 'crescendo',
}),
);
});
it('should render metric field with variables from test.vars', async () => {
const test: AtomicTestCase = {
vars: {
metricName: 'CustomMetric',
category: 'accuracy',
},
assert: [
{
type: 'equals',
value: 'Expected output',
metric: '{{metricName}}',
},
{
type: 'contains',
value: 'output',
metric: '{{category}}',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
expect(result.namedScores).toEqual({
CustomMetric: 1,
accuracy: 1,
});
});
it('should render metric field in assert-set with variables from test.vars', async () => {
const test: AtomicTestCase = {
vars: {
metricGroup: 'ValidationGroup',
},
assert: [
{
type: 'assert-set',
metric: '{{metricGroup}}',
assert: [
{
type: 'equals',
value: 'Expected output',
},
{
type: 'contains',
value: 'output',
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
expect(result.namedScores).toEqual({
ValidationGroup: 1,
});
});
it('should render undefined metric variables as empty string and not track them', async () => {
const test: AtomicTestCase = {
vars: {},
assert: [
{
type: 'equals',
value: 'Expected output',
metric: '{{undefinedVar}}',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
// Undefined variables render as empty string, which is falsy,
// so the metric is not tracked in namedScores
expect(result.namedScores).toEqual({});
});
it('should preserve static metric names without template syntax', async () => {
const test: AtomicTestCase = {
vars: {
metricName: 'ShouldNotBeUsed',
},
assert: [
{
type: 'equals',
value: 'Expected output',
metric: 'StaticMetricName',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
expect(result.namedScores).toEqual({
StaticMetricName: 1,
});
});
it('should render complex metric templates with multiple variables', async () => {
const test: AtomicTestCase = {
vars: {
category: 'accuracy',
version: 'v2',
},
assert: [
{
type: 'equals',
value: 'Expected output',
metric: '{{category}}_{{version}}',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
expect(result.namedScores).toEqual({
accuracy_v2: 1,
});
});
it('should render metrics for inner assertions within assert-set', async () => {
const test: AtomicTestCase = {
vars: {
outerMetric: 'GroupMetric',
innerMetric1: 'EqualityCheck',
innerMetric2: 'ContainsCheck',
},
assert: [
{
type: 'assert-set',
metric: '{{outerMetric}}',
assert: [
{
type: 'equals',
value: 'Expected output',
metric: '{{innerMetric1}}',
},
{
type: 'contains',
value: 'output',
metric: '{{innerMetric2}}',
},
],
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
// Both outer and inner metrics should be rendered
expect(result.namedScores).toEqual({
GroupMetric: 1,
EqualityCheck: 1,
ContainsCheck: 1,
});
});
it('should handle metric variable with same name as the field (issue #4986)', async () => {
// This test matches the exact use case from the original issue:
// metric: '{{metric}}' with vars: { metric: 'metric1' }
const test: AtomicTestCase = {
vars: {
metric: 'metric1',
},
assert: [
{
type: 'equals',
value: 'Expected output',
metric: '{{metric}}',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
expect(result.namedScores).toEqual({
metric1: 1,
});
});
it('should gracefully handle invalid metric template syntax', async () => {
// Invalid template syntax should not crash, should fall back to original metric
const test: AtomicTestCase = {
vars: {
someVar: 'value',
},
assert: [
{
type: 'equals',
value: 'Expected output',
metric: '{{invalid syntax',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
// Should fall back to the original (invalid) metric string
expect(result.namedScores).toEqual({
'{{invalid syntax': 1,
});
});
it('should handle test with no vars defined', async () => {
// When test.vars is undefined, should still work (empty vars object)
const test: AtomicTestCase = {
assert: [
{
type: 'equals',
value: 'Expected output',
metric: 'StaticMetric',
},
],
};
const result: GradingResult = await runAssertions({
prompt: 'Some prompt',
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
test,
providerResponse: { output: 'Expected output' },
});
expect(result.pass).toBe(true);
expect(result.namedScores).toEqual({
StaticMetric: 1,
});
});
});
describe('renderMetricName', () => {
it('should be exported and callable', () => {
expect(renderMetricName).toBeDefined();
expect(typeof renderMetricName).toBe('function');
});
it('should render template variables', () => {
expect(renderMetricName('{{foo}}', { foo: 'bar' })).toBe('bar');
});
it('should render multiple template variables', () => {
expect(
renderMetricName('{{category}}_{{version}}', { category: 'accuracy', version: 'v2' }),
).toBe('accuracy_v2');
});
it('should return undefined for undefined input', () => {
expect(renderMetricName(undefined, {})).toBeUndefined();
});
it('should return original on render error', () => {
expect(renderMetricName('{{invalid syntax', {})).toBe('{{invalid syntax');
});
it('should handle empty string metric', () => {
expect(renderMetricName('', {})).toBe('');
});
it('should handle metric without template syntax', () => {
expect(renderMetricName('StaticMetric', { foo: 'bar' })).toBe('StaticMetric');
});
it('should handle undefined variable by rendering to empty string', () => {
expect(renderMetricName('{{undefinedVar}}', {})).toBe('');
});
});