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
333 lines
9.8 KiB
TypeScript
333 lines
9.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { evaluate } from '../src/evaluator';
|
|
import Eval from '../src/models/eval';
|
|
|
|
import type { ApiProvider, TestSuite } from '../src/types/index';
|
|
|
|
// Create hoisted mock for transform
|
|
const mockTransform = vi.hoisted(() => vi.fn());
|
|
const mockRunAssertions = vi.hoisted(() => vi.fn());
|
|
|
|
// Mock the transform function to track calls
|
|
vi.mock('../src/util/transform', () => ({
|
|
transform: mockTransform,
|
|
TransformInputType: {
|
|
OUTPUT: 'output',
|
|
VARS: 'vars',
|
|
},
|
|
}));
|
|
|
|
// Mock assertions to prevent timeouts
|
|
vi.mock('../src/assertions', async () => {
|
|
const actual = await vi.importActual('../src/assertions');
|
|
return {
|
|
...(actual as any),
|
|
runAssertions: mockRunAssertions,
|
|
};
|
|
});
|
|
|
|
// Mock cache to prevent file system operations
|
|
vi.mock('../src/cache', () => ({
|
|
getCache: vi.fn(() => ({
|
|
get: vi.fn(),
|
|
set: vi.fn(),
|
|
wrap: vi.fn((_key: any, fn: any) => fn()),
|
|
})),
|
|
withCacheNamespace: vi.fn(async (_namespace: string | undefined, fn: () => Promise<unknown>) =>
|
|
fn(),
|
|
),
|
|
}));
|
|
|
|
// Mock logger to prevent console output during tests
|
|
vi.mock('../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock file operations
|
|
vi.mock('../src/util/file', () => ({
|
|
readFileCached: vi.fn(() => Promise.resolve('')),
|
|
}));
|
|
|
|
// Mock evaluator helpers
|
|
vi.mock('../src/evaluatorHelpers', async () => {
|
|
const actual = await vi.importActual('../src/evaluatorHelpers');
|
|
return {
|
|
...(actual as any),
|
|
runExtensionHook: vi.fn((...args: any[]) => args[2]),
|
|
};
|
|
});
|
|
|
|
// Mock time utilities
|
|
vi.mock('../src/util/time', async () => {
|
|
const actual = await vi.importActual('../src/util/time');
|
|
return {
|
|
...(actual as any),
|
|
sleep: vi.fn(() => Promise.resolve()),
|
|
};
|
|
});
|
|
|
|
// Mock ESM
|
|
vi.mock('../src/esm', () => ({}));
|
|
|
|
describe('Transformation integration', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockTransform.mockReset();
|
|
mockRunAssertions.mockReset();
|
|
mockRunAssertions.mockResolvedValue({
|
|
pass: true,
|
|
score: 1,
|
|
namedScores: {},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('passes provider-transformed output to test transforms and context assertions', async () => {
|
|
// Track the transformation sequence
|
|
const transformCalls: { expression: string; input: any }[] = [];
|
|
|
|
mockTransform.mockImplementation(async (expression, input) => {
|
|
transformCalls.push({ expression, input });
|
|
|
|
if (expression === 'output.toUpperCase()') {
|
|
// Provider transform
|
|
return 'PROVIDER TRANSFORMED';
|
|
} else if (expression === 'output + " - test transformed"') {
|
|
// Test transform - should receive provider output
|
|
return input + ' - test transformed';
|
|
}
|
|
return input;
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
prompts: [{ raw: 'Test prompt', label: 'Test' }],
|
|
providers: [
|
|
{
|
|
id: () => 'mock-provider',
|
|
callApi: async () => ({
|
|
output: 'original output',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
|
|
}),
|
|
transform: 'output.toUpperCase()',
|
|
} as ApiProvider,
|
|
],
|
|
tests: [
|
|
{
|
|
vars: {
|
|
query: 'test query',
|
|
context: 'test context',
|
|
},
|
|
assert: [
|
|
{
|
|
type: 'equals',
|
|
value: 'PROVIDER TRANSFORMED - test transformed',
|
|
},
|
|
{
|
|
type: 'context-faithfulness',
|
|
contextTransform: 'output.split(" ")[0]',
|
|
},
|
|
],
|
|
options: {
|
|
transform: 'output + " - test transformed"',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = new Eval({});
|
|
const results = await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
|
|
|
|
// Check that the test transform received provider output.
|
|
const testTransformCall = transformCalls.find(
|
|
(c) => c.expression === 'output + " - test transformed"',
|
|
);
|
|
|
|
expect(testTransformCall?.input).toBe('PROVIDER TRANSFORMED');
|
|
expect(results.results[0].response?.output).toBe('PROVIDER TRANSFORMED - test transformed');
|
|
|
|
expect(mockRunAssertions).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerResponse: expect.objectContaining({
|
|
output: 'PROVIDER TRANSFORMED - test transformed',
|
|
providerTransformedOutput: 'PROVIDER TRANSFORMED',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('passes provider-transformed output through with multiple context assertions', async () => {
|
|
const transformCalls: { expression: string; input: any; timestamp: number }[] = [];
|
|
|
|
mockTransform.mockImplementation(async (expression, input) => {
|
|
transformCalls.push({ expression, input, timestamp: Date.now() });
|
|
|
|
if (expression === 'JSON.stringify({provider: output})') {
|
|
// Provider transform
|
|
return JSON.stringify({ provider: input });
|
|
} else if (expression === 'JSON.parse(output).provider.toLowerCase()') {
|
|
// Test transform
|
|
const parsed = typeof input === 'string' ? JSON.parse(input) : input;
|
|
return parsed.provider.toLowerCase();
|
|
}
|
|
return input;
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
prompts: [{ raw: 'Test prompt', label: 'Test' }],
|
|
providers: [
|
|
{
|
|
id: () => 'mock-provider',
|
|
callApi: async () => ({
|
|
output: 'ORIGINAL',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
|
|
}),
|
|
transform: 'JSON.stringify({provider: output})',
|
|
} as ApiProvider,
|
|
],
|
|
tests: [
|
|
{
|
|
vars: {
|
|
query: 'test query',
|
|
},
|
|
assert: [
|
|
{
|
|
type: 'context-faithfulness',
|
|
contextTransform: 'JSON.parse(output).context1',
|
|
},
|
|
{
|
|
type: 'context-recall',
|
|
contextTransform: 'JSON.parse(output).context2',
|
|
},
|
|
{
|
|
type: 'context-relevance',
|
|
contextTransform: 'JSON.parse(output).context3',
|
|
},
|
|
],
|
|
options: {
|
|
transform: 'JSON.parse(output).provider.toLowerCase()',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = new Eval({});
|
|
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
|
|
|
|
const testTransform = transformCalls.find((c) => c.expression.includes('toLowerCase'));
|
|
expect(testTransform?.input).toBe('{"provider":"ORIGINAL"}');
|
|
|
|
expect(mockRunAssertions).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerResponse: expect.objectContaining({
|
|
output: 'original',
|
|
providerTransformedOutput: '{"provider":"ORIGINAL"}',
|
|
}),
|
|
test: expect.objectContaining({
|
|
assert: [
|
|
expect.objectContaining({ contextTransform: 'JSON.parse(output).context1' }),
|
|
expect.objectContaining({ contextTransform: 'JSON.parse(output).context2' }),
|
|
expect.objectContaining({ contextTransform: 'JSON.parse(output).context3' }),
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Note: function-transform wiring tests used to live here but were removed
|
|
// in favor of `test/evaluator.integration.realTransforms.test.ts`, which
|
|
// exercises the real `transform()` pipeline end-to-end instead of asserting
|
|
// that a mock was called with a function reference.
|
|
|
|
it('should work correctly with only provider transform', async () => {
|
|
mockTransform.mockImplementation(async (expression, input) => {
|
|
if (expression === 'output.trim().toUpperCase()') {
|
|
return String(input).trim().toUpperCase();
|
|
}
|
|
return input;
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
prompts: [{ raw: 'Test prompt', label: 'Test' }],
|
|
providers: [
|
|
{
|
|
id: () => 'mock-provider',
|
|
callApi: async () => ({
|
|
output: ' spaced output ',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
|
|
}),
|
|
transform: 'output.trim().toUpperCase()',
|
|
} as ApiProvider,
|
|
],
|
|
tests: [
|
|
{
|
|
assert: [
|
|
{
|
|
type: 'equals',
|
|
value: 'SPACED OUTPUT',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = new Eval({});
|
|
const results = await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
|
|
expect(results.results[0].response?.output).toBe('SPACED OUTPUT');
|
|
});
|
|
|
|
it('passes raw provider output to context assertions when no transforms are configured', async () => {
|
|
const testSuite: TestSuite = {
|
|
prompts: [{ raw: 'Test prompt', label: 'Test' }],
|
|
providers: [
|
|
{
|
|
id: () => 'mock-provider',
|
|
callApi: async () => ({
|
|
output: { data: 'test', metadata: { extracted: 'metadata' } },
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
|
|
}),
|
|
} as ApiProvider,
|
|
],
|
|
tests: [
|
|
{
|
|
vars: { query: 'test' },
|
|
assert: [
|
|
{
|
|
type: 'context-faithfulness',
|
|
contextTransform: 'output.metadata',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const evalRecord = new Eval({});
|
|
await evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
|
|
|
|
expect(mockRunAssertions).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerResponse: expect.objectContaining({
|
|
output: {
|
|
data: 'test',
|
|
metadata: { extracted: 'metadata' },
|
|
},
|
|
providerTransformedOutput: {
|
|
data: 'test',
|
|
metadata: { extracted: 'metadata' },
|
|
},
|
|
}),
|
|
test: expect.objectContaining({
|
|
assert: [expect.objectContaining({ contextTransform: 'output.metadata' })],
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|