Files
promptfoo--promptfoo/test/assertions/trace.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

490 lines
14 KiB
TypeScript

import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { assertionUsesTrace, runAssertion, runAssertions } from '../../src/assertions/index';
import cliState from '../../src/cliState';
import { getTraceStore } from '../../src/tracing/store';
import { mockProcessEnv } from '../util/utils';
import type {
Assertion,
AtomicTestCase,
GradingResult,
ProviderResponse,
} from '../../src/types/index';
import type { TraceData } from '../../src/types/tracing';
// Mock the trace store
vi.mock('../../src/tracing/store');
// Mock Python execution
vi.mock('../../src/python/wrapper', () => ({
runPythonCode: vi.fn((code: string, _functionName: string, args: any[]) => {
// Simple Python interpreter mock for our test cases
const [_output, context] = args;
// Handle the specific test cases
if (code.includes("len(context.trace['spans']) == 2")) {
return context.trace && context.trace.spans && context.trace.spans.length === 2;
}
if (code.includes('root_spans') && code.includes('leaf_spans')) {
const trace = context.trace;
if (!trace) {
return false;
}
const rootSpans = trace.spans.filter((s: any) => !s.parentSpanId);
const leafSpans = trace.spans.filter((s: any) => s.parentSpanId);
return rootSpans.length === 1 && leafSpans.length === 1;
}
if (code.includes('avg_duration')) {
const trace = context.trace;
if (!trace) {
return false;
}
const durations = trace.spans
.filter((s: any) => s.endTime)
.map((s: any) => s.endTime - s.startTime);
const avgDuration = durations.reduce((a: number, b: number) => a + b, 0) / durations.length;
return {
pass: true,
score: 0.9,
reason: `Average span duration: ${avgDuration}ms`,
};
}
return false;
}),
}));
describe('trace assertions', () => {
const originalBasePath = cliState.basePath;
const originalTraceFetchEnv = {
PROMPTFOO_TRACE_FETCH_MAX_ATTEMPTS: process.env.PROMPTFOO_TRACE_FETCH_MAX_ATTEMPTS,
PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS: process.env.PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS,
PROMPTFOO_TRACE_FETCH_STABLE_POLLS: process.env.PROMPTFOO_TRACE_FETCH_STABLE_POLLS,
};
const mockTraceStore = {
getTrace: vi.fn(),
};
const restoreTraceFetchEnv = () => {
for (const [name, value] of Object.entries(originalTraceFetchEnv)) {
if (value === undefined) {
mockProcessEnv({ [name]: undefined });
} else {
mockProcessEnv({ [name]: value });
}
}
};
beforeEach(() => {
mockTraceStore.getTrace.mockReset();
vi.clearAllMocks();
restoreTraceFetchEnv();
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS: '0' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_STABLE_POLLS: '1' });
vi.mocked(getTraceStore).mockReturnValue(
mockTraceStore as unknown as ReturnType<typeof getTraceStore>,
);
});
afterEach(() => {
cliState.basePath = originalBasePath;
restoreTraceFetchEnv();
vi.resetAllMocks();
vi.useRealTimers();
});
const mockTest: AtomicTestCase = {
vars: { test: 'value' },
};
const mockProviderResponse: ProviderResponse = {
output: 'Test output',
};
const mockTraceData: TraceData = {
traceId: 'test-trace-id',
evaluationId: 'test-evaluation-id',
testCaseId: 'test-test-case-id',
metadata: { test: 'value' },
spans: [
{
spanId: 'span-1',
name: 'http.request',
startTime: 1000,
endTime: 1500,
attributes: { 'http.method': 'GET' },
statusCode: 200,
},
{
spanId: 'span-2',
parentSpanId: 'span-1',
name: 'api.call',
startTime: 1100,
endTime: 1400,
attributes: { 'api.name': 'test-api' },
},
],
};
describe('javascript assertions with trace', () => {
it('should treat ruby assertions as trace-aware', () => {
expect(
assertionUsesTrace({
type: 'ruby',
value: 'context.trace && context.trace.spans.length > 0',
}),
).toBe(true);
});
it('should pass trace data to javascript assertion', async () => {
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
const assertion: Assertion = {
type: 'javascript',
value: `
if (!context.trace) return false;
return context.trace.spans.length === 2 &&
context.trace.traceId === 'test-trace-id';
`,
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).toHaveBeenCalledWith('test-trace-id', {
sanitizeAttributes: false,
});
});
it('should retry until trace spans are available', async () => {
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_MAX_ATTEMPTS: '3' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS: '0' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_STABLE_POLLS: '1' });
mockTraceStore.getTrace
.mockResolvedValueOnce({
...mockTraceData,
spans: [],
})
.mockResolvedValueOnce(mockTraceData);
const assertion: Assertion = {
type: 'javascript',
value: 'context.trace?.spans?.length === 2',
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).toHaveBeenCalledTimes(2);
});
it('should use default retry timing when trace fetch env vars are unset', async () => {
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS: undefined });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_STABLE_POLLS: undefined });
vi.useFakeTimers();
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
const resultPromise = runAssertion({
assertion: {
type: 'javascript',
value: 'context.trace?.spans?.length === 2',
},
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
await vi.advanceTimersByTimeAsync(250);
const result = await resultPromise;
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).toHaveBeenCalledTimes(2);
});
it('should wait for span count to stabilize', async () => {
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_MAX_ATTEMPTS: '4' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS: '0' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_STABLE_POLLS: '2' });
mockTraceStore.getTrace
.mockResolvedValueOnce({
...mockTraceData,
spans: [mockTraceData.spans[0]],
})
.mockResolvedValueOnce(mockTraceData)
.mockResolvedValueOnce(mockTraceData);
const assertion: Assertion = {
type: 'javascript',
value: 'context.trace?.spans?.length === 2',
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).toHaveBeenCalledTimes(3);
});
it('should handle missing trace gracefully', async () => {
mockTraceStore.getTrace.mockResolvedValue(null);
const assertion: Assertion = {
type: 'javascript',
value: 'context.trace === undefined',
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'non-existent-trace',
});
expect(result.pass).toBe(true);
});
it('should reuse a preloaded missing trace instead of retrying once per assertion', async () => {
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_MAX_ATTEMPTS: '2' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_RETRY_DELAY_MS: '0' });
mockProcessEnv({ PROMPTFOO_TRACE_FETCH_STABLE_POLLS: '1' });
mockTraceStore.getTrace.mockResolvedValue(null);
const result = await runAssertions({
test: {
...mockTest,
assert: [
{
type: 'javascript',
value: 'context.trace === undefined',
},
{
type: 'javascript',
value: 'context.trace === undefined',
},
],
},
providerResponse: mockProviderResponse,
traceId: 'non-existent-trace',
});
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).toHaveBeenCalledTimes(2);
});
it('should pass trace data to file:// scripts for non-trace assertion types', async () => {
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
cliState.basePath = path.resolve(__dirname, '../fixtures/file-script-assertions');
const result: GradingResult = await runAssertion({
assertion: {
type: 'equals',
value: 'file://rubric-generator.cjs:traceSpanName',
},
test: mockTest,
providerResponse: {
...mockProviderResponse,
output: 'http.request',
},
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
});
it('should calculate trace duration correctly', async () => {
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
const assertion: Assertion = {
type: 'javascript',
value: `
const duration = Math.max(...context.trace.spans.map(s => s.endTime || 0)) -
Math.min(...context.trace.spans.map(s => s.startTime));
return duration === 500; // 1500 - 1000
`,
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
});
it('should detect error spans', async () => {
const traceWithError: TraceData = {
traceId: 'error-trace',
evaluationId: 'test-evaluation-id',
testCaseId: 'test-test-case-id',
metadata: { test: 'value' },
spans: [
...mockTraceData.spans,
{
spanId: 'error-span',
name: 'failed.request',
startTime: 2000,
endTime: 2100,
statusCode: 500,
statusMessage: 'Internal Server Error',
},
],
};
mockTraceStore.getTrace.mockResolvedValue(traceWithError);
const assertion: Assertion = {
type: 'javascript',
value: `
const errorSpans = context.trace.spans.filter(s => s.statusCode >= 400);
return errorSpans.length === 1 && errorSpans[0].statusCode === 500;
`,
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'error-trace',
});
expect(result.pass).toBe(true);
});
it('should work without traceId', async () => {
const assertion: Assertion = {
type: 'javascript',
value: 'context.trace === undefined && output === "Test output"',
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
// No traceId provided
});
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).not.toHaveBeenCalled();
});
});
describe('python assertions with trace', () => {
it('should pass trace data to python assertion', async () => {
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
const assertion: Assertion = {
type: 'python',
value: `
if not hasattr(context, 'trace') or context.trace is None:
return False
return len(context.trace['spans']) == 2
`,
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
});
it('should analyze span hierarchy in python', async () => {
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
const assertion: Assertion = {
type: 'python',
value: `
root_spans = [s for s in context.trace['spans'] if not s.get('parentSpanId')]
leaf_spans = [s for s in context.trace['spans'] if s.get('parentSpanId')]
return len(root_spans) == 1 and len(leaf_spans) == 1
`,
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
});
it('should return grading result object from python', async () => {
mockTraceStore.getTrace.mockResolvedValue(mockTraceData);
const assertion: Assertion = {
type: 'python',
value: `
avg_duration = sum(s['endTime'] - s['startTime'] for s in context.trace['spans'] if s.get('endTime')) / len(context.trace['spans'])
return {
'pass': True,
'score': 0.9,
'reason': f"Average span duration: {avg_duration}ms"
}
`,
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
expect(result.score).toBe(0.9);
expect(result.reason).toContain('Average span duration');
});
});
describe('trace store error handling', () => {
it('should handle trace store errors gracefully', async () => {
mockTraceStore.getTrace.mockRejectedValue(new Error('Database error'));
const assertion: Assertion = {
type: 'javascript',
value: 'context.trace === undefined',
};
const result: GradingResult = await runAssertion({
assertion,
test: mockTest,
providerResponse: mockProviderResponse,
traceId: 'test-trace-id',
});
expect(result.pass).toBe(true);
expect(mockTraceStore.getTrace).toHaveBeenCalledWith('test-trace-id', {
sanitizeAttributes: false,
});
});
});
});