0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import * as blobs from '../../src/blobs';
|
|
import { getTraceStore } from '../../src/tracing/store';
|
|
import { writeOutput } from '../../src/util/index';
|
|
import { createTempDir, mockProcessEnv, removeTempDir } from './utils';
|
|
|
|
// Mock dependencies
|
|
vi.mock('../../src/database', () => ({
|
|
getDb: vi.fn().mockReturnValue({
|
|
select: vi.fn(),
|
|
insert: vi.fn(),
|
|
transaction: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../src/logger', () => ({
|
|
default: {
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('JSON export with improved error handling', () => {
|
|
let tempDir: string;
|
|
let tempFilePath: string;
|
|
let mockEval: any;
|
|
|
|
beforeEach(() => {
|
|
tempDir = createTempDir('promptfoo-json-test-');
|
|
tempFilePath = path.join(tempDir, 'test-export.json');
|
|
|
|
mockEval = {
|
|
id: 'test-eval-id',
|
|
createdAt: '2025-01-01T00:00:00.000Z',
|
|
author: 'test-author',
|
|
config: { testConfig: true },
|
|
prompts: [
|
|
{ raw: 'Test prompt 1', label: 'prompt1' },
|
|
{ raw: 'Test prompt 2', label: 'prompt2' },
|
|
],
|
|
toEvaluateSummary: vi.fn(),
|
|
getResultsCount: vi.fn(),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
removeTempDir(tempDir);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('normal JSON export', () => {
|
|
beforeEach(() => {
|
|
mockEval.toEvaluateSummary.mockResolvedValue({
|
|
version: 3,
|
|
timestamp: '2025-01-01T00:00:00.000Z',
|
|
prompts: mockEval.prompts,
|
|
results: [
|
|
{ testIdx: 0, promptIdx: 0, success: true, score: 1.0 },
|
|
{ testIdx: 1, promptIdx: 0, success: true, score: 0.9 },
|
|
],
|
|
stats: {
|
|
successes: 2,
|
|
failures: 0,
|
|
tokenUsage: { total: 100, prompt: 50, completion: 50 },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should export JSON successfully', async () => {
|
|
await writeOutput(tempFilePath, mockEval, 'https://share.url');
|
|
|
|
expect(fs.existsSync(tempFilePath)).toBe(true);
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
// Verify structure matches expected OutputFile format
|
|
expect(parsed).toHaveProperty('evalId', 'test-eval-id');
|
|
expect(parsed).toHaveProperty('results');
|
|
expect(parsed.results).toHaveProperty('version', 3);
|
|
expect(parsed.results.results).toHaveLength(2);
|
|
expect(parsed).toHaveProperty('config', { testConfig: true });
|
|
expect(parsed).toHaveProperty('shareableUrl', 'https://share.url');
|
|
expect(parsed).toHaveProperty('metadata');
|
|
});
|
|
|
|
it('should export persisted vars and runtime options for round-trip imports', async () => {
|
|
mockEval.vars = ['topic', 'tone'];
|
|
mockEval.runtimeOptions = { cache: false, maxConcurrency: 2 };
|
|
|
|
await writeOutput(tempFilePath, mockEval, null);
|
|
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
expect(parsed).toHaveProperty('vars', ['topic', 'tone']);
|
|
expect(parsed).toHaveProperty('runtimeOptions', { cache: false, maxConcurrency: 2 });
|
|
});
|
|
|
|
it('should embed referenced blob bytes only when media export is enabled', async () => {
|
|
const hash = 'a'.repeat(64);
|
|
const data = Buffer.from('portable image');
|
|
mockEval.toEvaluateSummary.mockResolvedValue({
|
|
version: 3,
|
|
timestamp: '2025-01-01T00:00:00.000Z',
|
|
prompts: mockEval.prompts,
|
|
results: [{ response: { output: `promptfoo://blob/${hash}` } }],
|
|
stats: { successes: 1, failures: 0 },
|
|
});
|
|
vi.spyOn(blobs, 'getBlobByHash').mockResolvedValue({
|
|
data,
|
|
metadata: {
|
|
mimeType: 'image/png',
|
|
sizeBytes: data.length,
|
|
createdAt: '2025-01-01T00:00:00.000Z',
|
|
provider: 'filesystem',
|
|
key: hash,
|
|
},
|
|
});
|
|
|
|
await writeOutput(tempFilePath, mockEval, null);
|
|
expect(JSON.parse(fs.readFileSync(tempFilePath, 'utf8'))).not.toHaveProperty('blobAssets');
|
|
|
|
await writeOutput(tempFilePath, mockEval, null, { includeMedia: true });
|
|
const parsed = JSON.parse(fs.readFileSync(tempFilePath, 'utf8'));
|
|
expect(parsed.blobAssets).toEqual([
|
|
{
|
|
hash,
|
|
mimeType: 'image/png',
|
|
sizeBytes: data.length,
|
|
data: data.toString('base64'),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should embed blob bytes referenced only by exported traces', async () => {
|
|
const hash = 'b'.repeat(64);
|
|
const data = Buffer.from('portable trace image');
|
|
const traceSpy = vi.spyOn(getTraceStore(), 'getTracesByEvaluation').mockResolvedValue([
|
|
{
|
|
traceId: 'trace-media-export',
|
|
evaluationId: mockEval.id,
|
|
testCaseId: 'trace-media-case',
|
|
metadata: { attachment: `promptfoo://blob/${hash}` },
|
|
spans: [],
|
|
},
|
|
]);
|
|
vi.spyOn(blobs, 'getBlobByHash').mockResolvedValue({
|
|
data,
|
|
metadata: {
|
|
mimeType: 'image/png',
|
|
sizeBytes: data.length,
|
|
createdAt: '2025-01-01T00:00:00.000Z',
|
|
provider: 'filesystem',
|
|
key: hash,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await writeOutput(tempFilePath, mockEval, null, { includeMedia: true });
|
|
|
|
const parsed = JSON.parse(fs.readFileSync(tempFilePath, 'utf8'));
|
|
expect(parsed.traces[0].metadata.attachment).toBe(`promptfoo://blob/${hash}`);
|
|
expect(parsed.blobAssets).toEqual([
|
|
{
|
|
hash,
|
|
mimeType: 'image/png',
|
|
sizeBytes: data.length,
|
|
data: data.toString('base64'),
|
|
},
|
|
]);
|
|
} finally {
|
|
traceSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('should not embed response blob bytes when response output stripping is enabled', async () => {
|
|
const restoreEnv = mockProcessEnv({ PROMPTFOO_STRIP_RESPONSE_OUTPUT: 'true' });
|
|
const hash = 'c'.repeat(64);
|
|
const getBlobSpy = vi.spyOn(blobs, 'getBlobByHash');
|
|
mockEval.toEvaluateSummary.mockResolvedValue({
|
|
version: 3,
|
|
timestamp: '2025-01-01T00:00:00.000Z',
|
|
prompts: mockEval.prompts,
|
|
results: [
|
|
{
|
|
response: {
|
|
output: '[output stripped]',
|
|
metadata: { blobUris: [`promptfoo://blob/${hash}`] },
|
|
},
|
|
},
|
|
],
|
|
stats: { successes: 1, failures: 0 },
|
|
});
|
|
|
|
try {
|
|
await writeOutput(tempFilePath, mockEval, null, { includeMedia: true });
|
|
|
|
const parsed = JSON.parse(fs.readFileSync(tempFilePath, 'utf8'));
|
|
expect(parsed.results.results[0].response.metadata.blobUris).toEqual([
|
|
`promptfoo://blob/${hash}`,
|
|
]);
|
|
expect(parsed).not.toHaveProperty('blobAssets');
|
|
expect(getBlobSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
getBlobSpy.mockRestore();
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should handle null shareableUrl', async () => {
|
|
await writeOutput(tempFilePath, mockEval, null);
|
|
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
expect(parsed.shareableUrl).toBeNull();
|
|
});
|
|
|
|
it('should maintain proper JSON formatting', async () => {
|
|
await writeOutput(tempFilePath, mockEval, 'https://test.url');
|
|
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
|
|
// Verify proper 2-space indentation
|
|
expect(content).toContain('{\n "evalId":');
|
|
expect(content).toContain(' "results": {');
|
|
expect(content).toContain(' "version":');
|
|
|
|
// Should be valid JSON
|
|
expect(() => JSON.parse(content)).not.toThrow();
|
|
});
|
|
|
|
it('should include all required metadata fields', async () => {
|
|
await writeOutput(tempFilePath, mockEval, 'https://test.url');
|
|
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
expect(parsed.metadata).toHaveProperty('promptfooVersion');
|
|
expect(parsed.metadata).toHaveProperty('nodeVersion');
|
|
expect(parsed.metadata).toHaveProperty('platform');
|
|
expect(parsed.metadata).toHaveProperty('exportedAt');
|
|
expect(parsed.metadata).toHaveProperty('author', 'test-author');
|
|
});
|
|
});
|
|
|
|
describe('memory limit error handling', () => {
|
|
beforeEach(() => {
|
|
mockEval.getResultsCount.mockResolvedValue(50000);
|
|
mockEval.toEvaluateSummary.mockImplementation(() => {
|
|
throw new RangeError('Invalid string length');
|
|
});
|
|
});
|
|
|
|
it('should handle RangeError gracefully with helpful message', async () => {
|
|
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
|
'Dataset too large for JSON export',
|
|
);
|
|
|
|
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
|
'Consider using JSONL format instead',
|
|
);
|
|
});
|
|
|
|
it('should include result count in error message', async () => {
|
|
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow('50000 results');
|
|
});
|
|
|
|
it('should not create output file when memory error occurs', async () => {
|
|
try {
|
|
await writeOutput(tempFilePath, mockEval, null);
|
|
} catch (_error) {
|
|
// Expected to throw
|
|
}
|
|
|
|
expect(fs.existsSync(tempFilePath)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('other error handling', () => {
|
|
it('should propagate non-RangeError exceptions', async () => {
|
|
const testError = new Error('Database connection failed');
|
|
mockEval.toEvaluateSummary.mockRejectedValue(testError);
|
|
|
|
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
|
'Database connection failed',
|
|
);
|
|
});
|
|
|
|
it('should handle file system errors', async () => {
|
|
// Use a path that will definitely fail - writing to a file that already exists as a directory
|
|
const dirAsFile = path.join(tempDir, 'directory-as-file');
|
|
fs.mkdirSync(dirAsFile);
|
|
const invalidPath = dirAsFile; // Try to write to a directory path as if it were a file
|
|
|
|
mockEval.toEvaluateSummary.mockResolvedValue({
|
|
version: 3,
|
|
results: [],
|
|
stats: { successes: 0, failures: 0 },
|
|
});
|
|
|
|
await expect(writeOutput(invalidPath, mockEval, null)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('backward compatibility', () => {
|
|
it('should maintain exact same JSON structure as original implementation', async () => {
|
|
const expectedSummary = {
|
|
version: 3,
|
|
timestamp: '2025-01-01T00:00:00.000Z',
|
|
prompts: [
|
|
{ raw: 'Test prompt 1', label: 'prompt1' },
|
|
{ raw: 'Test prompt 2', label: 'prompt2' },
|
|
],
|
|
results: [
|
|
{
|
|
testIdx: 0,
|
|
promptIdx: 0,
|
|
success: true,
|
|
score: 1.0,
|
|
vars: { input: 'test' },
|
|
output: 'response',
|
|
},
|
|
],
|
|
stats: {
|
|
successes: 1,
|
|
failures: 0,
|
|
errors: 0,
|
|
tokenUsage: { total: 100, prompt: 50, completion: 50 },
|
|
},
|
|
};
|
|
|
|
mockEval.toEvaluateSummary.mockResolvedValue(expectedSummary);
|
|
|
|
await writeOutput(tempFilePath, mockEval, 'https://share.url');
|
|
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
// Verify exact structure
|
|
expect(Object.keys(parsed).sort()).toEqual(
|
|
['evalId', 'results', 'config', 'shareableUrl', 'metadata'].sort(),
|
|
);
|
|
|
|
expect(parsed.results).toEqual(expectedSummary);
|
|
});
|
|
|
|
it('should handle both EvaluateSummaryV2 and V3 formats', async () => {
|
|
const v2Summary = {
|
|
version: 2,
|
|
timestamp: '2025-01-01T00:00:00.000Z',
|
|
results: [{ testIdx: 0, success: true }],
|
|
table: { head: { prompts: [], vars: [] }, body: [] },
|
|
stats: { successes: 1, failures: 0 },
|
|
};
|
|
|
|
mockEval.toEvaluateSummary.mockResolvedValue(v2Summary);
|
|
|
|
await writeOutput(tempFilePath, mockEval, null);
|
|
|
|
const content = fs.readFileSync(tempFilePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
|
|
expect(parsed.results.version).toBe(2);
|
|
expect(parsed.results).toHaveProperty('table');
|
|
expect(parsed.results.results).toEqual([{ testIdx: 0, success: true }]);
|
|
});
|
|
});
|
|
|
|
describe('performance considerations', () => {
|
|
it('should complete export in reasonable time for moderate datasets', async () => {
|
|
// Create a moderate-sized dataset
|
|
const moderateResults = Array.from({ length: 1000 }, (_, i) => ({
|
|
testIdx: i,
|
|
promptIdx: 0,
|
|
success: true,
|
|
score: Math.random(),
|
|
vars: { input: `test input ${i}` },
|
|
output: `test output ${i}`,
|
|
}));
|
|
|
|
mockEval.toEvaluateSummary.mockResolvedValue({
|
|
version: 3,
|
|
timestamp: '2025-01-01T00:00:00.000Z',
|
|
prompts: mockEval.prompts,
|
|
results: moderateResults,
|
|
stats: { successes: 1000, failures: 0 },
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
await writeOutput(tempFilePath, mockEval, null);
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Should complete within reasonable time (5 seconds for 1000 results)
|
|
expect(duration).toBeLessThan(5000);
|
|
|
|
// Verify file was created and has content
|
|
expect(fs.existsSync(tempFilePath)).toBe(true);
|
|
const stats = fs.statSync(tempFilePath);
|
|
expect(stats.size).toBeGreaterThan(1000); // Should have substantial content
|
|
}, 10000); // 10 second timeout
|
|
});
|
|
});
|