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
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { runAssertion } from '../../src/assertions/index';
|
|
import cliState from '../../src/cliState';
|
|
import { importModule } from '../../src/esm';
|
|
import * as llmGradingMatchers from '../../src/matchers/llmGrading';
|
|
import { runRuby } from '../../src/ruby/rubyUtils.js';
|
|
|
|
import type { ProviderResponse } from '../../src/types/index';
|
|
|
|
// Mock dependencies
|
|
vi.mock('../../src/redteam/remoteGeneration', () => ({
|
|
shouldGenerateRemote: vi.fn().mockReturnValue(false),
|
|
}));
|
|
|
|
vi.mock('libsql');
|
|
|
|
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('glob', () => ({
|
|
globSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/esm', () => ({
|
|
importModule: vi.fn(),
|
|
getDirectory: vi.fn().mockReturnValue('/test/dir'),
|
|
}));
|
|
vi.mock('../../src/database', () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/ruby/rubyUtils.js', () => ({
|
|
runRuby: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/matchers/llmGrading', async () => {
|
|
const actual = await vi.importActual('../../src/matchers/llmGrading');
|
|
return {
|
|
...actual,
|
|
matchesLlmRubric: vi.fn().mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' }),
|
|
matchesFactuality: vi.fn().mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' }),
|
|
matchesClosedQa: vi.fn().mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' }),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/matchers/similarity', async () => {
|
|
const actual = await vi.importActual('../../src/matchers/similarity');
|
|
return {
|
|
...actual,
|
|
matchesSimilarity: vi.fn().mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' }),
|
|
};
|
|
});
|
|
|
|
describe('Script value resolution', () => {
|
|
const originalBasePath = cliState.basePath;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
// Restore importModule mock implementation after reset
|
|
vi.mocked(importModule).mockImplementation((filePath: string, functionName?: string) => {
|
|
const mod = require(path.resolve(filePath));
|
|
if (functionName) {
|
|
return Promise.resolve(mod[functionName]);
|
|
}
|
|
return Promise.resolve(mod);
|
|
});
|
|
|
|
cliState.basePath = path.resolve(__dirname, '../fixtures/file-script-assertions');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cliState.basePath = originalBasePath;
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
const baseProviderResponse: ProviderResponse = {
|
|
output: 'test output',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
};
|
|
|
|
describe('llm-rubric with file:// script', () => {
|
|
it('should pass script output to matchesLlmRubric', async () => {
|
|
const mockMatchesLlmRubric = vi.mocked(llmGradingMatchers.matchesLlmRubric);
|
|
mockMatchesLlmRubric.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' });
|
|
|
|
await runAssertion({
|
|
assertion: {
|
|
type: 'llm-rubric',
|
|
value: 'file://rubric-generator.cjs:knownValue',
|
|
},
|
|
test: { vars: {}, options: { provider: { id: 'echo' } } },
|
|
providerResponse: baseProviderResponse,
|
|
});
|
|
|
|
expect(mockMatchesLlmRubric).toHaveBeenCalledWith(
|
|
'SCRIPT_OUTPUT_12345', // Script output, NOT file path
|
|
expect.any(String),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should pass direct value to matchesLlmRubric when no script', async () => {
|
|
const mockMatchesLlmRubric = vi.mocked(llmGradingMatchers.matchesLlmRubric);
|
|
mockMatchesLlmRubric.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' });
|
|
|
|
await runAssertion({
|
|
assertion: {
|
|
type: 'llm-rubric',
|
|
value: 'direct rubric text',
|
|
},
|
|
test: { vars: {}, options: { provider: { id: 'echo' } } },
|
|
providerResponse: baseProviderResponse,
|
|
});
|
|
|
|
expect(mockMatchesLlmRubric).toHaveBeenCalledWith(
|
|
'direct rubric text',
|
|
expect.any(String),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should pass object returned by script to matchesLlmRubric', async () => {
|
|
const mockMatchesLlmRubric = vi.mocked(llmGradingMatchers.matchesLlmRubric);
|
|
mockMatchesLlmRubric.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked' });
|
|
|
|
await runAssertion({
|
|
assertion: {
|
|
type: 'llm-rubric',
|
|
value: 'file://rubric-generator.cjs:rubricObject',
|
|
},
|
|
test: { vars: {}, options: { provider: { id: 'echo' } } },
|
|
providerResponse: baseProviderResponse,
|
|
});
|
|
|
|
expect(mockMatchesLlmRubric).toHaveBeenCalledWith(
|
|
{ role: 'system', content: 'Evaluate the response for accuracy' },
|
|
expect.any(String),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('contains with file:// script', () => {
|
|
it('should use script output for contains assertion', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'contains',
|
|
value: 'file://rubric-generator.cjs:knownValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'The answer is SCRIPT_OUTPUT_12345 here',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should fail when output does not contain script value', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'contains',
|
|
value: 'file://rubric-generator.cjs:knownValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'wrong output',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
// Verify error message contains script output, not file path
|
|
expect(result.reason).toContain('SCRIPT_OUTPUT_12345');
|
|
expect(result.reason).not.toContain('file://');
|
|
});
|
|
|
|
it('should use numeric script output for contains assertion', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'contains',
|
|
value: 'file://rubric-generator.cjs:numericValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'There are 0 errors',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should reject NaN script output for inverse contains assertions', async () => {
|
|
await expect(
|
|
runAssertion({
|
|
assertion: {
|
|
type: 'not-contains',
|
|
value: 'file://rubric-generator.cjs:nanValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'ordinary output',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
}),
|
|
).rejects.toThrow('"contains" assertion type must have a string or number value');
|
|
});
|
|
});
|
|
|
|
describe('equals with file:// script', () => {
|
|
it('should use script output for equals assertion', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'equals',
|
|
value: 'file://rubric-generator.cjs:knownValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'SCRIPT_OUTPUT_12345',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should fail when output does not equal script value', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'equals',
|
|
value: 'file://rubric-generator.cjs:knownValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'wrong output',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('regex with file:// script', () => {
|
|
it('should use script output as regex pattern', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'regex',
|
|
value: 'file://rubric-generator.cjs:getPattern',
|
|
},
|
|
test: { vars: { pattern: '\\d+' } },
|
|
providerResponse: {
|
|
output: 'Code: 12345',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should throw error when script returns function for non-script assertion', async () => {
|
|
await expect(
|
|
runAssertion({
|
|
assertion: {
|
|
type: 'equals',
|
|
value: 'file://rubric-generator.cjs:functionValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: baseProviderResponse,
|
|
}),
|
|
).rejects.toThrow(/Script for "equals" assertion returned a function/);
|
|
});
|
|
|
|
it('should throw error when script returns boolean for non-script assertion', async () => {
|
|
await expect(
|
|
runAssertion({
|
|
assertion: {
|
|
type: 'contains',
|
|
value: 'file://rubric-generator.cjs:booleanValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: baseProviderResponse,
|
|
}),
|
|
).rejects.toThrow(/Script for "contains" assertion returned a boolean/);
|
|
});
|
|
|
|
it('should throw error when script returns GradingResult for non-script assertion', async () => {
|
|
await expect(
|
|
runAssertion({
|
|
assertion: {
|
|
type: 'llm-rubric',
|
|
value: 'file://rubric-generator.cjs:gradingResultValue',
|
|
},
|
|
test: { vars: {}, options: { provider: { id: 'echo' } } },
|
|
providerResponse: baseProviderResponse,
|
|
}),
|
|
).rejects.toThrow(/Script for "llm-rubric" assertion returned a GradingResult/);
|
|
});
|
|
});
|
|
|
|
// REGRESSION TESTS: Ensure javascript/python/ruby assertions still work correctly
|
|
describe('javascript assertion regression', () => {
|
|
it('should use script return value as assertion result (NOT as comparison)', async () => {
|
|
// The gradingFunction returns { pass: true, score: 1, reason: '...' } when output contains 'expected'
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: 'file://rubric-generator.cjs:gradingFunction',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'this contains expected word',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toContain('expected');
|
|
});
|
|
|
|
it('should fail when javascript grading function returns false', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: 'file://rubric-generator.cjs:gradingFunction',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'does not contain the magic word',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
});
|
|
|
|
it('should work with inline javascript code', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: 'output.includes("hello")',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'hello world',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty string from script', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'equals',
|
|
value: 'file://rubric-generator.cjs:emptyValue',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: '',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle array from script for contains-all', async () => {
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'contains-all',
|
|
value: 'file://rubric-generator.cjs:referenceArray',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'This has reference one and also reference two in it',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should allow colons in class or function names when parsing file names', async () => {
|
|
const mockRunRuby = vi.mocked(runRuby);
|
|
mockRunRuby.mockResolvedValue(true);
|
|
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'ruby',
|
|
value: 'file://some_ruby_file.rb:MyModule::Nested.method',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'namespaced result',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(mockRunRuby).toHaveBeenCalledWith(
|
|
expect.stringContaining('some_ruby_file.rb'),
|
|
'MyModule::Nested.method',
|
|
expect.any(Array),
|
|
);
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should return fail result when runRuby throws for a namespaced method', async () => {
|
|
const mockRunRuby = vi.mocked(runRuby);
|
|
mockRunRuby.mockRejectedValue(new Error('Ruby execution error'));
|
|
|
|
const result = await runAssertion({
|
|
assertion: {
|
|
type: 'ruby',
|
|
value: 'file://some_ruby_file.rb:MyModule::Nested.method',
|
|
},
|
|
test: { vars: {} },
|
|
providerResponse: {
|
|
output: 'namespaced result',
|
|
tokenUsage: { total: 0, prompt: 0, completion: 0 },
|
|
},
|
|
});
|
|
|
|
expect(mockRunRuby).toHaveBeenCalledWith(
|
|
expect.stringContaining('some_ruby_file.rb'),
|
|
'MyModule::Nested.method',
|
|
expect.any(Array),
|
|
);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.reason).toContain('Ruby execution error');
|
|
});
|
|
});
|
|
});
|