Files
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

1891 lines
64 KiB
TypeScript

import * as fsPromises from 'fs/promises';
import * as path from 'path';
import { XMLParser } from 'fast-xml-parser';
import * as yaml from 'js-yaml';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getDb } from '../../src/database/index';
import * as googleSheets from '../../src/googleSheets';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import { getTraceStore } from '../../src/tracing/store';
import { type EvaluateResult, ResultFailureReason } from '../../src/types/index';
import { createJunitXml } from '../../src/util/junit';
import {
createOutputMetadata,
warnOnDegradedJsonlRecovery,
writeMultipleOutputs,
writeOutput,
} from '../../src/util/output';
import { mockConsole, mockProcessEnv } from './utils';
vi.mock('../../src/database', () => ({
getDb: vi.fn(),
}));
vi.mock('proxy-agent', () => ({
ProxyAgent: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
statSync: vi.fn(),
readdirSync: vi.fn(),
existsSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
const mockFileHandle = vi.hoisted(() => ({
write: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
}));
const JSONL_TEMP_DIRECTORY = '/tmp/promptfoo-jsonl-test';
const JSONL_BACKUP_PATH = path.join(JSONL_TEMP_DIRECTORY, 'backup.jsonl');
const JSONL_REPLACEMENT_PATH = path.join(JSONL_TEMP_DIRECTORY, 'replacement.jsonl');
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
appendFile: vi.fn(),
copyFile: vi.fn(),
chmod: vi.fn(),
lstat: vi.fn(),
mkdtemp: vi.fn(),
stat: vi.fn(),
realpath: vi.fn(),
readlink: vi.fn(),
rename: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn(),
open: vi.fn().mockResolvedValue(mockFileHandle),
}));
vi.mock('../../src/googleSheets', () => ({
writeCsvToGoogleSheet: vi.fn(),
}));
describe('writeOutput', () => {
let consoleLogSpy: ReturnType<typeof mockConsole>;
beforeEach(() => {
const fileNotFoundError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.clearAllMocks();
// Restore mock implementations that vi.resetAllMocks() clears
mockFileHandle.write.mockResolvedValue(undefined);
mockFileHandle.close.mockResolvedValue(undefined);
vi.mocked(fsPromises.open).mockResolvedValue(
mockFileHandle as unknown as fsPromises.FileHandle,
);
vi.mocked(fsPromises.rename).mockResolvedValue(undefined);
vi.mocked(fsPromises.rm).mockResolvedValue(undefined);
vi.mocked(fsPromises.copyFile).mockResolvedValue(undefined);
vi.mocked(fsPromises.mkdtemp).mockResolvedValue(JSONL_TEMP_DIRECTORY);
vi.mocked(fsPromises.lstat).mockRejectedValue(fileNotFoundError);
vi.mocked(fsPromises.stat).mockRejectedValue(fileNotFoundError);
consoleLogSpy = mockConsole('log');
// @ts-expect-error getDb is mocked with a partial test double.
vi.mocked(getDb).mockResolvedValue({
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
}),
}),
});
});
afterEach(() => {
consoleLogSpy.mockRestore();
vi.resetAllMocks();
});
it('writeOutput with CSV output', async () => {
// @ts-expect-error getDb is mocked with a partial test double.
vi.mocked(getDb).mockResolvedValue({
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue([]) }),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
}),
}),
});
const outputPath = 'output.csv';
const results: EvaluateResult[] = [
{
success: true,
failureReason: ResultFailureReason.NONE,
score: 1.0,
namedScores: {},
latencyMs: 1000,
provider: {
id: 'foo',
},
prompt: {
raw: 'Test prompt',
label: '[display] Test prompt',
},
response: {
output: 'Test output',
},
vars: {
var1: 'value1',
var2: 'value2',
},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'foo',
},
];
const eval_ = new Eval({});
await eval_.addResult(results[0]);
const shareableUrl = null;
await writeOutput(outputPath, eval_, shareableUrl);
// CSV uses file handle streaming for memory-efficient export
// Headers are always written even when database returns no results
expect(fsPromises.open).toHaveBeenCalledWith(outputPath, 'w');
expect(mockFileHandle.write).toHaveBeenCalled(); // Headers written
expect(mockFileHandle.close).toHaveBeenCalled();
});
it('writeOutput with JSON output', async () => {
const outputPath = 'output.json';
const eval_ = new Eval({});
await writeOutput(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1);
});
it('redacts env and secret config fields in JSON output', async () => {
const outputPath = 'output.json';
const eval_ = new Eval({
description: 'Test config',
tests: 'az://account/container/tests.yaml?sp=r&sig=azure-secret',
env: {
AWS_BEARER_TOKEN_BEDROCK: 'bedrock-secret-token',
ANTHROPIC_API_KEY: 'anthropic-secret-token',
REGION: 'us-east-1',
},
providers: [
{
id: 'anthropic:claude-agent-sdk',
config: {
apiKey: 'sk-secret-value',
max_turns: 2,
},
},
],
});
await writeOutput(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1);
const outputJson = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = JSON.parse(outputJson);
expect(parsed.config.env.AWS_BEARER_TOKEN_BEDROCK).toBe('[REDACTED]');
expect(parsed.config.env.ANTHROPIC_API_KEY).toBe('[REDACTED]');
expect(parsed.config.env.REGION).toBe('us-east-1');
expect(parsed.config.providers[0].config.apiKey).toBe('[REDACTED]');
expect(parsed.config.providers[0].config.max_turns).toBe(2);
expect(parsed.config.description).toBe('Test config');
expect(parsed.config.tests).toBe('az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D');
});
it.each([
{
extension: 'json',
parse: (value: string) => JSON.parse(value),
},
{
extension: 'yaml',
parse: (value: string) => yaml.load(value) as Record<string, any>,
},
])('omits nested response metadata from $extension output when metadata stripping is enabled', async ({
extension,
parse,
}) => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_STRIP_METADATA: 'true' });
try {
const eval_ = new Eval({});
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: {
raw: 'Test prompt',
label: 'Test prompt',
},
response: {
output: 'Test output',
metadata: {
transformedRequest: {
headers: {
Authorization: 'Bearer nested-secret',
},
},
},
},
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'prompt',
metadata: {
debug: 'top-level-secret',
},
});
await writeOutput(`output.${extension}`, eval_, null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = parse(written);
const result = parsed.results.results[0];
expect(result.metadata).toEqual({});
expect(result.response.metadata).toBeUndefined();
expect(written).not.toContain('nested-secret');
} finally {
restoreEnv();
}
});
it.each([
{
extension: 'json',
parse: (value: string) => JSON.parse(value),
},
{
extension: 'yaml',
parse: (value: string) => yaml.load(value) as Record<string, any>,
},
])('omits test-case metadata from $extension output when metadata stripping is enabled', async ({
extension,
parse,
}) => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_STRIP_METADATA: 'true',
PROMPTFOO_STRIP_TEST_VARS: 'true',
});
try {
const eval_ = new Eval({});
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: {
raw: 'Test prompt',
label: 'Test prompt',
},
response: {
output: 'Test output',
},
vars: {
customerEmail: 'secret@example.com',
},
promptIdx: 0,
testIdx: 0,
testCase: {
vars: {
customerEmail: 'secret@example.com',
},
metadata: {
goal: 'goal testcase-secret',
pluginConfig: {
policy: 'policy testcase-secret',
},
inputMaterialization: {
source: 'source testcase-secret',
},
},
},
promptId: 'prompt',
metadata: {
debug: 'top-level-secret',
},
});
await writeOutput(`output.${extension}`, eval_, null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = parse(written);
const result = parsed.results.results[0];
expect(result.metadata).toEqual({});
expect(result.testCase.metadata).toBeUndefined();
expect(result.testCase.vars).toBeUndefined();
expect(written).not.toContain('testcase-secret');
} finally {
restoreEnv();
}
});
it('omits trace vars from JSON output when test variable stripping is enabled', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_STRIP_TEST_VARS: 'true' });
const traceSpy = vi.spyOn(getTraceStore(), 'getTracesByEvaluation').mockResolvedValue([
{
traceId: 'trace-strip-vars',
evaluationId: 'eval-strip-vars',
testCaseId: 'case-strip-vars',
metadata: {
testIdx: 0,
promptIdx: 0,
source: 'trace export',
vars: {
customerEmail: 'trace-var-secret@example.com',
},
},
spans: [],
},
]);
try {
await writeOutput('output.json', new Eval({}), null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = JSON.parse(written);
expect(parsed.traces[0].metadata).toEqual({
testIdx: 0,
promptIdx: 0,
source: 'trace export',
});
expect(written).not.toContain('trace-var-secret@example.com');
} finally {
traceSpy.mockRestore();
restoreEnv();
}
});
it('omits trace metadata when stripped vars are the only metadata', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_STRIP_TEST_VARS: 'true' });
const traceSpy = vi.spyOn(getTraceStore(), 'getTracesByEvaluation').mockResolvedValue([
{
traceId: 'trace-strip-only-vars',
evaluationId: 'eval-strip-only-vars',
testCaseId: 'case-strip-only-vars',
metadata: {
vars: {
customerEmail: 'trace-only-var-secret@example.com',
},
},
spans: [],
},
]);
try {
await writeOutput('output.json', new Eval({}), null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = JSON.parse(written);
expect(parsed.traces[0]).not.toHaveProperty('metadata');
expect(written).not.toContain('trace-only-var-secret@example.com');
} finally {
traceSpy.mockRestore();
restoreEnv();
}
});
it('omits trace metadata from JSON output when metadata stripping is enabled', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_STRIP_METADATA: 'true' });
const traceSpy = vi.spyOn(getTraceStore(), 'getTracesByEvaluation').mockResolvedValue([
{
traceId: 'trace-strip-metadata',
evaluationId: 'eval-strip-metadata',
testCaseId: 'case-strip-metadata',
metadata: {
note: 'trace-metadata-secret',
vars: {
customerEmail: 'trace-var-secret@example.com',
},
},
spans: [],
},
]);
try {
await writeOutput('output.json', new Eval({}), null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = JSON.parse(written);
expect(parsed.traces[0]).not.toHaveProperty('metadata');
expect(written).not.toContain('trace-metadata-secret');
expect(written).not.toContain('trace-var-secret@example.com');
} finally {
traceSpy.mockRestore();
restoreEnv();
}
});
it('omits stripped prompt and response bodies from trace span attributes', async () => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_STRIP_PROMPT_TEXT: 'true',
PROMPTFOO_STRIP_RESPONSE_OUTPUT: 'true',
});
const traceSpy = vi.spyOn(getTraceStore(), 'getTracesByEvaluation').mockResolvedValue([
{
traceId: 'trace-strip-bodies',
evaluationId: 'eval-strip-bodies',
testCaseId: 'case-strip-bodies',
spans: [
{
spanId: 'span-strip-bodies',
name: 'provider',
startTime: 1,
attributes: {
'promptfoo.request.body': 'trace-prompt-secret',
'promptfoo.response.body': 'trace-response-secret',
operation: 'provider-call',
},
},
],
},
]);
try {
await writeOutput('output.json', new Eval({}), null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = JSON.parse(written);
expect(parsed.traces[0].spans[0].attributes).toEqual({ operation: 'provider-call' });
expect(written).not.toContain('trace-prompt-secret');
expect(written).not.toContain('trace-response-secret');
} finally {
traceSpy.mockRestore();
restoreEnv();
}
});
it('preserves deep non-secret config fields in JSON output', async () => {
const outputPath = 'output.json';
const eval_ = new Eval({
defaultTest: {
options: {
deepConfig: {
l1: {
l2: {
l3: {
l4: {
l5: {
l6: {
value: 'deep-public-value',
apiKey: 'sk-secret-value',
},
},
},
},
},
},
},
},
},
});
await writeOutput(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1);
const outputJson = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = JSON.parse(outputJson);
expect(parsed.config.defaultTest.options.deepConfig.l1.l2.l3.l4.l5.l6.value).toBe(
'deep-public-value',
);
expect(parsed.config.defaultTest.options.deepConfig.l1.l2.l3.l4.l5.l6.apiKey).toBe(
'[REDACTED]',
);
});
it('writeOutput with YAML output', async () => {
const outputPath = 'output.yaml';
const eval_ = new Eval({});
await writeOutput(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1);
});
it.each([
'yaml',
'txt',
])('sanitizes runtime options before writing %s output for in-memory evals', async (extension) => {
const eval_ = new Eval(
{},
{
runtimeOptions: {
cache: false,
abortSignal: new AbortController().signal,
progressCallback: vi.fn(),
},
},
);
await writeOutput(`output.${extension}`, eval_, null);
const written = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = yaml.load(written) as { runtimeOptions: Record<string, unknown> };
expect(parsed.runtimeOptions).toEqual({ cache: false });
});
it('redacts env and secret config fields in YAML output', async () => {
const outputPath = 'output.yaml';
const eval_ = new Eval({
env: {
AWS_BEARER_TOKEN_BEDROCK: 'bedrock-token',
ANTHROPIC_API_KEY: 'anthropic-token',
AWS_REGION: 'us-east-1',
},
defaultTest: {
options: {
apiKey: 'another-secret',
temperature: 0.1,
},
},
});
await writeOutput(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1);
const outputYaml = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const parsed = yaml.load(outputYaml) as Record<string, any>;
expect(parsed.config.env.AWS_BEARER_TOKEN_BEDROCK).toBe('[REDACTED]');
expect(parsed.config.env.ANTHROPIC_API_KEY).toBe('[REDACTED]');
expect(parsed.config.env.AWS_REGION).toBe('us-east-1');
expect(parsed.config.defaultTest.options.apiKey).toBe('[REDACTED]');
expect(parsed.config.defaultTest.options.temperature).toBe(0.1);
});
it('writeOutput with XML output', async () => {
const outputPath = 'output.xml';
const eval_ = new Eval({});
await writeOutput(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1);
});
it('writes compact JUnit XML grouped by provider and prompt', async () => {
const eval_ = new Eval({});
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 1250,
provider: { id: 'echo' },
prompt: { raw: 'Hello {{name}}', label: 'Hello prompt' },
response: { output: 'Hello Alice' },
vars: { name: 'Alice' },
promptIdx: 0,
testIdx: 0,
testCase: { description: 'passing test' },
promptId: 'prompt-1',
});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ASSERT,
score: 0.5,
namedScores: {},
latencyMs: 2500,
provider: { id: 'echo' },
prompt: { raw: 'Hello {{name}}', label: 'Hello prompt' },
response: { output: 'SECRET MODEL OUTPUT' },
vars: { name: 'Bob' },
promptIdx: 0,
testIdx: 1,
testCase: { description: 'failing test' },
gradingResult: {
pass: false,
score: 0.5,
reason: 'Expected output to contain Bob',
componentResults: [
{
pass: true,
score: 1,
reason: 'Assertion passed',
assertion: { type: 'contains', value: 'Hello' },
},
{
pass: false,
score: 0,
reason: 'Expected output to contain Bob',
assertion: { type: 'contains', value: 'Bob' },
},
],
},
promptId: 'prompt-1',
});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ERROR,
score: 0,
namedScores: {},
latencyMs: 500,
provider: { id: 'echo' },
prompt: { raw: 'Hello {{name}}', label: 'Hello prompt' },
response: { output: '' },
error: 'provider request failed',
vars: { name: 'Carol' },
promptIdx: 0,
testIdx: 2,
testCase: {},
promptId: 'prompt-1',
});
await writeOutput('promptfoo.junit.xml', eval_, null);
const xml = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites).toMatchObject({
'@_errors': '1',
'@_failures': '1',
'@_name': 'promptfoo',
'@_skipped': '0',
'@_tests': '3',
'@_time': '4.25',
});
expect(parsed.testsuites.testsuite).toMatchObject({
'@_errors': '1',
'@_failures': '1',
'@_name': '[echo] prompt 1',
'@_skipped': '0',
'@_tests': '3',
'@_time': '4.25',
});
expect(parsed.testsuites.testsuite.testcase).toEqual([
expect.objectContaining({
'@_classname': '[echo] prompt 1',
'@_name': 'test 1: passing test',
'@_time': '1.25',
}),
expect.objectContaining({
'@_classname': '[echo] prompt 1',
'@_name': 'test 2: failing test',
'@_time': '2.5',
failure: expect.objectContaining({
'#text': 'Score: 0.5\nReason: Assertion failed\nFailed assertions:\n- contains',
'@_message': 'Assertion failed',
'@_type': 'assertion',
}),
}),
expect.objectContaining({
'@_classname': '[echo] prompt 1',
'@_name': 'test 3',
'@_time': '0.5',
error: expect.objectContaining({
'#text': 'Reason: Evaluation error',
'@_message': 'Evaluation error',
'@_type': 'error',
}),
}),
]);
expect(xml).not.toContain('SECRET MODEL OUTPUT');
expect(xml).not.toContain('Alice');
expect(xml).not.toContain('Carol');
});
it('keeps Promptfoo XML separate from JUnit XML', async () => {
const eval_ = new Eval({});
await writeOutput('output.xml', eval_, null);
await writeOutput('output.junit.xml', eval_, null);
const promptfooXml = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
const junitXml = vi.mocked(fsPromises.writeFile).mock.calls[1][1] as string;
expect(promptfooXml).toContain('<promptfoo>');
expect(junitXml).toContain('<testsuites');
});
it('serializes legacy eval results into JUnit XML', async () => {
const legacyResult: EvaluateResult = {
success: false,
failureReason: ResultFailureReason.ASSERT,
score: 0,
namedScores: {},
latencyMs: 0,
provider: { label: 'legacy provider' },
prompt: { raw: 'Legacy raw prompt', label: '' },
response: { output: '' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'legacy-prompt',
};
const eval_ = {
createdAt: 'not-a-date',
fetchResultsBatched: vi.fn(),
getResults: vi.fn().mockResolvedValue([legacyResult]),
persisted: false,
results: [],
useOldResults: () => true,
} as unknown as Eval;
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(eval_.getResults).toHaveBeenCalledTimes(1);
expect(eval_.fetchResultsBatched).not.toHaveBeenCalled();
expect(parsed.testsuites.testsuite).toMatchObject({
'@_name': '[legacy provider] prompt 1',
});
expect(parsed.testsuites.testsuite).not.toHaveProperty('@_timestamp');
expect(parsed.testsuites.testsuite.testcase.failure).toMatchObject({
'#text': 'Score: 0\nReason: Assertion failed',
'@_message': 'Assertion failed',
});
});
it('keeps legacy prompts without ids in separate suites', async () => {
const makeLegacyResult = (promptIdx: number, raw: string): EvaluateResult => ({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 0,
provider: { id: 'echo' },
prompt: { raw, label: '' },
response: { output: '' },
vars: {},
promptIdx,
testIdx: 0,
testCase: {},
promptId: '',
});
const eval_ = {
createdAt: '2026-05-03T15:00:00.000Z',
fetchResultsBatched: vi.fn(),
getResults: vi
.fn()
.mockResolvedValue([makeLegacyResult(0, 'Prompt A'), makeLegacyResult(1, 'Prompt B')]),
persisted: false,
results: [],
useOldResults: () => true,
} as unknown as Eval;
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite).toEqual([
expect.objectContaining({ '@_name': '[echo] prompt 1' }),
expect.objectContaining({ '@_name': '[echo] prompt 2' }),
]);
});
it('does not use prompt text or labels as JUnit suite names', async () => {
const eval_ = new Eval({});
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 0,
provider: { id: 'echo' },
prompt: { raw: 'Sensitive system prompt', label: 'Sensitive label' },
response: { output: '' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'sensitive-prompt',
});
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite).toMatchObject({
'@_name': '[echo] prompt 1',
});
expect(xml).not.toContain('Sensitive system prompt');
expect(xml).not.toContain('Sensitive label');
});
it('serializes persisted batched eval results into JUnit XML', async () => {
const persistedResult: EvaluateResult = {
success: false,
failureReason: ResultFailureReason.ERROR,
score: 0,
namedScores: {},
latencyMs: Number.NaN,
provider: { id: '', label: '' },
prompt: { raw: '', label: '' },
response: { output: '' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
gradingResult: {
pass: false,
score: 0,
reason: 'grader unavailable',
},
promptId: 'persisted-prompt',
};
const eval_ = {
createdAt: '2026-05-03T15:00:00.000Z',
async *fetchResultsBatched() {
yield [persistedResult];
},
getResults: vi.fn(),
persisted: true,
results: [],
useOldResults: () => false,
} as unknown as Eval;
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(eval_.getResults).not.toHaveBeenCalled();
expect(parsed.testsuites.testsuite).toMatchObject({
'@_name': '[unknown provider] prompt 1',
'@_time': '0',
'@_timestamp': '2026-05-03T15:00:00.000Z',
});
expect(parsed.testsuites.testsuite.testcase.error).toMatchObject({
'#text': 'Reason: Evaluation error',
'@_message': 'Evaluation error',
});
});
it('keeps JUnit failure messages compact when raw assertion reasons are long', async () => {
const longReason = 'x'.repeat(600);
const eval_ = new Eval({});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ASSERT,
score: 0,
namedScores: {},
latencyMs: 0,
provider: { id: 'echo' },
prompt: { raw: 'Prompt', label: 'Prompt' },
response: { output: '' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
gradingResult: {
pass: false,
score: 0,
reason: longReason,
},
promptId: 'long-message',
});
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite.testcase.failure['@_message']).toBe('Assertion failed');
expect(xml).not.toContain(longReason);
});
it('separates JUnit suites for providers that share an id but differ by label', async () => {
const eval_ = new Eval({});
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 100,
provider: { id: 'echo', label: 'target-A' },
prompt: { raw: 'Greet {{name}}', label: 'Greet prompt' },
response: { output: '' },
vars: { name: 'Alice' },
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'prompt-shared',
});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ASSERT,
score: 0,
namedScores: {},
latencyMs: 200,
provider: { id: 'echo', label: 'target-B' },
prompt: { raw: 'Greet {{name}}', label: 'Greet prompt' },
response: { output: '' },
vars: { name: 'Alice' },
promptIdx: 1,
testIdx: 0,
testCase: {},
gradingResult: { pass: false, score: 0, reason: 'B failed' },
promptId: 'prompt-shared',
});
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite).toEqual([
expect.objectContaining({ '@_name': '[target-A] prompt 1', '@_tests': '1' }),
expect.objectContaining({
'@_name': '[target-B] prompt 1',
'@_tests': '1',
'@_failures': '1',
}),
]);
});
it('keeps JUnit testcase classnames consistent within a suite when promptIdx varies', async () => {
const baseResult = (promptIdx: number, testIdx: number, promptId: string): EvaluateResult => ({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 100,
provider: { id: 'echo' },
prompt: { raw: `prompt ${promptId}`, label: '' },
response: { output: '' },
vars: {},
promptIdx,
testIdx,
testCase: {},
promptId,
});
const eval_ = {
createdAt: '2026-05-03T15:00:00.000Z',
fetchResultsBatched: vi.fn(),
// Same promptId across non-contiguous promptIdx values would previously
// produce mixed classnames inside a single suite.
getResults: vi
.fn()
.mockResolvedValue([baseResult(0, 0, 'shared'), baseResult(2, 1, 'shared')]),
persisted: false,
results: [],
useOldResults: () => true,
} as unknown as Eval;
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite).toMatchObject({
'@_name': '[echo] prompt 1',
'@_tests': '2',
});
for (const testcase of parsed.testsuites.testsuite.testcase) {
expect(testcase['@_classname']).toBe('[echo] prompt 1');
}
});
it('omits raw JUnit error text when it matches the inline reason', async () => {
const eval_ = new Eval({});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ERROR,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'echo' },
prompt: { raw: 'p', label: '' },
response: { output: '' },
error: 'request failed: 500',
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'collapsed-error',
});
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite.testcase.error).toMatchObject({
'#text': 'Reason: Evaluation error',
'@_message': 'Evaluation error',
});
expect(xml).not.toContain('request failed: 500');
});
it('omits multiline raw JUnit error payloads', async () => {
const eval_ = new Eval({});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ERROR,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'echo' },
prompt: { raw: 'p', label: '' },
response: { output: '' },
error: 'API error: 401\n{"code":"invalid_api_key"}',
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'multiline-error',
});
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite.testcase.error).toMatchObject({
'#text': 'Reason: Evaluation error',
'@_message': 'Evaluation error',
});
expect(xml).not.toContain('invalid_api_key');
});
it('omits raw model outputs from JUnit assertion details', async () => {
const eval_ = new Eval({});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ASSERT,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'echo' },
prompt: { raw: 'p', label: '' },
response: { output: 'TOP_SECRET_OUTPUT_42' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
gradingResult: {
pass: false,
score: 0,
reason: 'Expected output "TOP_SECRET_OUTPUT_42" to equal "safe"',
componentResults: [
{
pass: false,
score: 0,
reason: 'Expected output "TOP_SECRET_OUTPUT_42" to equal "safe"',
assertion: { type: 'equals', value: 'safe' },
},
],
},
promptId: 'output-leak',
});
const xml = await createJunitXml(eval_);
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
expect(parsed.testsuites.testsuite.testcase.failure).toMatchObject({
'#text': 'Score: 0\nReason: Assertion failed\nFailed assertions:\n- equals',
'@_message': 'Assertion failed',
});
expect(xml).not.toContain('TOP_SECRET_OUTPUT_42');
});
it('does not sanitize config for CSV output', async () => {
const outputPath = 'output.csv';
// @ts-expect-error getDb is mocked with a partial test double.
vi.mocked(getDb).mockResolvedValue({
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue([]) }),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
}),
}),
});
const config: Record<string, unknown> = {};
Object.defineProperty(config, 'bad', {
enumerable: true,
get() {
throw new Error('getter boom');
},
});
const eval_ = new Eval(config);
await expect(writeOutput(outputPath, eval_, null)).resolves.toBeUndefined();
expect(fsPromises.open).toHaveBeenCalledWith(outputPath, 'w');
});
it('does not sanitize config for JSONL output', async () => {
const outputPath = 'output.jsonl';
// @ts-expect-error getDb is mocked with a partial test double.
vi.mocked(getDb).mockResolvedValue({
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue([]) }),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
}),
}),
});
const config: Record<string, unknown> = {};
Object.defineProperty(config, 'bad', {
enumerable: true,
get() {
throw new Error('getter boom');
},
});
const eval_ = new Eval(config);
await expect(writeOutput(outputPath, eval_, null)).resolves.toBeUndefined();
expect(fsPromises.writeFile).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), '');
expect(fsPromises.rename).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), outputPath);
});
it('rewrites JSONL through an external backup when the output directory is not writable', async () => {
const permissionError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
vi.mocked(fsPromises.writeFile)
.mockRejectedValueOnce(permissionError)
.mockResolvedValueOnce(undefined);
const outputPath = 'output.jsonl';
await writeOutput(outputPath, new Eval({}), null);
expect(fsPromises.writeFile).toHaveBeenNthCalledWith(1, expect.stringMatching(/\.tmp$/), '');
expect(fsPromises.copyFile).toHaveBeenNthCalledWith(1, outputPath, JSONL_BACKUP_PATH);
expect(fsPromises.writeFile).toHaveBeenNthCalledWith(2, JSONL_REPLACEMENT_PATH, '');
expect(fsPromises.copyFile).toHaveBeenNthCalledWith(2, JSONL_REPLACEMENT_PATH, outputPath);
expect(fsPromises.rename).not.toHaveBeenCalled();
});
it('warns when the temporary JSONL backup directory cannot be removed', async () => {
const permissionError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
vi.mocked(fsPromises.writeFile)
.mockRejectedValueOnce(permissionError)
.mockResolvedValueOnce(undefined);
vi.mocked(fsPromises.rm).mockRejectedValueOnce(new Error('rm failed'));
await writeOutput('output.jsonl', new Eval({}), null);
expect(warnSpy).toHaveBeenCalledWith(
'[Output] Failed to remove temporary JSONL backup directory',
expect.objectContaining({
tempDirectory: JSONL_TEMP_DIRECTORY,
}),
);
warnSpy.mockRestore();
});
it('rewrites JSONL through an external backup when replacing the output file is not permitted', async () => {
const permissionError = Object.assign(new Error('EPERM'), { code: 'EPERM' });
vi.mocked(fsPromises.rename).mockRejectedValueOnce(permissionError);
const outputPath = 'output.jsonl';
const eval_ = new Eval({});
const fetchResultsBatchedSpy = vi.spyOn(eval_, 'fetchResultsBatched');
await writeOutput(outputPath, eval_, null);
expect(fsPromises.rename).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), outputPath);
expect(fsPromises.rm).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), { force: true });
expect(fetchResultsBatchedSpy).toHaveBeenCalledTimes(1);
expect(fsPromises.copyFile).toHaveBeenNthCalledWith(1, outputPath, JSONL_BACKUP_PATH);
expect(fsPromises.copyFile).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/\.tmp$/),
JSONL_REPLACEMENT_PATH,
);
expect(fsPromises.copyFile).toHaveBeenNthCalledWith(3, JSONL_REPLACEMENT_PATH, outputPath);
});
it('warns when a populated JSONL sidecar cannot be removed after an external rewrite', async () => {
const permissionError = Object.assign(new Error('EPERM'), { code: 'EPERM' });
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
vi.mocked(fsPromises.rename).mockRejectedValueOnce(permissionError);
vi.mocked(fsPromises.rm)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('rm failed'));
await writeOutput('output.jsonl', new Eval({}), null);
expect(warnSpy).toHaveBeenCalledWith(
'[Output] Failed to remove temporary JSONL output',
expect.objectContaining({
tempOutputPath: expect.stringMatching(/\.tmp$/),
}),
);
warnSpy.mockRestore();
});
it('rewrites the target of a dangling JSONL symlink', async () => {
const fileNotFoundError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
const outputPath = path.join('artifacts', 'output.jsonl');
const symlinkTarget = path.join('nested', 'missing.jsonl');
const resolvedTarget = path.resolve(path.dirname(outputPath), symlinkTarget);
vi.mocked(fsPromises.lstat).mockResolvedValueOnce({
isSymbolicLink: () => true,
} as never);
vi.mocked(fsPromises.realpath).mockRejectedValueOnce(fileNotFoundError);
vi.mocked(fsPromises.readlink).mockResolvedValueOnce(symlinkTarget);
await writeOutput(outputPath, new Eval({}), null);
expect(fsPromises.rename).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), resolvedTarget);
});
it('creates the resolved target directory when rewriting a dangling JSONL symlink', async () => {
const fileNotFoundError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
const outputPath = path.join('artifacts', 'output.jsonl');
const symlinkTarget = path.join('nested', 'missing.jsonl');
const resolvedTarget = path.resolve(path.dirname(outputPath), symlinkTarget);
vi.mocked(fsPromises.lstat).mockResolvedValueOnce({
isSymbolicLink: () => true,
} as never);
vi.mocked(fsPromises.realpath).mockRejectedValueOnce(fileNotFoundError);
vi.mocked(fsPromises.readlink).mockResolvedValueOnce(symlinkTarget);
await writeOutput(outputPath, new Eval({}), null);
// The link's resolved target may live in a directory that does not exist yet; it must be
// created so the sibling temp-file rewrite can land next to it (otherwise the temp-file
// write fails with ENOENT instead of preserving the symlink).
expect(fsPromises.mkdir).toHaveBeenCalledWith(path.dirname(resolvedTarget), {
recursive: true,
});
expect(fsPromises.rename).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), resolvedTarget);
});
it('surfaces the permission error when a first-time JSONL write hits a read-only directory', async () => {
const accessError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
const fileNotFoundError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
// The output file does not exist yet and its directory is not writable: the temp-file
// create fails with EACCES, routing into the external-backup fallback.
vi.mocked(fsPromises.writeFile)
.mockRejectedValueOnce(accessError) // temp file in the read-only output directory
.mockResolvedValueOnce(undefined); // replacement file in the OS temp directory
// There is no existing artifact to back up (ENOENT); the replacement copy back into the
// read-only directory then fails with the real EACCES, which must be what surfaces.
vi.mocked(fsPromises.copyFile)
.mockRejectedValueOnce(fileNotFoundError) // copyFile(outputPath, backup) → ENOENT (absent)
.mockRejectedValueOnce(accessError); // copyFile(replacement, outputPath) → EACCES
await expect(writeOutput('output.jsonl', new Eval({}), null)).rejects.toThrow('EACCES');
// No backup was ever created, so the restore copy must be skipped (exactly two copies).
expect(fsPromises.copyFile).toHaveBeenCalledTimes(2);
});
it('restores the JSONL backup when an external rewrite fails', async () => {
const permissionError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
const copyError = new Error('copy failed');
vi.mocked(fsPromises.writeFile)
.mockRejectedValueOnce(permissionError)
.mockResolvedValueOnce(undefined);
vi.mocked(fsPromises.copyFile)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(copyError)
.mockResolvedValueOnce(undefined);
const outputPath = 'output.jsonl';
await expect(writeOutput(outputPath, new Eval({}), null)).rejects.toThrow('copy failed');
expect(fsPromises.copyFile).toHaveBeenNthCalledWith(3, JSONL_BACKUP_PATH, outputPath);
});
it('preserves the existing JSONL artifact when an external backup cannot be created', async () => {
const permissionError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
vi.mocked(fsPromises.writeFile).mockRejectedValueOnce(permissionError);
vi.mocked(fsPromises.copyFile).mockRejectedValueOnce(new Error('backup failed'));
const outputPath = 'output.jsonl';
await expect(writeOutput(outputPath, new Eval({}), null)).rejects.toThrow('backup failed');
expect(fsPromises.copyFile).toHaveBeenCalledTimes(1);
});
it('retains the external JSONL backup when restoration fails', async () => {
const permissionError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
vi.mocked(fsPromises.writeFile)
.mockRejectedValueOnce(permissionError)
.mockResolvedValueOnce(undefined);
vi.mocked(fsPromises.copyFile)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('copy failed'))
.mockRejectedValueOnce(new Error('restore failed'));
const outputPath = 'output.jsonl';
await expect(writeOutput(outputPath, new Eval({}), null)).rejects.toThrow(
`Backup retained at ${JSONL_BACKUP_PATH}`,
);
expect(fsPromises.rm).not.toHaveBeenCalledWith(JSONL_TEMP_DIRECTORY, {
recursive: true,
force: true,
});
});
it('preserves the existing output file permissions across the atomic JSONL rewrite', async () => {
const outputPath = 'output.jsonl';
vi.mocked(fsPromises.stat).mockResolvedValueOnce({ mode: 0o600 } as Awaited<
ReturnType<typeof fsPromises.stat>
>);
const eval_ = new Eval({});
await expect(writeOutput(outputPath, eval_, null)).resolves.toBeUndefined();
// The temp file is chmod'd to the destination's prior mode before the rename, so a
// restricted (0600) reused path is not silently widened to the umask default.
expect(fsPromises.chmod).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), 0o600);
expect(fsPromises.rename).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), outputPath);
});
it('skips the chmod when the JSONL output file does not yet exist', async () => {
const outputPath = 'output.jsonl';
vi.mocked(fsPromises.stat).mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
const eval_ = new Eval({});
await expect(writeOutput(outputPath, eval_, null)).resolves.toBeUndefined();
expect(fsPromises.chmod).not.toHaveBeenCalled();
expect(fsPromises.rename).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/), outputPath);
});
it('keeps newly streamed retry rows ahead of stale persisted rows after a save failure', async () => {
const outputPath = 'output.jsonl';
const staleResult: EvaluateResult = {
success: false,
failureReason: ResultFailureReason.ERROR,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: { raw: 'Test prompt', label: 'Test prompt' },
response: { error: 'stale persisted error' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'prompt',
};
const retriedResult: EvaluateResult = {
...staleResult,
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
response: { output: 'fresh streamed retry' },
};
const eval_ = new Eval({});
eval_.recordResultPersistenceFailure(retriedResult);
vi.mocked(fsPromises.readFile).mockResolvedValue(
`${JSON.stringify(staleResult)}\n${JSON.stringify(retriedResult)}\n`,
);
vi.spyOn(eval_, 'fetchResultsBatched').mockImplementation(async function* () {
yield [staleResult as any];
});
await writeOutput(outputPath, eval_, null);
const written = vi.mocked(fsPromises.appendFile).mock.calls[0][1] as string;
expect(
written
.trim()
.split(/\r?\n/)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({
failureReason: ResultFailureReason.NONE,
response: { output: 'fresh streamed retry' },
score: 1,
success: true,
}),
]);
});
it('skips a malformed (truncated) streamed JSONL row during recovery instead of aborting', async () => {
const outputPath = 'output.jsonl';
const goodResult: EvaluateResult = {
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 10,
provider: { id: 'provider' },
prompt: { raw: 'Test prompt', label: 'Test prompt' },
response: { output: 'recovered streamed row' },
vars: {},
promptIdx: 0,
testIdx: 0,
testCase: {},
promptId: 'prompt',
};
const eval_ = new Eval({});
eval_.resultPersistenceFailed = true;
// A complete row followed by a truncated final row, as a killed/crashed run can leave.
vi.mocked(fsPromises.readFile).mockResolvedValue(
`${JSON.stringify(goodResult)}\n{"testIdx":1,"promptIdx":0,"resp`,
);
vi.spyOn(eval_, 'fetchResultsBatched').mockImplementation(async function* () {});
await expect(writeOutput(outputPath, eval_, null)).resolves.toBeUndefined();
const written = vi.mocked(fsPromises.appendFile).mock.calls[0][1] as string;
expect(
written
.trim()
.split(/\r?\n/)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({ testIdx: 0, response: { output: 'recovered streamed row' } }),
]);
});
it('writeOutput with json and txt output', async () => {
const outputPath = ['output.json', 'output.txt'];
const eval_ = new Eval({});
await writeMultipleOutputs(outputPath, eval_, null);
expect(fsPromises.writeFile).toHaveBeenCalledTimes(2);
});
it('waits for every output write before rejecting', async () => {
let resolveSlowWrite: () => void;
const slowWrite = new Promise<void>((resolve) => {
resolveSlowWrite = resolve;
});
vi.mocked(fsPromises.writeFile).mockImplementation(async (outputPath) => {
if (outputPath === 'output.json') {
throw new Error('json write failed');
}
if (outputPath === 'output.txt') {
await slowWrite;
}
});
let settled = false;
const writePromise = writeMultipleOutputs(['output.json', 'output.txt'], new Eval({}), null);
void writePromise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.waitFor(() => {
expect(fsPromises.writeFile).toHaveBeenCalledTimes(2);
});
expect(settled).toBe(false);
resolveSlowWrite!();
await expect(writePromise).rejects.toThrow('One or more output writes failed');
});
it('writeOutput with HTML template escapes special characters', async () => {
// Use the real fs module to read the template
const realFs = await vi.importActual<typeof import('fs')>('fs');
const templatePath = path.resolve(__dirname, '../../src/tableOutput.html');
const templateContent = realFs.readFileSync(templatePath, 'utf-8');
// Check that the template has escape filters on all user-provided content
expect(templateContent).toContain("{{ config.description | default('Eval Output') | escape }}");
expect(templateContent).toContain('{{ header | escape }}');
expect(templateContent).toContain('{{ cell.text | escape }}');
expect(templateContent).toContain('{{ cell.reason | escape }}');
expect(templateContent).toContain('{{ cell.error | escape }}');
expect(templateContent).toContain('{{ cell.description | escape }}');
// Ensure structured output content and derived search fields are escaped.
expect(templateContent).toContain('data-search="{{ cell.statusLabel | escape }}');
expect(templateContent).toContain('{{ cell.error | escape }}"');
expect(templateContent).toContain('data-report-search');
expect(templateContent).toContain('data-status-filter');
expect(templateContent).toContain('data-visible-count');
expect(templateContent).toContain('data-output-cell="true"');
expect(templateContent).toContain('data-status="{{ cell.status | escape }}"');
expect(templateContent).toContain('data-variable-name="{{ cell.name | escape }}"');
expect(templateContent).toContain('status-pill {{ cell.status | escape }}');
expect(templateContent).toContain('data-open-detail');
expect(templateContent).toContain('data-detail-drawer');
expect(templateContent).toContain('data-detail-template');
expect(templateContent).toContain('appendVariableDetails(trigger);');
expect(templateContent).not.toContain('{% for variable in cell.variables %}');
});
it('writeOutput with HTML includes report summary values', async () => {
const realFs = await vi.importActual<typeof import('fs')>('fs');
const templatePath = path.resolve(__dirname, '../../src/tableOutput.html');
const templateContent = realFs.readFileSync(templatePath, 'utf-8');
vi.mocked(fsPromises.readFile).mockResolvedValue(templateContent);
const eval_ = new Eval({ description: 'HTML facelift report' });
await eval_.addPrompts([{ raw: 'Prompt', label: 'Prompt', provider: 'provider' }]);
eval_.setVars(['input']);
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: { raw: 'Prompt', label: 'Prompt' },
response: { output: 'Passing output' },
vars: { input: 'one' },
promptIdx: 0,
testIdx: 0,
testCase: { vars: { input: 'one' }, description: 'Passing <test> description' },
promptId: 'prompt',
gradingResult: {
pass: true,
score: 1,
reason: 'Passing reason',
},
});
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ASSERT,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: { raw: 'Prompt', label: 'Prompt' },
response: { output: 'Failing output' },
vars: { input: 'two' },
promptIdx: 0,
testIdx: 1,
testCase: { vars: { input: 'two' } },
promptId: 'prompt',
gradingResult: {
pass: false,
score: 0,
reason: 'Failing reason',
},
});
await writeOutput('output.html', eval_, null);
const html = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
expect(html).toContain('HTML facelift report');
expect(html).toContain('<p class="metric-label">Total Results</p>');
expect(html).toContain('<p class="metric-value">2</p>');
expect(html).toContain('50.0%');
expect(html).toContain('>PASS<');
expect(html).toContain('>FAIL<');
expect(html).toContain('Score 1.00');
expect(html).toContain('Score 0.00');
expect(html).toContain('Passing output');
expect(html).toContain('Failing output');
expect(html).toContain('Passing &lt;test&gt; description');
expect(html).not.toContain('Passing <test> description');
expect(html).toContain('Passing reason');
expect(html).toContain('Failing reason');
expect(html).toContain('View detail');
expect(html).toContain('Result detail - row 1, prompt 1');
expect(html).toContain('Prompt');
expect(html).toContain('Variables');
expect(html).toContain('data-report-search');
expect(html).toContain('No rows match the current search and status filters.');
});
it('writeOutput with HTML classifies non-error failures as failures', async () => {
const realFs = await vi.importActual<typeof import('fs')>('fs');
const templatePath = path.resolve(__dirname, '../../src/tableOutput.html');
const templateContent = realFs.readFileSync(templatePath, 'utf-8');
vi.mocked(fsPromises.readFile).mockResolvedValue(templateContent);
const eval_ = new Eval({ description: 'HTML failure classification' });
await eval_.addPrompts([{ raw: 'Prompt', label: 'Prompt', provider: 'provider' }]);
eval_.setVars(['input']);
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.NONE,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: { raw: 'Prompt', label: 'Prompt' },
response: { output: '' },
vars: { input: 'missing-output' },
promptIdx: 0,
testIdx: 0,
testCase: { vars: { input: 'missing-output' } },
promptId: 'prompt',
});
await writeOutput('output.html', eval_, null);
const html = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
expect(html).toContain('data-status="fail"');
expect(html).toContain('>FAIL<');
});
it('writeOutput with HTML keeps visible runtime errors searchable without pre-rendering row variables per output', async () => {
const realFs = await vi.importActual<typeof import('fs')>('fs');
const templatePath = path.resolve(__dirname, '../../src/tableOutput.html');
const templateContent = realFs.readFileSync(templatePath, 'utf-8');
vi.mocked(fsPromises.readFile).mockResolvedValue(templateContent);
const eval_ = new Eval({ description: 'HTML error search' });
await eval_.addPrompts([{ raw: 'Prompt', label: 'Prompt', provider: 'provider' }]);
eval_.setVars(['input']);
await eval_.addResult({
success: false,
failureReason: ResultFailureReason.ERROR,
score: 0,
namedScores: {},
latencyMs: 100,
provider: { id: 'provider' },
prompt: { raw: 'Prompt', label: 'Prompt' },
response: { output: 'Visible output despite failure' },
error: 'provider timed out',
vars: { input: 'shared value' },
promptIdx: 0,
testIdx: 0,
testCase: { vars: { input: 'shared value' } },
promptId: 'prompt',
gradingResult: {
pass: false,
score: 0,
reason: 'Evaluation failed',
},
});
await writeOutput('output.html', eval_, null);
const html = vi.mocked(fsPromises.writeFile).mock.calls[0][1] as string;
expect(html).toContain(
'data-search="ERROR 0.00 provider timed out Evaluation failed provider timed out"',
);
expect(html).toContain('data-variable-name="input"');
expect(html).not.toContain('<span class="detail-variable-name">');
expect(html).toContain('appendVariableDetails(trigger);');
});
it('writes output to Google Sheets', async () => {
const outputPath = 'https://docs.google.com/spreadsheets/d/1234567890/edit#gid=0';
const config = { description: 'Test config' };
const shareableUrl = null;
const eval_ = new Eval(config);
await writeOutput(outputPath, eval_, shareableUrl);
expect(googleSheets.writeCsvToGoogleSheet).toHaveBeenCalledTimes(1);
});
it('writes Google Sheets output with provider in column headers', async () => {
const outputPath = 'https://docs.google.com/spreadsheets/d/1234567890/edit#gid=0';
const eval_ = new Eval({});
await eval_.addPrompts([{ raw: 'prompt1', label: 'First Prompt', provider: 'openai:gpt-4' }]);
eval_.setVars(['input']);
const result: EvaluateResult = {
success: true,
failureReason: ResultFailureReason.NONE,
score: 1.0,
namedScores: {},
latencyMs: 100,
provider: { id: 'openai:gpt-4' },
prompt: { raw: 'prompt1', label: 'First Prompt' },
response: { output: 'Test output' },
vars: { input: 'test input' },
promptIdx: 0,
testIdx: 0,
testCase: { vars: { input: 'test input' } },
promptId: 'prompt1',
};
await eval_.addResult(result);
await writeOutput(outputPath, eval_, null);
expect(googleSheets.writeCsvToGoogleSheet).toHaveBeenCalledTimes(1);
const rows = vi.mocked(googleSheets.writeCsvToGoogleSheet).mock.calls[0][0];
expect(rows.length).toBeGreaterThan(0);
const columnKeys = Object.keys(rows[0]);
expect(columnKeys).toContain('[openai:gpt-4] First Prompt');
});
it('writes Google Sheets output with multiple providers in column headers', async () => {
const outputPath = 'https://docs.google.com/spreadsheets/d/1234567890/edit#gid=0';
const eval_ = new Eval({});
await eval_.addPrompts([
{ raw: 'prompt1', label: 'Test Prompt', provider: 'openai:gpt-4' },
{ raw: 'prompt1', label: 'Test Prompt', provider: 'anthropic:claude-3' },
]);
eval_.setVars(['input']);
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 1.0,
namedScores: {},
latencyMs: 100,
provider: { id: 'openai:gpt-4' },
prompt: { raw: 'prompt1', label: 'Test Prompt' },
response: { output: 'GPT-4 output' },
vars: { input: 'test input' },
promptIdx: 0,
testIdx: 0,
testCase: { vars: { input: 'test input' } },
promptId: 'prompt1',
});
await eval_.addResult({
success: true,
failureReason: ResultFailureReason.NONE,
score: 0.8,
namedScores: {},
latencyMs: 150,
provider: { id: 'anthropic:claude-3' },
prompt: { raw: 'prompt1', label: 'Test Prompt' },
response: { output: 'Claude output' },
vars: { input: 'test input' },
promptIdx: 1,
testIdx: 0,
testCase: { vars: { input: 'test input' } },
promptId: 'prompt1',
});
await writeOutput(outputPath, eval_, null);
expect(googleSheets.writeCsvToGoogleSheet).toHaveBeenCalledTimes(1);
const rows = vi.mocked(googleSheets.writeCsvToGoogleSheet).mock.calls[0][0];
expect(rows.length).toBeGreaterThan(0);
const columnKeys = Object.keys(rows[0]);
expect(columnKeys).toContain('[openai:gpt-4] Test Prompt');
expect(columnKeys).toContain('[anthropic:claude-3] Test Prompt');
});
});
describe('warnOnDegradedJsonlRecovery', () => {
it('warns once when a row failed to persist and a JSONL artifact is being reconciled', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const eval_ = new Eval({});
eval_.resultPersistenceFailed = true;
warnOnDegradedJsonlRecovery(eval_, [
'results.jsonl',
'results.JSONL',
'results.json',
'results.yaml',
]);
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it('does not warn when no row failed to persist', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const eval_ = new Eval({});
warnOnDegradedJsonlRecovery(eval_, ['results.jsonl', 'results.json']);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('does not warn when the persistence failure does not touch a JSONL artifact', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const eval_ = new Eval({});
eval_.resultPersistenceFailed = true;
warnOnDegradedJsonlRecovery(eval_, ['results.json', 'results.yaml']);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('createOutputMetadata', () => {
it('should create metadata with all fields when evalRecord has all data', async () => {
const evalRecord = {
createdAt: new Date('2025-01-01T12:00:00.000Z').getTime(),
author: 'test-author',
} as any as Eval;
const metadata = createOutputMetadata(evalRecord);
expect(metadata).toMatchObject({
promptfooVersion: expect.any(String),
nodeVersion: expect.stringMatching(/^v\d+\.\d+\.\d+/),
platform: expect.any(String),
arch: expect.any(String),
exportedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
evaluationCreatedAt: '2025-01-01T12:00:00.000Z',
author: 'test-author',
});
});
it('should handle missing createdAt gracefully', async () => {
const evalRecord = {
author: 'test-author',
} as any as Eval;
const metadata = createOutputMetadata(evalRecord);
expect(metadata).toMatchObject({
promptfooVersion: expect.any(String),
nodeVersion: expect.stringMatching(/^v\d+\.\d+\.\d+/),
platform: expect.any(String),
arch: expect.any(String),
exportedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
evaluationCreatedAt: undefined,
author: 'test-author',
});
});
it('should handle missing author', async () => {
const evalRecord = {
createdAt: new Date('2025-01-01T12:00:00.000Z').getTime(),
} as any as Eval;
const metadata = createOutputMetadata(evalRecord);
expect(metadata).toMatchObject({
promptfooVersion: expect.any(String),
nodeVersion: expect.stringMatching(/^v\d+\.\d+\.\d+/),
platform: expect.any(String),
arch: expect.any(String),
exportedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
evaluationCreatedAt: '2025-01-01T12:00:00.000Z',
author: undefined,
});
});
it('should handle invalid date in createdAt', async () => {
const evalRecord = {
createdAt: 'invalid-date',
author: 'test-author',
} as any as Eval;
const metadata = createOutputMetadata(evalRecord);
// When new Date() is given invalid input, it returns "Invalid Date"
expect(metadata).toMatchObject({
promptfooVersion: expect.any(String),
nodeVersion: expect.stringMatching(/^v\d+\.\d+\.\d+/),
platform: expect.any(String),
arch: expect.any(String),
exportedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
evaluationCreatedAt: undefined,
author: 'test-author',
});
});
it('should create consistent exportedAt timestamps', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:30:00.000Z'));
const evalRecord = {} as any as Eval;
const metadata = createOutputMetadata(evalRecord);
expect(metadata.exportedAt).toBe('2025-01-15T10:30:00.000Z');
vi.useRealTimers();
});
});