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

1472 lines
58 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { createClient } from '@libsql/client/node';
import { Command } from 'commander';
import { sql } from 'drizzle-orm';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBlobByHash, resetBlobStorageProvider, setBlobStorageProvider } from '../../src/blobs';
import * as blobRefs from '../../src/blobs/blobRefs';
import { FilesystemBlobStorageProvider } from '../../src/blobs/filesystemProvider';
import { importCommand } from '../../src/commands/import';
import { getDb } from '../../src/database/index';
import { updateSignalFile, updateSignalFileForDeletedEvals } from '../../src/database/signal';
import { evalsTable, evalsToPromptsTable } from '../../src/database/tables';
import logger from '../../src/logger';
import { runDbMigrations } from '../../src/migrate';
import Eval from '../../src/models/eval';
import EvalResult from '../../src/models/evalResult';
import { TraceStore } from '../../src/tracing/store';
import { ResultFailureReason } from '../../src/types/index';
import { sha256 } from '../../src/util/createHash';
import { createTempDir, mockProcessEnv, removeTempDir } from '../util/utils';
vi.mock('../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
},
}));
vi.mock('../../src/database/signal', async () => {
const actual = await vi.importActual('../../src/database/signal');
return {
...actual,
updateSignalFile: vi.fn(),
updateSignalFileForDeletedEvals: vi.fn(),
};
});
const SHARED_CACHE_MEMORY_URL = 'file::memory:?cache=shared';
describe('importCommand', () => {
let program: Command;
let tempFilePath: string;
beforeAll(async () => {
await runDbMigrations();
});
beforeEach(async () => {
program = new Command();
process.exitCode = undefined;
// Clear all tables before each test
const db = await getDb();
// Delete related tables first
await db.run('DELETE FROM blob_references');
await db.run('DELETE FROM blob_assets');
await db.run('DELETE FROM spans');
await db.run('DELETE FROM traces');
await db.run('DELETE FROM eval_results');
await db.run('DELETE FROM evals_to_datasets');
await db.run('DELETE FROM evals_to_prompts');
await db.run('DELETE FROM evals_to_tags');
// Then delete from main table
await db.run('DELETE FROM evals');
});
afterEach(() => {
vi.clearAllMocks();
process.exitCode = undefined;
// Clean up temp file if it exists
if (tempFilePath && fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
});
describe('command registration', () => {
it('should register the import command', () => {
importCommand(program);
const importCmd = program.commands.find((cmd) => cmd.name() === 'import');
expect(importCmd).toBeDefined();
expect(importCmd!.description()).toBe(
'Import a Promptfoo eval JSON export or an OpenAI Evals JSONL export',
);
});
});
describe('error handling', () => {
it('should handle file read errors', async () => {
importCommand(program);
await program.parseAsync(['node', 'test', 'import', 'non-existent-file.json']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to import eval.*ENOENT/),
);
expect(process.exitCode).toBe(1);
});
it('should handle invalid JSON', async () => {
const filePath = path.join(__dirname, `temp-invalid-${Date.now()}.json`);
fs.writeFileSync(filePath, 'invalid json');
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to import eval.*(?:Unexpected token|JSON)/i),
);
expect(process.exitCode).toBe(1);
});
it('should reject result payloads that are neither current nor legacy exports', async () => {
const filePath = path.join(__dirname, `temp-unsupported-results-${Date.now()}.json`);
const evalId = 'eval-unsupported-results';
fs.writeFileSync(
filePath,
JSON.stringify({
id: evalId,
config: { description: 'unsupported results shape' },
results: {
results: [{ success: true, response: { output: 'not enough legacy shape' } }],
},
}),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Unsupported eval export results format'),
);
expect(await Eval.findById(evalId)).toBeUndefined();
expect(process.exitCode).toBe(1);
});
it('reports a schema mismatch for valid JSONL that is not an OpenAI Evals export', async () => {
// Every line is valid JSON, so the whole-file JSON.parse fails but the
// JSONL path succeeds. The error must describe the schema mismatch
// rather than re-throw the misleading whole-file JSON parse error.
const filePath = path.join(__dirname, `temp-bad-jsonl-${Date.now()}.json`);
fs.writeFileSync(filePath, ['{"foo":1}', '{"bar":2}'].join('\n'));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching(/parsed as JSONL but line 1 is not a valid OpenAI Evals row/),
);
expect(process.exitCode).toBe(1);
});
it('imports successfully when evaluationCreatedAt is corrupt', async () => {
// Regression: a present-but-unparseable timestamp reached
// createEvalId().toISOString() and crashed mid-import with an opaque
// "Invalid time value".
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.metadata.evaluationCreatedAt = 'this-is-not-a-date';
const filePath = path.join(__dirname, `temp-bad-date-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(logger.error).not.toHaveBeenCalledWith(expect.stringMatching(/Invalid time value/));
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching(/unparseable metadata\.evaluationCreatedAt/),
);
expect(process.exitCode).not.toBe(1);
expect(await Eval.findById(sampleData.evalId)).toBeDefined();
});
});
describe('with real sample file', () => {
it('should successfully import the sample export file preserving evalId', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
// Get the sample data to verify import details
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
// Check if the import process exited with an error
expect(process.exitCode).toBeUndefined();
// The evalId should now be preserved from the export file
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
expect(importedEval!.id).toBe(sampleData.evalId);
expect(importedEval!.config).toBeDefined();
expect(importedEval!.prompts).toBeDefined();
expect(importedEval!.prompts.length).toBe(2); // Based on sample file having 2 prompts
// Verify that the config matches what we expect from the sample file
expect(importedEval!.config.description).toBe(sampleData.config.description);
expect(importedEval!.config.providers).toEqual(sampleData.config.providers);
// Also verify that eval results were imported
const results = await EvalResult.findManyByEvalId(importedEval!.id);
expect(results.length).toBe(4); // Based on sample file having 4 results
expect(updateSignalFile).toHaveBeenCalledWith(importedEval!.id);
});
it('should preserve createdAt timestamp from metadata', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
// Verify createdAt matches the metadata.evaluationCreatedAt from export
const expectedCreatedAt = new Date(sampleData.metadata.evaluationCreatedAt).getTime();
expect(importedEval!.createdAt).toBe(expectedCreatedAt);
});
it('should derive vars from results', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
// Verify vars were derived from results
expect(importedEval!.vars).toBeDefined();
expect(importedEval!.vars.length).toBeGreaterThan(0);
// The sample has a 'riddle' variable
expect(importedEval!.vars).toContain('riddle');
});
it('should preserve exported vars, runtime options, and durations', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.vars = ['riddle', 'unused_export_var'];
sampleData.runtimeOptions = { cache: false, maxConcurrency: 2, repeat: 3 };
sampleData.results.stats.durationMs = 4321;
sampleData.results.stats.generationDurationMs = 123;
sampleData.results.stats.evaluationDurationMs = 4198;
const filePath = path.join(__dirname, `temp-parity-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
expect(importedEval!.vars).toEqual(sampleData.vars);
expect(importedEval!.runtimeOptions).toEqual(sampleData.runtimeOptions);
expect(importedEval!.durationMs).toBe(4321);
expect(importedEval!.generationDurationMs).toBe(123);
expect(importedEval!.evaluationDurationMs).toBe(4198);
});
it('should import traces and attach them to the imported eval', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.traces = [
{
traceId: 'trace-imported',
evaluationId: 'eval-from-another-machine',
testCaseId: 'trace-case',
metadata: { source: 'portable-export' },
spans: [
{
spanId: 'span-imported',
name: 'portable span',
startTime: 10,
endTime: 20,
attributes: { operation: 'search' },
statusCode: 1,
},
{
spanId: 'span-malformed',
},
],
},
];
const filePath = path.join(__dirname, `temp-trace-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const traces = await new TraceStore().getTracesByEvaluation(sampleData.evalId);
expect(traces).toHaveLength(1);
expect(traces[0]).toMatchObject({
traceId: 'trace-imported',
evaluationId: sampleData.evalId,
testCaseId: 'trace-case',
metadata: { source: 'portable-export' },
});
expect(traces[0].spans).toEqual([
expect.objectContaining({
spanId: 'span-imported',
name: 'portable span',
attributes: { operation: 'search' },
statusCode: 1,
}),
]);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Skipping malformed trace span'),
);
});
it('should skip malformed traces and still import the eval', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.traces = [
{
traceId: 'trace-valid',
evaluationId: sampleData.evalId,
testCaseId: 'trace-case',
spans: [{ spanId: 'span-valid', name: 'span', startTime: 1 }],
},
// Missing traceId — not an importable trace.
{ evaluationId: sampleData.evalId, testCaseId: 'trace-orphan', spans: [] },
];
const filePath = path.join(__dirname, `temp-malformed-trace-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Skipping malformed trace during import'),
);
const traces = await new TraceStore().getTracesByEvaluation(sampleData.evalId);
expect(traces).toHaveLength(1);
expect(traces[0].traceId).toBe('trace-valid');
});
it('should clear result trace linkage when the exported trace is absent', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.results.results[0].traceId = 'trace-missing-from-export';
sampleData.results.results[0].evaluationId = 'eval-from-another-machine';
const filePath = path.join(__dirname, `temp-unlinked-trace-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const [result] = await EvalResult.findManyByEvalId(sampleData.evalId);
expect(result.toEvaluateResult().traceId).toBeUndefined();
expect(result.toEvaluateResult().evaluationId).toBeUndefined();
});
it('should import embedded blob assets before recording result references', async () => {
const blobDir = createTempDir('promptfoo-import-blobs-');
setBlobStorageProvider(new FilesystemBlobStorageProvider({ basePath: blobDir }));
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const data = Buffer.from('portable image from eval export');
const hash = sha256(data);
sampleData.results.results[0].response = { output: `promptfoo://blob/${hash}` };
sampleData.blobAssets = [
{
hash,
mimeType: 'image/png',
sizeBytes: data.length,
data: data.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const imported = await getBlobByHash(hash);
expect(imported.data).toEqual(data);
expect(imported.metadata.mimeType).toBe('image/png');
const db = await getDb();
const references = (await db.all(
sql`SELECT blob_hash, eval_id, location FROM blob_references WHERE blob_hash = ${hash}`,
)) as Array<{ blob_hash: string; eval_id: string; location: string }>;
expect(references).toContainEqual({
blob_hash: hash,
eval_id: sampleData.evalId,
location: 'import',
});
} finally {
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should downgrade active embedded blob MIME types during import', async () => {
const blobDir = createTempDir('promptfoo-import-active-mime-blobs-');
setBlobStorageProvider(new FilesystemBlobStorageProvider({ basePath: blobDir }));
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const htmlData = Buffer.from('<script>alert(document.domain)</script>');
const htmlHash = sha256(htmlData);
const svgData = Buffer.from('<svg onload="alert(document.domain)" />');
const svgHash = sha256(svgData);
sampleData.results.results[0].response = {
output: `promptfoo://blob/${htmlHash} promptfoo://blob/${svgHash}`,
};
sampleData.blobAssets = [
{
hash: htmlHash,
mimeType: 'text/html',
sizeBytes: htmlData.length,
data: htmlData.toString('base64'),
},
{
hash: svgHash,
mimeType: 'image/svg+xml',
sizeBytes: svgData.length,
data: svgData.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-active-mime-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect((await getBlobByHash(htmlHash)).metadata.mimeType).toBe('application/octet-stream');
expect((await getBlobByHash(svgHash)).metadata.mimeType).toBe('application/octet-stream');
} finally {
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should import embedded blob assets referenced only by traces', async () => {
const blobDir = createTempDir('promptfoo-import-trace-blobs-');
setBlobStorageProvider(new FilesystemBlobStorageProvider({ basePath: blobDir }));
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const data = Buffer.from('portable trace image from eval export');
const hash = sha256(data);
sampleData.traces = [
{
traceId: 'trace-media-import',
evaluationId: sampleData.evalId,
testCaseId: 'trace-media-case',
metadata: { attachment: `promptfoo://blob/${hash}` },
spans: [],
},
];
sampleData.blobAssets = [
{
hash,
mimeType: 'image/png',
sizeBytes: data.length,
data: data.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-trace-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const imported = await getBlobByHash(hash);
expect(imported.data).toEqual(data);
const db = await getDb();
const references = (await db.all(
sql`SELECT blob_hash, eval_id, location FROM blob_references WHERE blob_hash = ${hash}`,
)) as Array<{ blob_hash: string; eval_id: string; location: string }>;
expect(references).toContainEqual({
blob_hash: hash,
eval_id: sampleData.evalId,
location: 'import',
});
} finally {
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should skip blob reference scans for imports without embedded assets', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const collectBlobHashesSpy = vi.spyOn(blobRefs, 'collectBlobHashes');
try {
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
expect(collectBlobHashesSpy).not.toHaveBeenCalled();
} finally {
collectBlobHashesSpy.mockRestore();
}
});
it('should ignore unreferenced embedded blob assets before validation and storage', async () => {
const blobDir = createTempDir('promptfoo-import-filtered-blobs-');
const provider = new FilesystemBlobStorageProvider({ basePath: blobDir });
const storeSpy = vi.spyOn(provider, 'store');
setBlobStorageProvider(provider);
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const referencedData = Buffer.from('referenced portable image');
const referencedHash = sha256(referencedData);
const unreferencedHash = sha256(Buffer.from('expected orphan bytes'));
const tamperedOrphanData = Buffer.from('tampered orphan bytes');
sampleData.results.results[0].response = {
output: `promptfoo://blob/${referencedHash}`,
};
sampleData.blobAssets = [
{
hash: referencedHash,
mimeType: 'image/png',
sizeBytes: referencedData.length,
data: referencedData.toString('base64'),
},
{
hash: unreferencedHash,
mimeType: 'image/png',
sizeBytes: tamperedOrphanData.length,
data: tamperedOrphanData.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-filtered-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBeUndefined();
expect(storeSpy).toHaveBeenCalledTimes(1);
expect((await getBlobByHash(referencedHash)).data).toEqual(referencedData);
await expect(getBlobByHash(unreferencedHash)).rejects.toThrow();
} finally {
storeSpy.mockRestore();
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should reject embedded blob assets whose bytes do not match the exported hash', async () => {
const blobDir = createTempDir('promptfoo-import-corrupt-blobs-');
setBlobStorageProvider(new FilesystemBlobStorageProvider({ basePath: blobDir }));
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const data = Buffer.from('wrong bytes');
const hash = 'f'.repeat(64);
sampleData.results.results[0].response = { output: `promptfoo://blob/${hash}` };
sampleData.blobAssets = [
{
hash,
mimeType: 'image/png',
sizeBytes: data.length,
data: data.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-corrupt-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Embedded blob hash mismatch'),
);
expect(process.exitCode).toBe(1);
} finally {
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should reject embedded blob assets that exceed the import size limit', async () => {
const blobDir = createTempDir('promptfoo-import-oversized-blobs-');
setBlobStorageProvider(new FilesystemBlobStorageProvider({ basePath: blobDir }));
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const data = Buffer.from('small bytes claiming to be huge');
const hash = 'a'.repeat(64);
sampleData.results.results[0].response = { output: `promptfoo://blob/${hash}` };
sampleData.blobAssets = [
{
hash,
// 60MB — over the 50MB BLOB_MAX_SIZE import cap.
sizeBytes: 60 * 1024 * 1024,
mimeType: 'image/png',
data: data.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-oversized-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('byte import limit'));
expect(process.exitCode).toBe(1);
expect(await Eval.findById(sampleData.evalId)).toBeUndefined();
} finally {
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should preserve author from metadata', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
// Verify author is preserved from metadata
expect(importedEval!.author).toBe(sampleData.metadata.author);
});
it('should preserve imported author even when the local user is cloud-authed as someone else', async () => {
// Simulate a cloud-authed importer with a different local identity. The
// imported eval's historical author must still win — this is a
// regression test for PR #7760.
const restoreEnv = mockProcessEnv({ PROMPTFOO_API_KEY: 'fake-test-api-key' });
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
expect(importedEval!.author).toBe(sampleData.metadata.author);
} finally {
restoreEnv();
}
});
it('should import legacy table-backed eval exports', async () => {
const evalId = 'eval-legacy-table-backed';
const filePath = path.join(__dirname, `temp-legacy-v2-${Date.now()}.json`);
fs.writeFileSync(
filePath,
JSON.stringify({
id: evalId,
createdAt: '2024-01-02T03:04:05.000Z',
author: 'legacy-author',
config: { description: 'legacy import', redteam: {} },
results: {
version: 2,
timestamp: '2024-01-02T03:04:05.000Z',
results: [{ success: true, vars: { topic: 'legacy' } }],
table: { head: { prompts: [], vars: ['topic'] }, body: [] },
stats: { successes: 1, failures: 0 },
},
}),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const importedEval = await Eval.findById(evalId);
expect(importedEval).toBeDefined();
expect(importedEval!.author).toBe('legacy-author');
const db = await getDb();
const storedEval = await db
.select({ isRedteam: evalsTable.isRedteam })
.from(evalsTable)
.where(sql`${evalsTable.id} = ${evalId}`)
.get();
expect(storedEval?.isRedteam).toBe(true);
expect(await importedEval!.toEvaluateSummary()).toMatchObject({
version: 2,
results: [{ success: true, vars: { topic: 'legacy' } }],
table: { head: { vars: ['topic'] } },
});
});
});
describe('with OpenAI eval items JSONL export', () => {
it('should import grader outcomes and preserve OpenAI metadata', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/openai-evals-items.jsonl');
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
const importedEval = await Eval.findById('openai-evals-evalrun_import_fixture');
expect(importedEval).toBeDefined();
expect(importedEval!.config.description).toBe(
'Imported from OpenAI Evals run evalrun_import_fixture',
);
expect(importedEval!.config.metadata?.openaiEvalsImport).toEqual({
format: 'dashboard-output-items-jsonl',
rowCount: 2,
runIds: ['evalrun_import_fixture'],
});
expect(importedEval!.prompts).toHaveLength(1);
expect(importedEval!.prompts[0].metrics?.testPassCount).toBe(1);
expect(importedEval!.prompts[0].metrics?.testFailCount).toBe(1);
expect(importedEval!.prompts[0].metrics?.assertPassCount).toBe(5);
expect(importedEval!.prompts[0].metrics?.assertFailCount).toBe(1);
const results = (await EvalResult.findManyByEvalId(importedEval!.id)).sort(
(a, b) => a.testIdx - b.testIdx,
);
expect(results).toHaveLength(2);
expect(results[0].namedScores).toEqual({
Relevance: 6,
Directness: 3,
'Tool called': 1,
});
expect(results[0].gradingResult?.pass).toBe(true);
expect(results[0].gradingResult?.componentResults).toHaveLength(3);
expect(results[0].metadata?.openai?.dataSourceIdx).toBe(10);
expect(results[0].metadata?.openai?.graderSamples?.Relevance).toEqual({
outputs: [{ role: 'assistant', content: '{"result":6}' }],
});
expect(results[0].metadata?.openai?.outputItem).toMatchObject({
run_id: 'evalrun_import_fixture',
data_source_idx: 10,
item: {
input: 'How do I unsubscribe from marketing emails?',
},
});
expect(results[1].success).toBe(false);
expect(results[1].failureReason).toBe(ResultFailureReason.ASSERT);
expect(results[1].gradingResult?.reason).toContain('Relevance');
expect(results[1].testCase.vars?.item).toMatchObject({
input: 'Can I use multiple promo codes?',
});
});
it('should preserve run-based collision behavior for OpenAI JSONL imports', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/openai-evals-items.jsonl');
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
vi.clearAllMocks();
process.exitCode = undefined;
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Eval openai-evals-evalrun_import_fixture already exists'),
);
expect(process.exitCode).toBe(1);
});
it('should import multi-run samples as comparable prompt columns', async () => {
const sampleFilePath = path.join(
__dirname,
'../__fixtures__/openai-evals-multi-run-items.jsonl',
);
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
const importedEvals = await Eval.getMany(10);
expect(importedEvals).toHaveLength(1);
expect(importedEvals[0].prompts).toHaveLength(2);
expect(new Set(importedEvals[0].prompts.map((prompt) => prompt.label)).size).toBe(2);
expect(importedEvals[0].prompts.map((prompt) => prompt.provider)).toEqual([
'openai-evals:evalrun_error_fixture',
'openai-evals:evalrun_pass_fixture',
]);
expect(importedEvals[0].prompts[0].metrics?.testErrorCount).toBe(1);
expect(importedEvals[0].prompts[0].metrics?.testFailCount).toBe(0);
expect(importedEvals[0].prompts[1].metrics?.testPassCount).toBe(1);
expect(importedEvals[0].prompts[1].metrics?.tokenUsage).toMatchObject({
total: 10,
prompt: 8,
completion: 2,
cached: 1,
numRequests: 1,
});
const results = await EvalResult.findManyByEvalId(importedEvals[0].id);
expect(results).toHaveLength(2);
expect(results.map((result) => result.testIdx)).toEqual([0, 0]);
expect(new Set(results.map((result) => result.promptId)).size).toBe(2);
const db = await getDb();
const promptLinks = await db.select().from(evalsToPromptsTable).all();
expect(
promptLinks.filter((promptLink) => promptLink.evalId === importedEvals[0].id),
).toHaveLength(2);
const errorResult = results.find(
(result) => result.metadata?.openai?.runId === 'evalrun_error_fixture',
);
expect(errorResult?.failureReason).toBe(ResultFailureReason.ERROR);
expect(errorResult?.error).toBe('Response input item missing role');
expect(errorResult?.metadata?.openai?.sample?.error?.type).toBe('invalid_request_error');
const passedResult = results.find(
(result) => result.metadata?.openai?.runId === 'evalrun_pass_fixture',
);
expect(passedResult?.response?.output).toBe('Hardware');
expect(passedResult?.response?.finishReason).toBe('stop');
expect(passedResult?.response?.tokenUsage).toEqual({
total: 10,
prompt: 8,
completion: 2,
cached: 1,
numRequests: 1,
});
});
it('should keep non-equivalent multi-run items on separate test rows', async () => {
const filePath = path.join(__dirname, `temp-openai-evals-${Date.now()}.jsonl`);
fs.writeFileSync(
filePath,
[
{
run_id: 'evalrun_first_dataset',
data_source_idx: 0,
item: { input: 'First dataset item' },
grades: { Match: 1 },
passes: { Match: true },
},
{
run_id: 'evalrun_second_dataset',
data_source_idx: 0,
item: { input: 'Different dataset item' },
grades: { Match: 1 },
passes: { Match: true },
},
]
.map((row) => JSON.stringify(row))
.join('\n'),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBeUndefined();
const importedEvals = await Eval.getMany(10);
const results = await EvalResult.findManyByEvalId(importedEvals[0].id);
expect(importedEvals[0].config.tests).toHaveLength(2);
expect(results).toHaveLength(2);
expect(results.map((result) => result.testIdx)).toEqual([0, 1]);
expect(results.map((result) => result.testCase.vars?.item)).toEqual([
{ input: 'First dataset item' },
{ input: 'Different dataset item' },
]);
});
it('should import dashboard rows that omit optional pass results as score-only data', async () => {
const filePath = path.join(__dirname, `temp-openai-evals-${Date.now()}.jsonl`);
fs.writeFileSync(
filePath,
JSON.stringify({
run_id: 'evalrun_without_passes',
data_source_idx: 0,
item: { input: 'Grade this later' },
grades: { Quality: 0.5 },
eval_id: 'eval_future_shape',
}),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBeUndefined();
const importedEval = await Eval.findById('openai-evals-evalrun_without_passes');
const results = await EvalResult.findManyByEvalId(importedEval!.id);
expect(results).toHaveLength(1);
expect(results[0].success).toBe(true);
expect(results[0].gradingResult?.pass).toBe(true);
expect(results[0].gradingResult?.componentResults).toEqual([]);
expect(results[0].gradingResult?.reason).toContain('did not include grader pass results');
expect(importedEval!.prompts[0].metrics).toMatchObject({
testPassCount: 1,
testFailCount: 0,
assertPassCount: 0,
assertFailCount: 0,
});
expect(results[0].metadata?.openai?.passes).toEqual({});
expect(results[0].metadata?.openai?.outputItem?.eval_id).toBe('eval_future_shape');
});
it('should align rows whose items have identical content but different key orderings', async () => {
const filePath = path.join(__dirname, `temp-openai-evals-${Date.now()}.jsonl`);
fs.writeFileSync(
filePath,
[
{
run_id: 'evalrun_canonical_a',
data_source_idx: 0,
item: { a: 1, b: 2, nested: { x: 10, y: 20 } },
grades: { Quality: 1 },
passes: { Quality: true },
},
{
run_id: 'evalrun_canonical_b',
data_source_idx: 0,
item: { nested: { y: 20, x: 10 }, b: 2, a: 1 },
grades: { Quality: 1 },
passes: { Quality: true },
},
]
.map((row) => JSON.stringify(row))
.join('\n'),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBeUndefined();
const importedEvals = await Eval.getMany(10);
const results = await EvalResult.findManyByEvalId(importedEvals[0].id);
expect(importedEvals[0].config.tests).toHaveLength(1);
expect(results).toHaveLength(2);
expect(results.map((result) => result.testIdx)).toEqual([0, 0]);
expect(new Set(results.map((result) => result.promptIdx)).size).toBe(2);
});
it('should surface JSON error when a JSONL file mixes valid and invalid lines', async () => {
const filePath = path.join(__dirname, `temp-openai-evals-${Date.now()}.jsonl`);
fs.writeFileSync(
filePath,
[
JSON.stringify({
run_id: 'evalrun_valid',
data_source_idx: 0,
item: { input: 'hi' },
grades: { X: 1 },
passes: { X: true },
}),
'this is not json at all',
JSON.stringify({
run_id: 'evalrun_valid',
data_source_idx: 1,
item: { input: 'there' },
grades: { X: 1 },
passes: { X: true },
}),
].join('\n'),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBe(1);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to import eval'));
const importedEvals = await Eval.getMany(10);
expect(importedEvals).toHaveLength(0);
});
it('should sanitize unsafe characters in the OpenAI run id used as eval id', async () => {
const filePath = path.join(__dirname, `temp-openai-evals-${Date.now()}.jsonl`);
fs.writeFileSync(
filePath,
JSON.stringify({
run_id: 'evalrun/with spaces & slashes',
data_source_idx: 0,
item: { input: 'sanitize me' },
grades: { Match: 1 },
passes: { Match: true },
}),
);
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
expect(process.exitCode).toBeUndefined();
const importedEval = await Eval.findById('openai-evals-evalrun_with_spaces___slashes');
expect(importedEval).toBeDefined();
expect(importedEval!.config.metadata?.openaiEvalsImport).toMatchObject({
runIds: ['evalrun/with spaces & slashes'],
});
});
});
describe('collision handling', () => {
it('should reject import when eval already exists', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
// First import should succeed
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
// Reset mocks and exitCode for second import
vi.clearAllMocks();
process.exitCode = undefined;
// Second import should fail with collision error
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(`Eval ${sampleData.evalId} already exists`),
);
expect(process.exitCode).toBe(1);
});
it('should allow import with --new-id flag when eval already exists', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
// First import should succeed
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
// Reset mocks and exitCode for second import
vi.clearAllMocks();
process.exitCode = undefined;
// Second import with --new-id should succeed
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', '--new-id', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
expect(logger.info).toHaveBeenCalledWith(
expect.stringMatching(/has been successfully imported/),
);
// Should now have 2 evals in database
const allEvals = await Eval.getMany(10);
expect(allEvals.length).toBe(2);
// One should have the original ID, one should have a new ID
const originalEval = allEvals.find((e) => e.id === sampleData.evalId);
const newEval = allEvals.find((e) => e.id !== sampleData.evalId);
expect(originalEval).toBeDefined();
expect(newEval).toBeDefined();
});
it('should remap trace IDs for duplicate imports with --new-id', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.traces = [
{
traceId: 'trace-duplicate-import',
evaluationId: sampleData.evalId,
testCaseId: 'trace-case',
spans: [{ spanId: 'span-duplicate-import', name: 'span', startTime: 10 }],
},
];
sampleData.results.results[0].traceId = 'trace-duplicate-import';
sampleData.results.results[0].evaluationId = sampleData.evalId;
const filePath = path.join(__dirname, `temp-trace-new-id-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', '--new-id', filePath]);
const allEvals = await Eval.getMany(10);
const duplicateEval = allEvals.find((eval_) => eval_.id !== sampleData.evalId);
expect(duplicateEval).toBeDefined();
const traceStore = new TraceStore();
const originalTraces = await traceStore.getTracesByEvaluation(sampleData.evalId);
const duplicateTraces = await traceStore.getTracesByEvaluation(duplicateEval!.id);
expect(originalTraces).toHaveLength(1);
expect(duplicateTraces).toHaveLength(1);
expect(duplicateTraces[0].traceId).not.toBe(originalTraces[0].traceId);
expect(duplicateTraces[0].spans).toEqual([
expect.objectContaining({ spanId: 'span-duplicate-import' }),
]);
const [duplicateResult] = await EvalResult.findManyByEvalId(duplicateEval!.id);
expect(duplicateResult.toEvaluateResult()).toMatchObject({
traceId: duplicateTraces[0].traceId,
evaluationId: duplicateEval!.id,
});
});
it('keeps the trace id and rewrites evaluationId on a default import (no --new-id)', async () => {
// The most common import path: trace ids are preserved (no collision) and the result's
// evaluationId is corrected to the imported eval id even if the export carried a stale one.
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.traces = [
{
traceId: 'trace-default-import',
evaluationId: 'eval-from-original-machine',
testCaseId: 'trace-case',
spans: [{ spanId: 'span-default-import', name: 'span', startTime: 10 }],
},
];
sampleData.results.results[0].traceId = 'trace-default-import';
sampleData.results.results[0].evaluationId = 'eval-from-original-machine';
const filePath = path.join(__dirname, `temp-trace-default-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const traceStore = new TraceStore();
const traces = await traceStore.getTracesByEvaluation(sampleData.evalId);
expect(traces).toHaveLength(1);
expect(traces[0].traceId).toBe('trace-default-import');
const [result] = await EvalResult.findManyByEvalId(sampleData.evalId);
expect(result.toEvaluateResult()).toMatchObject({
traceId: 'trace-default-import',
evaluationId: sampleData.evalId,
});
});
it('should remap imported trace IDs that already belong to another eval', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
sampleData.traces = [
{
traceId: 'trace-cross-eval-collision',
evaluationId: sampleData.evalId,
testCaseId: 'trace-case',
spans: [{ spanId: 'span-original', name: 'original span', startTime: 10 }],
},
];
sampleData.results.results[0].traceId = 'trace-cross-eval-collision';
sampleData.results.results[0].evaluationId = sampleData.evalId;
const filePath = path.join(__dirname, `temp-trace-collision-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(sampleData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', filePath]);
const collidingData = structuredClone(sampleData);
collidingData.evalId = 'eval-trace-collision-import';
collidingData.traces[0].evaluationId = collidingData.evalId;
collidingData.traces[0].spans = [
{ spanId: 'span-imported', name: 'imported span', startTime: 20 },
];
fs.writeFileSync(filePath, JSON.stringify(collidingData));
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', filePath]);
const traceStore = new TraceStore();
const originalTraces = await traceStore.getTracesByEvaluation(sampleData.evalId);
const importedTraces = await traceStore.getTracesByEvaluation(collidingData.evalId);
expect(originalTraces).toHaveLength(1);
expect(originalTraces[0].spans).toEqual([
expect.objectContaining({ spanId: 'span-original' }),
]);
expect(importedTraces).toHaveLength(1);
expect(importedTraces[0].traceId).not.toBe(originalTraces[0].traceId);
expect(importedTraces[0].spans).toEqual([
expect.objectContaining({ spanId: 'span-imported' }),
]);
const [importedResult] = await EvalResult.findManyByEvalId(collidingData.evalId);
expect(importedResult.toEvaluateResult()).toMatchObject({
traceId: importedTraces[0].traceId,
evaluationId: collidingData.evalId,
});
});
it('should replace existing eval with --force flag', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
// First import should succeed
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
// Verify the eval exists
const firstImport = await Eval.findById(sampleData.evalId);
expect(firstImport).toBeDefined();
// Reset mocks and exitCode for second import
vi.clearAllMocks();
process.exitCode = undefined;
// Second import with --force should succeed and replace existing
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', '--force', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
expect(logger.info).toHaveBeenCalledWith(`Replacing existing eval ${sampleData.evalId}`);
expect(logger.info).toHaveBeenCalledWith(
expect.stringMatching(/has been successfully imported/),
);
expect(updateSignalFileForDeletedEvals).not.toHaveBeenCalled();
// Should still have only 1 eval in database (replaced, not duplicated)
const allEvals = await Eval.getMany(10);
expect(allEvals.length).toBe(1);
expect(allEvals[0].id).toBe(sampleData.evalId);
// Verify the eval data is correct
const replacedEval = await Eval.findById(sampleData.evalId);
expect(replacedEval).toBeDefined();
expect(replacedEval!.config.description).toBe(sampleData.config.description);
});
it('should keep the existing eval when a --force replacement fails preflight', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const replacementData = structuredClone(sampleData);
const badData = Buffer.from('wrong replacement bytes');
const hash = 'f'.repeat(64);
replacementData.results.results[0].response = { output: `promptfoo://blob/${hash}` };
replacementData.blobAssets = [
{
hash,
mimeType: 'image/png',
sizeBytes: badData.length,
data: badData.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-force-corrupt-blob-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(replacementData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
vi.clearAllMocks();
process.exitCode = undefined;
const lockHolder = createClient({ url: SHARED_CACHE_MEMORY_URL });
const writeTx = await lockHolder.transaction('write');
try {
await writeTx.execute({
sql: 'UPDATE evals SET description = description WHERE id = ?',
args: [sampleData.evalId],
});
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', '--force', filePath]);
} finally {
await writeTx.rollback().catch(() => undefined);
lockHolder.close();
}
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Embedded blob hash mismatch'),
);
expect(process.exitCode).toBe(1);
const preservedEval = await Eval.findById(sampleData.evalId);
expect(preservedEval).toBeDefined();
expect(await EvalResult.findManyByEvalId(sampleData.evalId)).toHaveLength(4);
});
it('should keep the existing eval when a --force replacement cannot store embedded media', async () => {
const blobDir = createTempDir('promptfoo-import-force-store-failure-');
const provider = new FilesystemBlobStorageProvider({ basePath: blobDir });
setBlobStorageProvider(provider);
try {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
const replacementData = structuredClone(sampleData);
const replacementBlob = Buffer.from('valid replacement bytes');
const replacementHash = sha256(replacementBlob);
replacementData.results.results[0].response = {
output: `promptfoo://blob/${replacementHash}`,
};
replacementData.blobAssets = [
{
hash: replacementHash,
mimeType: 'image/png',
sizeBytes: replacementBlob.length,
data: replacementBlob.toString('base64'),
},
];
const filePath = path.join(__dirname, `temp-force-store-failure-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(replacementData));
tempFilePath = filePath;
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
vi.clearAllMocks();
process.exitCode = undefined;
const storeSpy = vi
.spyOn(provider, 'store')
.mockRejectedValueOnce(new Error('embedded media storage failed'));
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', '--force', filePath]);
expect(storeSpy).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('embedded media storage failed'),
);
expect(process.exitCode).toBe(1);
expect(await Eval.findById(sampleData.evalId)).toBeDefined();
expect(await EvalResult.findManyByEvalId(sampleData.evalId)).toHaveLength(4);
} finally {
resetBlobStorageProvider();
removeTempDir(blobDir);
}
});
it('should warn that the original eval was deleted when a --force replacement fails after deletion', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
expect(await EvalResult.findManyByEvalId(sampleData.evalId)).toHaveLength(4);
vi.clearAllMocks();
process.exitCode = undefined;
// Fail after replaceExistingEval has already deleted the original eval.
const createSpy = vi
.spyOn(EvalResult, 'createManyFromEvaluateResult')
.mockRejectedValueOnce(new Error('result write failed'));
try {
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', '--force', sampleFilePath]);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('result write failed'));
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('was deleted for a --force replacement'),
);
expect(updateSignalFileForDeletedEvals).toHaveBeenCalledWith([sampleData.evalId]);
expect(process.exitCode).toBe(1);
// The original results are gone — the disclosure tells the user why.
expect(await EvalResult.findManyByEvalId(sampleData.evalId)).toHaveLength(0);
} finally {
createSpy.mockRestore();
}
});
it('should import normally with --force flag when eval does not exist', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
const sampleData = JSON.parse(fs.readFileSync(sampleFilePath, 'utf-8'));
importCommand(program);
// Import with --force when no eval exists should succeed normally
await program.parseAsync(['node', 'test', 'import', '--force', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
// Should NOT log "Replacing existing eval" since there was nothing to replace
expect(logger.info).not.toHaveBeenCalledWith(
expect.stringContaining('Replacing existing eval'),
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringMatching(/has been successfully imported/),
);
// Verify the eval was imported with original ID
const importedEval = await Eval.findById(sampleData.evalId);
expect(importedEval).toBeDefined();
expect(importedEval!.id).toBe(sampleData.evalId);
});
it('should show updated error message mentioning --force option', async () => {
const sampleFilePath = path.join(__dirname, '../__fixtures__/sample-export.json');
importCommand(program);
// First import should succeed
await program.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(process.exitCode).toBeUndefined();
// Reset mocks and exitCode for second import
vi.clearAllMocks();
process.exitCode = undefined;
// Second import without flags should fail with error mentioning both options
const program2 = new Command();
importCommand(program2);
await program2.parseAsync(['node', 'test', 'import', sampleFilePath]);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('--new-id'));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('--force'));
expect(process.exitCode).toBe(1);
});
});
});