Files
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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
});
});