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
2667 lines
94 KiB
TypeScript
2667 lines
94 KiB
TypeScript
import { eq, sql } from 'drizzle-orm';
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getDb } from '../../src/database/index';
|
|
import { updateSignalFile, updateSignalFileForDeletedEvals } from '../../src/database/signal';
|
|
import { evalResultsTable, evalsTable, spansTable, tracesTable } from '../../src/database/tables';
|
|
import { getAuthor } from '../../src/globalConfig/accounts';
|
|
import { runDbMigrations } from '../../src/migrate';
|
|
import Eval, {
|
|
buildSafeJsonPath,
|
|
combineFilterConditions,
|
|
EvalQueries,
|
|
escapeJsonPathKey,
|
|
getEvalSummaries,
|
|
} from '../../src/models/eval';
|
|
import { getCachedResultsCount } from '../../src/models/evalPerformance';
|
|
import EvalResult from '../../src/models/evalResult';
|
|
import { TraceStore } from '../../src/tracing/store';
|
|
import { type EvaluateResult, type Prompt, ResultFailureReason } from '../../src/types/index';
|
|
import { updateResult, writeResultsToDatabase } from '../../src/util/database';
|
|
import {
|
|
getCachedStandaloneEvals,
|
|
getStandaloneEvalCacheKey,
|
|
setCachedStandaloneEvals,
|
|
} from '../../src/util/standaloneEvalCache';
|
|
import { createEvaluateResult } from '../factories/eval';
|
|
import EvalFactory from '../factories/evalFactory';
|
|
|
|
vi.mock('../../src/globalConfig/accounts', async () => {
|
|
const actual = await vi.importActual('../../src/globalConfig/accounts');
|
|
return {
|
|
...actual,
|
|
getUserEmail: vi.fn(),
|
|
getAuthor: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/database/signal', async () => {
|
|
const actual = await vi.importActual('../../src/database/signal');
|
|
return {
|
|
...actual,
|
|
updateSignalFile: vi.fn(),
|
|
updateSignalFileForDeletedEvals: vi.fn(),
|
|
};
|
|
});
|
|
|
|
describe('evaluator', () => {
|
|
beforeAll(async () => {
|
|
await runDbMigrations();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.mocked(getAuthor).mockReset();
|
|
vi.mocked(updateSignalFile).mockClear();
|
|
|
|
// Clear all tables before each test
|
|
const db = await getDb();
|
|
// Delete related tables first
|
|
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.resetAllMocks();
|
|
});
|
|
|
|
describe('addPrompts', () => {
|
|
it('should notify watchers when persisted prompt metadata changes', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 0 });
|
|
vi.mocked(updateSignalFile).mockClear();
|
|
|
|
await eval_.addPrompts([
|
|
{
|
|
raw: 'Summarize the latest changelog entry',
|
|
label: 'Summarize the latest changelog entry',
|
|
provider: 'test-provider',
|
|
},
|
|
]);
|
|
|
|
expect(updateSignalFile).toHaveBeenCalledWith(eval_.id);
|
|
|
|
const reloaded = await Eval.findById(eval_.id);
|
|
expect(reloaded?.prompts).toEqual([
|
|
expect.objectContaining({
|
|
raw: 'Summarize the latest changelog entry',
|
|
label: 'Summarize the latest changelog entry',
|
|
provider: 'test-provider',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('should not notify watchers for in-memory evals', async () => {
|
|
const eval_ = new Eval({});
|
|
|
|
await eval_.addPrompts([
|
|
{
|
|
raw: 'In-memory prompt',
|
|
label: 'In-memory prompt',
|
|
provider: 'test-provider',
|
|
},
|
|
]);
|
|
|
|
expect(updateSignalFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('fetchResultsBatched', () => {
|
|
it('returns in-memory results in batches for non-persisted evals', async () => {
|
|
const eval_ = new Eval({});
|
|
const results = Array.from({ length: 3 }, (_, testIdx) => {
|
|
return new EvalResult({
|
|
id: `in-memory-${testIdx}`,
|
|
evalId: eval_.id,
|
|
promptIdx: 0,
|
|
testIdx,
|
|
testCase: { vars: { testIdx } },
|
|
prompt: { raw: 'Test prompt', label: 'Test prompt' },
|
|
provider: { id: 'test-provider' },
|
|
response: { output: `Result ${testIdx}` },
|
|
gradingResult: null,
|
|
namedScores: {},
|
|
metadata: {},
|
|
success: true,
|
|
score: 1,
|
|
latencyMs: 1,
|
|
cost: 0,
|
|
failureReason: ResultFailureReason.NONE,
|
|
});
|
|
});
|
|
await eval_.setResults(results);
|
|
|
|
const batches: EvalResult[][] = [];
|
|
for await (const batch of eval_.fetchResultsBatched(2)) {
|
|
batches.push(batch);
|
|
}
|
|
|
|
expect(batches.map((batch) => batch.map((result) => result.id))).toEqual([
|
|
['in-memory-0', 'in-memory-1'],
|
|
['in-memory-2'],
|
|
]);
|
|
});
|
|
|
|
it('advances across sparse test indices for persisted evals', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 0 });
|
|
const results = [150, 275].map((testIdx) => {
|
|
return new EvalResult({
|
|
id: `sparse-${eval_.id}-${testIdx}`,
|
|
evalId: eval_.id,
|
|
promptIdx: 0,
|
|
testIdx,
|
|
testCase: { vars: { testIdx } },
|
|
prompt: { raw: 'Test prompt', label: 'Test prompt' },
|
|
provider: { id: 'test-provider' },
|
|
response: { output: `Result ${testIdx}` },
|
|
gradingResult: null,
|
|
namedScores: {},
|
|
metadata: {},
|
|
success: true,
|
|
score: 1,
|
|
latencyMs: 1,
|
|
cost: 0,
|
|
failureReason: ResultFailureReason.NONE,
|
|
});
|
|
});
|
|
await eval_.setResults(results);
|
|
|
|
const batches: EvalResult[][] = [];
|
|
for await (const batch of eval_.fetchResultsBatched(100)) {
|
|
batches.push(batch);
|
|
}
|
|
|
|
expect(batches.map((batch) => batch.map((result) => result.testIdx))).toEqual([[150], [275]]);
|
|
});
|
|
});
|
|
|
|
describe('getFailedResultsByTestIdx', () => {
|
|
const makeFailedRow = (promptIdx: number) => ({
|
|
promptIdx,
|
|
testIdx: 0,
|
|
testCase: { vars: {} },
|
|
prompt: { raw: 'p', label: 'p' },
|
|
provider: { id: 'test-provider' },
|
|
response: { output: 'out' },
|
|
gradingResult: { pass: true, score: 1, reason: 'init', componentResults: [] },
|
|
namedScores: {},
|
|
metadata: {},
|
|
success: true,
|
|
score: 1,
|
|
latencyMs: 1,
|
|
cost: 0,
|
|
failureReason: ResultFailureReason.NONE,
|
|
});
|
|
|
|
it('reuses the reconstructed instance so comparison grading composes across passes', async () => {
|
|
const eval_ = new Eval({});
|
|
eval_.recordResultPersistenceFailure(makeFailedRow(1) as any);
|
|
|
|
const [first] = await eval_.getFailedResultsByTestIdx(0);
|
|
// Simulate an earlier comparison pass (e.g. select-best) demoting the failed row.
|
|
first.success = false;
|
|
first.score = 0;
|
|
|
|
const [second] = await eval_.getFailedResultsByTestIdx(0);
|
|
// A later pass (e.g. max-score) must see the SAME, already-mutated instance so its
|
|
// grading composes on top rather than rehydrating the stale pre-comparison row.
|
|
expect(second).toBe(first);
|
|
expect(second.success).toBe(false);
|
|
expect(second.score).toBe(0);
|
|
});
|
|
|
|
it('rebuilds from the raw row when the persistence failure is re-recorded', async () => {
|
|
const eval_ = new Eval({});
|
|
eval_.recordResultPersistenceFailure(makeFailedRow(0) as any);
|
|
|
|
const [first] = await eval_.getFailedResultsByTestIdx(0);
|
|
first.success = false;
|
|
|
|
// Re-recording the failure replaces the raw row and must drop the cached reconstruction.
|
|
eval_.recordResultPersistenceFailure(makeFailedRow(0) as any);
|
|
const [second] = await eval_.getFailedResultsByTestIdx(0);
|
|
|
|
expect(second).not.toBe(first);
|
|
expect(second.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('setResults', () => {
|
|
it('should persist result rows when replacing results on a persisted eval', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 0 });
|
|
const result = new EvalResult({
|
|
id: 'set-results-row',
|
|
evalId: eval_.id,
|
|
promptIdx: 0,
|
|
testIdx: 0,
|
|
testCase: { vars: { state: 'colorado' } },
|
|
prompt: {
|
|
raw: 'What is the capital of colorado?',
|
|
label: 'What is the capital of {{state}}?',
|
|
},
|
|
provider: { id: 'test-provider' },
|
|
response: { output: 'Denver' },
|
|
gradingResult: null,
|
|
namedScores: {},
|
|
metadata: {},
|
|
success: true,
|
|
score: 1,
|
|
latencyMs: 12,
|
|
cost: 0,
|
|
failureReason: ResultFailureReason.NONE,
|
|
});
|
|
|
|
expect(await getCachedResultsCount(eval_.id)).toBe(0);
|
|
await eval_.setResults([result]);
|
|
|
|
const persistedResults = await EvalResult.findManyByEvalId(eval_.id);
|
|
expect(await getCachedResultsCount(eval_.id)).toBe(1);
|
|
expect(updateSignalFile).toHaveBeenCalledWith(eval_.id);
|
|
expect(persistedResults).toHaveLength(1);
|
|
expect(persistedResults[0]).toEqual(
|
|
expect.objectContaining({
|
|
evalId: eval_.id,
|
|
promptIdx: 0,
|
|
response: expect.objectContaining({ output: 'Denver' }),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('summaryResults', () => {
|
|
it('should return all evaluations', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const eval2 = await EvalFactory.create();
|
|
const eval3 = await EvalFactory.createOldResult();
|
|
|
|
const evaluations = await getEvalSummaries();
|
|
|
|
expect(evaluations).toHaveLength(3);
|
|
|
|
expect(evaluations).toContainEqual(
|
|
expect.objectContaining({
|
|
evalId: eval1.id,
|
|
createdAt: eval1.createdAt,
|
|
description: null,
|
|
numTests: 2,
|
|
isRedteam: false,
|
|
passRate: 50,
|
|
label: eval1.id,
|
|
}),
|
|
);
|
|
|
|
expect(evaluations).toContainEqual(
|
|
expect.objectContaining({
|
|
evalId: eval2.id,
|
|
createdAt: eval2.createdAt,
|
|
description: null,
|
|
numTests: 2,
|
|
isRedteam: false,
|
|
passRate: 50,
|
|
label: eval2.id,
|
|
}),
|
|
);
|
|
|
|
expect(evaluations).toContainEqual(expect.objectContaining({ evalId: eval3.id }));
|
|
});
|
|
|
|
it('should return evaluations in descending order by createdAt', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const eval2 = await EvalFactory.create();
|
|
const eval3 = await EvalFactory.create();
|
|
const db = await getDb();
|
|
await db.update(evalsTable).set({ createdAt: 1 }).where(eq(evalsTable.id, eval1.id)).run();
|
|
await db.update(evalsTable).set({ createdAt: 2 }).where(eq(evalsTable.id, eval2.id)).run();
|
|
await db.update(evalsTable).set({ createdAt: 3 }).where(eq(evalsTable.id, eval3.id)).run();
|
|
|
|
const evaluations = await getEvalSummaries();
|
|
|
|
expect(evaluations).toHaveLength(3);
|
|
expect(evaluations[0].evalId).toBe(eval3.id);
|
|
expect(evaluations[1].evalId).toBe(eval2.id);
|
|
expect(evaluations[2].evalId).toBe(eval1.id);
|
|
});
|
|
|
|
it('should sort timestamp ties consistently in full and filtered summaries', async () => {
|
|
const eval1 = await Eval.create({}, []);
|
|
const eval2 = await Eval.create({}, []);
|
|
const createdAt = Date.now();
|
|
|
|
const db = await getDb();
|
|
await db.update(evalsTable).set({ createdAt }).where(eq(evalsTable.id, eval1.id)).run();
|
|
await db.update(evalsTable).set({ createdAt }).where(eq(evalsTable.id, eval2.id)).run();
|
|
|
|
const expectedIds = [eval1.id, eval2.id].sort().reverse();
|
|
const allIds = (await getEvalSummaries()).map(({ evalId }) => evalId);
|
|
const filteredIds = (await getEvalSummaries(undefined, 'eval')).map(({ evalId }) => evalId);
|
|
|
|
expect(allIds).toEqual(expectedIds);
|
|
expect(filteredIds).toEqual(expectedIds);
|
|
});
|
|
|
|
it('should update redteam classification when a persisted config changes type', async () => {
|
|
const redteamEvaluation = await Eval.create({ redteam: {} as any }, []);
|
|
const regularEvaluation = await Eval.create({}, []);
|
|
|
|
await updateResult(redteamEvaluation.id, {});
|
|
await updateResult(regularEvaluation.id, { redteam: {} as any });
|
|
|
|
const db = await getDb();
|
|
const stored = await db
|
|
.select({ id: evalsTable.id, isRedteam: evalsTable.isRedteam })
|
|
.from(evalsTable)
|
|
.all();
|
|
const redteamSummaries = await getEvalSummaries(undefined, 'redteam');
|
|
const evalSummaries = await getEvalSummaries(undefined, 'eval');
|
|
|
|
expect(stored).toContainEqual({ id: redteamEvaluation.id, isRedteam: false });
|
|
expect(stored).toContainEqual({ id: regularEvaluation.id, isRedteam: true });
|
|
expect(redteamSummaries).not.toContainEqual(
|
|
expect.objectContaining({ evalId: redteamEvaluation.id }),
|
|
);
|
|
expect(redteamSummaries).toContainEqual(
|
|
expect.objectContaining({ evalId: regularEvaluation.id, isRedteam: true }),
|
|
);
|
|
expect(evalSummaries).toContainEqual(
|
|
expect.objectContaining({ evalId: redteamEvaluation.id, isRedteam: false }),
|
|
);
|
|
expect(evalSummaries).not.toContainEqual(
|
|
expect.objectContaining({ evalId: regularEvaluation.id }),
|
|
);
|
|
});
|
|
|
|
it('should persist the redteam flag for legacy result writes', async () => {
|
|
const evalId = await writeResultsToDatabase(
|
|
{
|
|
version: 2,
|
|
timestamp: new Date().toISOString(),
|
|
results: [],
|
|
table: { head: { prompts: [], vars: [] }, body: [] },
|
|
stats: { successes: 0, failures: 0 },
|
|
} as any,
|
|
{ redteam: {} as any },
|
|
);
|
|
|
|
const db = await getDb();
|
|
const stored = await db
|
|
.select({ isRedteam: evalsTable.isRedteam })
|
|
.from(evalsTable)
|
|
.where(eq(evalsTable.id, evalId))
|
|
.get();
|
|
|
|
expect(stored?.isRedteam).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{ label: 'redteam: {}', config: { redteam: {} as any }, expected: true },
|
|
{ label: 'redteam: null', config: { redteam: null as any }, expected: true },
|
|
{ label: 'no redteam key', config: {}, expected: false },
|
|
])('classifies $label as isRedteam=$expected on create', async ({ config, expected }) => {
|
|
const eval_ = await Eval.create(config, []);
|
|
const db = await getDb();
|
|
const stored = await db
|
|
.select({ isRedteam: evalsTable.isRedteam })
|
|
.from(evalsTable)
|
|
.where(eq(evalsTable.id, eval_.id))
|
|
.get();
|
|
expect(stored?.isRedteam).toBe(expected);
|
|
|
|
const redteamSummaries = await getEvalSummaries(undefined, 'redteam');
|
|
const evalSummaries = await getEvalSummaries(undefined, 'eval');
|
|
const presentInRedteam = redteamSummaries.some((s) => s.evalId === eval_.id);
|
|
const presentInEval = evalSummaries.some((s) => s.evalId === eval_.id);
|
|
expect(presentInRedteam).toBe(expected);
|
|
expect(presentInEval).toBe(!expected);
|
|
});
|
|
|
|
it('should correctly deserialize all provider types', async () => {
|
|
// Test different provider formats
|
|
const testCases = [
|
|
// String provider
|
|
{ providers: 'openai:gpt-4', expected: [{ id: 'openai:gpt-4', label: null }] },
|
|
// Array with strings
|
|
{
|
|
providers: ['openai:gpt-4', 'anthropic:claude'],
|
|
expected: [
|
|
{ id: 'openai:gpt-4', label: null },
|
|
{ id: 'anthropic:claude', label: null },
|
|
],
|
|
},
|
|
// Array with ProviderOptions (explicit id)
|
|
{
|
|
providers: [{ id: 'custom-provider', label: 'Custom Label' }],
|
|
expected: [{ id: 'custom-provider', label: 'Custom Label' }],
|
|
},
|
|
// Array with declarative providers (record format)
|
|
{
|
|
providers: [
|
|
{ 'openai:gpt-4': { config: { temperature: 0.5 }, label: 'GPT-4' } },
|
|
{ 'anthropic:claude': { config: { maxTokens: 1000 } } },
|
|
],
|
|
expected: [
|
|
{ id: 'openai:gpt-4', label: 'GPT-4' },
|
|
{ id: 'anthropic:claude', label: null },
|
|
],
|
|
},
|
|
// Mixed array
|
|
{
|
|
providers: [
|
|
'openai:gpt-3.5',
|
|
{ id: 'custom', label: 'Custom' },
|
|
{ 'anthropic:claude': { label: 'Claude' } },
|
|
],
|
|
expected: [
|
|
{ id: 'openai:gpt-3.5', label: null },
|
|
{ id: 'custom', label: 'Custom' },
|
|
{ id: 'anthropic:claude', label: 'Claude' },
|
|
],
|
|
},
|
|
];
|
|
|
|
for (const testCase of testCases) {
|
|
const evaluation = await Eval.create(
|
|
{
|
|
providers: testCase.providers as any,
|
|
prompts: ['Test prompt'],
|
|
tests: [{ vars: { test: 'value' } }],
|
|
},
|
|
[{ raw: 'Test prompt', label: 'Test prompt' }],
|
|
);
|
|
|
|
const summaries = await getEvalSummaries(undefined, undefined, true);
|
|
const summary = summaries.find((s) => s.evalId === evaluation.id);
|
|
|
|
expect(summary).toBeDefined();
|
|
expect(summary?.providers).toEqual(testCase.expected);
|
|
|
|
// Clean up
|
|
await evaluation.delete();
|
|
}
|
|
});
|
|
|
|
// Note: Function providers cannot be serialized to the database,
|
|
// so they won't appear in getEvalSummaries() results.
|
|
// The deserialization logic handles them, but they're lost during storage.
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete an evaluation', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const cacheKey = getStandaloneEvalCacheKey();
|
|
setCachedStandaloneEvals(cacheKey, []);
|
|
|
|
const eval_ = await Eval.findById(eval1.id);
|
|
expect(eval_).toBeDefined();
|
|
expect(getCachedStandaloneEvals(cacheKey)).toBeDefined();
|
|
|
|
await eval1.delete();
|
|
|
|
const eval_2 = await Eval.findById(eval1.id);
|
|
expect(eval_2).toBeUndefined();
|
|
expect(getCachedStandaloneEvals(cacheKey)).toBeUndefined();
|
|
expect(updateSignalFileForDeletedEvals).toHaveBeenCalledWith([eval1.id]);
|
|
});
|
|
|
|
it('should delete traces and spans for an evaluation', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const traceStore = new TraceStore();
|
|
await traceStore.createTrace({
|
|
traceId: 'trace-to-delete',
|
|
evaluationId: eval1.id,
|
|
testCaseId: 'test-case-id',
|
|
});
|
|
await traceStore.addSpans('trace-to-delete', [
|
|
{
|
|
spanId: 'span-to-delete',
|
|
name: 'test-span',
|
|
startTime: 1,
|
|
},
|
|
]);
|
|
|
|
await eval1.delete();
|
|
|
|
const db = await getDb();
|
|
expect(await Eval.findById(eval1.id)).toBeUndefined();
|
|
await expect(db.select().from(tracesTable).all()).resolves.toHaveLength(0);
|
|
await expect(db.select().from(spansTable).all()).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it('should suppress deletion signals while replacing an evaluation', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
|
|
await eval1.delete({ notify: false });
|
|
|
|
expect(await Eval.findById(eval1.id)).toBeUndefined();
|
|
expect(updateSignalFileForDeletedEvals).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should use provided author when available', async () => {
|
|
const providedAuthor = 'provided@example.com';
|
|
// Spy must not be called — opts.author is explicit, so getAuthor() is bypassed.
|
|
vi.mocked(getAuthor).mockReturnValue('should-not-be-used@example.com');
|
|
const config = { description: 'Test eval' };
|
|
const renderedPrompts: Prompt[] = [
|
|
{ raw: 'Test prompt', display: 'Test prompt', label: 'Test label' } as Prompt,
|
|
];
|
|
const evaluation = await Eval.create(config, renderedPrompts, { author: providedAuthor });
|
|
expect(evaluation.author).toBe(providedAuthor);
|
|
expect(vi.mocked(getAuthor)).not.toHaveBeenCalled();
|
|
const persistedEval = await Eval.findById(evaluation.id);
|
|
expect(persistedEval?.author).toBe(providedAuthor);
|
|
});
|
|
|
|
it('should honor an explicit author even when the current user is cloud-authed (import regression)', async () => {
|
|
// Simulate cloud-authed user with a different identity than the imported eval.
|
|
vi.mocked(getAuthor).mockReturnValue('current-cloud-user@example.com');
|
|
const config = { description: 'Imported eval' };
|
|
const renderedPrompts: Prompt[] = [
|
|
{ raw: 'Test prompt', display: 'Test prompt', label: 'Test label' } as Prompt,
|
|
];
|
|
const evaluation = await Eval.create(config, renderedPrompts, {
|
|
author: 'original-author@example.com',
|
|
});
|
|
expect(evaluation.author).toBe('original-author@example.com');
|
|
expect(vi.mocked(getAuthor)).not.toHaveBeenCalled();
|
|
const persistedEval = await Eval.findById(evaluation.id);
|
|
expect(persistedEval?.author).toBe('original-author@example.com');
|
|
});
|
|
|
|
it('should persist null when author is explicitly null', async () => {
|
|
vi.mocked(getAuthor).mockReturnValue('should-not-be-used@example.com');
|
|
const config = { description: 'Test eval' };
|
|
const renderedPrompts: Prompt[] = [
|
|
{ raw: 'Test prompt', display: 'Test prompt', label: 'Test label' } as Prompt,
|
|
];
|
|
const evaluation = await Eval.create(config, renderedPrompts, { author: null });
|
|
expect(evaluation.author).toBeNull();
|
|
expect(vi.mocked(getAuthor)).not.toHaveBeenCalled();
|
|
const persistedEval = await Eval.findById(evaluation.id);
|
|
expect(persistedEval?.author).toBeNull();
|
|
});
|
|
|
|
it('should fall back to getAuthor when author is not provided', async () => {
|
|
const mockAuthor = 'default@example.com';
|
|
vi.mocked(getAuthor).mockReturnValue(mockAuthor);
|
|
const config = { description: 'Test eval' };
|
|
const renderedPrompts: Prompt[] = [
|
|
{ raw: 'Test prompt', display: 'Test prompt', label: 'Test label' } as Prompt,
|
|
];
|
|
const evaluation = await Eval.create(config, renderedPrompts);
|
|
expect(evaluation.author).toBe(mockAuthor);
|
|
const persistedEval = await Eval.findById(evaluation.id);
|
|
expect(persistedEval?.author).toBe(mockAuthor);
|
|
});
|
|
|
|
it('preserves trace linkage when results are inserted during eval creation', async () => {
|
|
const tracedResult = createEvaluateResult({
|
|
traceId: 'create-trace-id',
|
|
evaluationId: 'create-evaluation-id',
|
|
metadata: { source: 'create-path' },
|
|
});
|
|
|
|
const evaluation = await Eval.create({ description: 'Trace linkage create coverage' }, [], {
|
|
results: [tracedResult as unknown as EvalResult],
|
|
});
|
|
|
|
const [persistedResult] = await EvalResult.findManyByEvalId(evaluation.id);
|
|
expect(persistedResult.toEvaluateResult()).toMatchObject({
|
|
traceId: 'create-trace-id',
|
|
evaluationId: 'create-evaluation-id',
|
|
metadata: { source: 'create-path' },
|
|
});
|
|
});
|
|
|
|
it('surfaces trace linkage without leaking the reserved namespace through toEvaluateSummary (export path)', async () => {
|
|
// output.ts serializes JSON/JSONL/CSV via toEvaluateSummary(); assert that path surfaces
|
|
// traceId/evaluationId at the top level and never emits the internal `__promptfoo` key.
|
|
const tracedResult = createEvaluateResult({
|
|
traceId: 'export-trace-id',
|
|
evaluationId: 'export-evaluation-id',
|
|
metadata: { source: 'export-path' },
|
|
});
|
|
|
|
const evaluation = await Eval.create({ description: 'Trace linkage export coverage' }, [], {
|
|
results: [tracedResult as unknown as EvalResult],
|
|
});
|
|
|
|
const summary = await evaluation.toEvaluateSummary();
|
|
expect('results' in summary).toBe(true);
|
|
const [exportedRow] = (summary as { results: EvaluateResult[] }).results;
|
|
expect(exportedRow).toMatchObject({
|
|
traceId: 'export-trace-id',
|
|
evaluationId: 'export-evaluation-id',
|
|
metadata: { source: 'export-path' },
|
|
});
|
|
expect(exportedRow.metadata).not.toHaveProperty('__promptfoo');
|
|
expect(JSON.stringify(summary)).not.toContain('__promptfoo');
|
|
});
|
|
});
|
|
|
|
describe('setResults trace linkage', () => {
|
|
it('preserves trace linkage when results are appended to an existing eval', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 0 });
|
|
const tracedResult = createEvaluateResult({
|
|
traceId: 'append-trace-id',
|
|
evaluationId: 'append-evaluation-id',
|
|
metadata: { source: 'set-results-path' },
|
|
});
|
|
|
|
await eval_.setResults([
|
|
{
|
|
id: 'append-trace-result',
|
|
evalId: eval_.id,
|
|
...tracedResult,
|
|
failureReason: ResultFailureReason.NONE,
|
|
persisted: false,
|
|
} as unknown as EvalResult,
|
|
]);
|
|
|
|
const [persistedResult] = await EvalResult.findManyByEvalId(eval_.id);
|
|
expect(persistedResult.toEvaluateResult()).toMatchObject({
|
|
traceId: 'append-trace-id',
|
|
evaluationId: 'append-evaluation-id',
|
|
metadata: { source: 'set-results-path' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('copy', () => {
|
|
it('drops trace linkage from copied results without copied trace records', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 0 });
|
|
await EvalResult.createFromEvaluateResult(
|
|
eval_.id,
|
|
createEvaluateResult({
|
|
traceId: 'copy-source-trace',
|
|
evaluationId: eval_.id,
|
|
metadata: {
|
|
source: 'copy-path',
|
|
__promptfoo: { retained: 'internal-metadata' },
|
|
},
|
|
}),
|
|
);
|
|
|
|
const copy = await eval_.copy();
|
|
const [copiedResult] = await EvalResult.findManyByEvalId(copy.id);
|
|
|
|
expect(copiedResult.toEvaluateResult()).toMatchObject({
|
|
metadata: {
|
|
source: 'copy-path',
|
|
__promptfoo: { retained: 'internal-metadata' },
|
|
},
|
|
});
|
|
expect(copiedResult.toEvaluateResult().traceId).toBeUndefined();
|
|
expect(copiedResult.toEvaluateResult().evaluationId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should handle empty vars array', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.vars).toEqual([]);
|
|
});
|
|
|
|
it('should backfill vars from eval results when vars array is empty', async () => {
|
|
// This will create eval results with vars in test_case
|
|
const eval1 = await EvalFactory.create({
|
|
numResults: 2,
|
|
// @ts-expect-error: injectVarsInResults is for test factory only
|
|
injectVarsInResults: true,
|
|
});
|
|
|
|
// Remove vars from the evals table to trigger backfill
|
|
const db = await getDb();
|
|
// Drizzle's .run() does not support ? params for this case, so interpolate directly
|
|
await db.run(`UPDATE evals SET vars = json('[]') WHERE id = '${eval1.id}'`);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.vars.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should store backfilled vars in database', async () => {
|
|
const eval1 = await EvalFactory.create({
|
|
numResults: 2,
|
|
// @ts-expect-error: injectVarsInResults is for test factory only
|
|
injectVarsInResults: true,
|
|
});
|
|
|
|
// Remove vars from the evals table to trigger backfill
|
|
const db = await getDb();
|
|
await db.run(`UPDATE evals SET vars = json('[]') WHERE id = '${eval1.id}'`);
|
|
|
|
const persistedEval1 = await Eval.findById(eval1.id);
|
|
const vars = persistedEval1?.vars || [];
|
|
expect(vars.length).toBeGreaterThan(0);
|
|
|
|
// Now, after backfilling, the next load should get the same vars from db
|
|
const persistedEval2 = await Eval.findById(eval1.id);
|
|
expect(persistedEval2?.vars).toEqual(vars);
|
|
});
|
|
|
|
it('should handle NaN durationMs in database by returning undefined', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Inject NaN as durationMs in the results column
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = json_set(results, '$.durationMs', 'NaN') WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
const stats = persistedEval?.getStats();
|
|
// NaN should be filtered out, resulting in undefined
|
|
expect(stats?.durationMs).toBeUndefined();
|
|
});
|
|
|
|
it('should handle negative durationMs in database by returning undefined', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Inject negative number as durationMs
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = json_set(results, '$.durationMs', -5000) WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
const stats = persistedEval?.getStats();
|
|
// Negative should be filtered out, resulting in undefined
|
|
expect(stats?.durationMs).toBeUndefined();
|
|
});
|
|
|
|
it('should handle string durationMs in database by returning undefined', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Inject string as durationMs
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = json_set(results, '$.durationMs', '"not a number"') WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
const stats = persistedEval?.getStats();
|
|
// String should be filtered out, resulting in undefined
|
|
expect(stats?.durationMs).toBeUndefined();
|
|
});
|
|
|
|
it('should preserve valid durationMs from database', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Inject valid durationMs
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = json_set(results, '$.durationMs', 12345) WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
const stats = persistedEval?.getStats();
|
|
expect(stats?.durationMs).toBe(12345);
|
|
});
|
|
|
|
it('should extract generationDurationMs and evaluationDurationMs from database', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = '${JSON.stringify({ durationMs: 15000, generationDurationMs: 10000, evaluationDurationMs: 5000 })}' WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.generationDurationMs).toBe(10000);
|
|
expect(persistedEval?.evaluationDurationMs).toBe(5000);
|
|
expect(persistedEval?.durationMs).toBe(15000);
|
|
});
|
|
|
|
it('should handle missing generationDurationMs and evaluationDurationMs in database', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = '${JSON.stringify({ durationMs: 5000 })}' WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.generationDurationMs).toBeUndefined();
|
|
expect(persistedEval?.evaluationDurationMs).toBeUndefined();
|
|
expect(persistedEval?.durationMs).toBe(5000);
|
|
});
|
|
|
|
it('should handle invalid generationDurationMs in database by returning undefined', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = '${JSON.stringify({ durationMs: 5000, generationDurationMs: -100, evaluationDurationMs: 'bad' })}' WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.generationDurationMs).toBeUndefined();
|
|
expect(persistedEval?.evaluationDurationMs).toBeUndefined();
|
|
expect(persistedEval?.durationMs).toBe(5000);
|
|
});
|
|
|
|
it('should recompute durationMs from split fields when durationMs is missing', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = '${JSON.stringify({ generationDurationMs: 10000, evaluationDurationMs: 5000 })}' WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.generationDurationMs).toBe(10000);
|
|
expect(persistedEval?.evaluationDurationMs).toBe(5000);
|
|
expect(persistedEval?.durationMs).toBe(15000);
|
|
});
|
|
});
|
|
|
|
describe('save with duration fields', () => {
|
|
it('should persist all three duration fields in results JSON', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
eval1.setDurationMs(5000);
|
|
eval1.setGenerationDurationMs(10000);
|
|
await eval1.save();
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.durationMs).toBe(15000);
|
|
expect(persistedEval?.generationDurationMs).toBe(10000);
|
|
expect(persistedEval?.evaluationDurationMs).toBe(5000);
|
|
});
|
|
|
|
it('should preserve existing keys in results JSON when saving duration fields', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Seed the results column with an extra key (simulating future fields or other data)
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE evals SET results = '${JSON.stringify({ someOtherKey: 'preserve-me' })}' WHERE id = '${eval1.id}'`,
|
|
);
|
|
|
|
eval1.setDurationMs(5000);
|
|
await eval1.save();
|
|
|
|
// Verify duration fields were written AND the extra key survived
|
|
const row = await db.get<{ results: string }>(
|
|
`SELECT results FROM evals WHERE id = '${eval1.id}'`,
|
|
);
|
|
const results = JSON.parse(row!.results);
|
|
expect(results.someOtherKey).toBe('preserve-me');
|
|
expect(results.durationMs).toBe(5000);
|
|
expect(results.evaluationDurationMs).toBe(5000);
|
|
});
|
|
|
|
it('should recover from malformed JSON in results column', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Corrupt the results column with invalid JSON
|
|
const db = await getDb();
|
|
await db.run(`UPDATE evals SET results = 'not-valid-json' WHERE id = '${eval1.id}'`);
|
|
|
|
eval1.setDurationMs(5000);
|
|
await eval1.save();
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.durationMs).toBe(5000);
|
|
expect(persistedEval?.evaluationDurationMs).toBe(5000);
|
|
});
|
|
|
|
it('should recover from non-object JSON in results column', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
// Set results to a valid JSON array (non-object)
|
|
const db = await getDb();
|
|
await db.run(`UPDATE evals SET results = '[]' WHERE id = '${eval1.id}'`);
|
|
|
|
eval1.setDurationMs(3000);
|
|
await eval1.save();
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.durationMs).toBe(3000);
|
|
expect(persistedEval?.evaluationDurationMs).toBe(3000);
|
|
});
|
|
|
|
it('should persist only evaluationDurationMs and durationMs for non-redteam evals', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 0 });
|
|
|
|
eval1.setDurationMs(5000);
|
|
await eval1.save();
|
|
|
|
const persistedEval = await Eval.findById(eval1.id);
|
|
expect(persistedEval?.durationMs).toBe(5000);
|
|
expect(persistedEval?.evaluationDurationMs).toBe(5000);
|
|
expect(persistedEval?.generationDurationMs).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getStats', () => {
|
|
it('should accumulate assertion token usage correctly', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.prompts = [
|
|
{
|
|
raw: 'test',
|
|
metrics: {
|
|
tokenUsage: {
|
|
prompt: 10,
|
|
completion: 20,
|
|
cached: 5,
|
|
total: 35,
|
|
numRequests: 1,
|
|
assertions: {
|
|
total: 100,
|
|
prompt: 40,
|
|
completion: 50,
|
|
cached: 10,
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
{
|
|
raw: 'test2',
|
|
metrics: {
|
|
tokenUsage: {
|
|
prompt: 15,
|
|
completion: 25,
|
|
cached: 10,
|
|
total: 50,
|
|
numRequests: 1,
|
|
assertions: {
|
|
total: 200,
|
|
prompt: 80,
|
|
completion: 100,
|
|
cached: 20,
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.tokenUsage.assertions).toEqual({
|
|
total: 300,
|
|
prompt: 120,
|
|
completion: 150,
|
|
cached: 30,
|
|
numRequests: 0,
|
|
completionDetails: {
|
|
reasoning: 0,
|
|
acceptedPrediction: 0,
|
|
rejectedPrediction: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle missing assertion token usage', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.prompts = [
|
|
{
|
|
raw: 'test',
|
|
metrics: {
|
|
tokenUsage: {
|
|
prompt: 10,
|
|
completion: 20,
|
|
cached: 5,
|
|
total: 35,
|
|
numRequests: 1,
|
|
},
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.tokenUsage.assertions).toEqual({
|
|
total: 0,
|
|
prompt: 0,
|
|
completion: 0,
|
|
cached: 0,
|
|
numRequests: 0,
|
|
completionDetails: {
|
|
reasoning: 0,
|
|
acceptedPrediction: 0,
|
|
rejectedPrediction: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle mix of prompts with and without assertion usage', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.prompts = [
|
|
{
|
|
raw: 'test1',
|
|
metrics: {
|
|
tokenUsage: {
|
|
prompt: 10,
|
|
completion: 20,
|
|
cached: 5,
|
|
total: 35,
|
|
numRequests: 1,
|
|
assertions: {
|
|
total: 100,
|
|
prompt: 40,
|
|
completion: 50,
|
|
cached: 10,
|
|
},
|
|
},
|
|
},
|
|
} as any,
|
|
{
|
|
raw: 'test2',
|
|
metrics: {
|
|
tokenUsage: {
|
|
prompt: 15,
|
|
completion: 25,
|
|
cached: 10,
|
|
total: 50,
|
|
numRequests: 1,
|
|
},
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.tokenUsage.assertions).toEqual({
|
|
total: 100,
|
|
prompt: 40,
|
|
completion: 50,
|
|
cached: 10,
|
|
numRequests: 0,
|
|
completionDetails: {
|
|
reasoning: 0,
|
|
acceptedPrediction: 0,
|
|
rejectedPrediction: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should include durationMs and evaluationDurationMs when setDurationMs is called', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.setDurationMs(12345);
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.durationMs).toBe(12345);
|
|
expect(stats.evaluationDurationMs).toBe(12345);
|
|
});
|
|
|
|
it('should return undefined for all duration fields when not set', () => {
|
|
const eval1 = new Eval({});
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.durationMs).toBeUndefined();
|
|
expect(stats.generationDurationMs).toBeUndefined();
|
|
expect(stats.evaluationDurationMs).toBeUndefined();
|
|
});
|
|
|
|
it('should preserve durationMs when passed via constructor', () => {
|
|
const eval1 = new Eval({}, { durationMs: 54321 });
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.durationMs).toBe(54321);
|
|
});
|
|
|
|
it('should compute total from generation + evaluation durations', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.setDurationMs(5000); // evaluation phase
|
|
eval1.setGenerationDurationMs(10000); // generation phase
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.generationDurationMs).toBe(10000);
|
|
expect(stats.evaluationDurationMs).toBe(5000);
|
|
expect(stats.durationMs).toBe(15000);
|
|
});
|
|
|
|
it('should handle setGenerationDurationMs called before setDurationMs', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.setGenerationDurationMs(10000);
|
|
|
|
// durationMs should reflect generation even before evaluation is set
|
|
expect(eval1.generationDurationMs).toBe(10000);
|
|
expect(eval1.evaluationDurationMs).toBeUndefined();
|
|
expect(eval1.durationMs).toBe(10000);
|
|
|
|
eval1.setDurationMs(5000);
|
|
expect(eval1.durationMs).toBe(15000);
|
|
});
|
|
|
|
it('should ignore invalid duration values', () => {
|
|
const eval1 = new Eval({});
|
|
eval1.setDurationMs(5000);
|
|
|
|
eval1.setDurationMs(NaN);
|
|
expect(eval1.evaluationDurationMs).toBe(5000);
|
|
|
|
eval1.setDurationMs(-1);
|
|
expect(eval1.evaluationDurationMs).toBe(5000);
|
|
|
|
eval1.setDurationMs(Infinity);
|
|
expect(eval1.evaluationDurationMs).toBe(5000);
|
|
|
|
eval1.setGenerationDurationMs(NaN);
|
|
expect(eval1.generationDurationMs).toBeUndefined();
|
|
|
|
eval1.setGenerationDurationMs(-100);
|
|
expect(eval1.generationDurationMs).toBeUndefined();
|
|
});
|
|
|
|
it('should include generationDurationMs and evaluationDurationMs in stats', () => {
|
|
const eval1 = new Eval(
|
|
{},
|
|
{ durationMs: 15000, generationDurationMs: 10000, evaluationDurationMs: 5000 },
|
|
);
|
|
|
|
const stats = eval1.getStats();
|
|
expect(stats.generationDurationMs).toBe(10000);
|
|
expect(stats.evaluationDurationMs).toBe(5000);
|
|
expect(stats.durationMs).toBe(15000);
|
|
});
|
|
});
|
|
|
|
describe('toResultsFile', () => {
|
|
it('should return results file with correct version', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const results = await eval1.toResultsFile();
|
|
expect(results.version).toBe(eval1.version());
|
|
});
|
|
|
|
it('should return results file with all required fields', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const results = await eval1.toResultsFile();
|
|
|
|
expect(results).toEqual({
|
|
version: eval1.version(),
|
|
createdAt: new Date(eval1.createdAt).toISOString(),
|
|
config: eval1.config,
|
|
author: null,
|
|
prompts: eval1.getPrompts(),
|
|
...(eval1.vars.length > 0 && { vars: eval1.vars }),
|
|
datasetId: null,
|
|
results: await eval1.toEvaluateSummary(),
|
|
});
|
|
});
|
|
|
|
it('should include persisted variable display order', async () => {
|
|
const eval1 = new Eval({}, { vars: ['zebra', 'apple'] });
|
|
|
|
expect((await eval1.toResultsFile()).vars).toEqual(['zebra', 'apple']);
|
|
});
|
|
|
|
it('omits the vars field entirely when no variable order is persisted', async () => {
|
|
const eval1 = new Eval({});
|
|
|
|
const results = await eval1.toResultsFile();
|
|
|
|
expect(results).not.toHaveProperty('vars');
|
|
});
|
|
|
|
it('should handle null author and datasetId', async () => {
|
|
const eval1 = new Eval({});
|
|
const results = await eval1.toResultsFile();
|
|
|
|
expect(results.author).toBeNull();
|
|
expect(results.datasetId).toBeNull();
|
|
});
|
|
|
|
it('should include correct results summary', async () => {
|
|
const eval1 = await EvalFactory.create();
|
|
const results = await eval1.toResultsFile();
|
|
|
|
expect(results.results).toEqual(await eval1.toEvaluateSummary());
|
|
});
|
|
});
|
|
|
|
describe('getTablePage', () => {
|
|
let evalWithResults: Eval;
|
|
|
|
beforeEach(async () => {
|
|
// Create an evaluation with varied results for testing pagination and filtering
|
|
evalWithResults = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
withHighlights: true,
|
|
withNamedScores: true,
|
|
searchableContent: 'searchable_content',
|
|
});
|
|
});
|
|
|
|
it('should return paginated results with default parameters', async () => {
|
|
const result = await evalWithResults.getTablePage({ filters: [] });
|
|
|
|
expect(result).toHaveProperty('head');
|
|
expect(result).toHaveProperty('body');
|
|
expect(result).toHaveProperty('totalCount');
|
|
expect(result).toHaveProperty('filteredCount');
|
|
expect(result).toHaveProperty('id', evalWithResults.id);
|
|
|
|
// Default limit is 50
|
|
expect(result.body.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it('should respect offset and limit parameters', async () => {
|
|
// Create an eval with enough results to ensure we have content for two pages
|
|
const largeEval = await EvalFactory.create({
|
|
numResults: 12,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const firstPage = await largeEval.getTablePage({ offset: 0, limit: 5, filters: [] });
|
|
const secondPage = await largeEval.getTablePage({ offset: 5, limit: 5, filters: [] });
|
|
|
|
expect(firstPage.body.length).toBeLessThanOrEqual(5);
|
|
expect(secondPage.body.length).toBeLessThanOrEqual(5);
|
|
|
|
// Ensure we have content in both pages for a valid test
|
|
expect(firstPage.body.length).toBeGreaterThan(0);
|
|
expect(secondPage.body.length).toBeGreaterThan(0);
|
|
|
|
// Compare test indices between pages to ensure they're different
|
|
const firstPageIndices = firstPage.body.map((row) => row.testIdx);
|
|
const secondPageIndices = secondPage.body.map((row) => row.testIdx);
|
|
|
|
// Verify no overlap between page indices
|
|
const intersection = firstPageIndices.filter((idx) => secondPageIndices.includes(idx));
|
|
expect(intersection).toHaveLength(0);
|
|
});
|
|
|
|
it('should filter by errors', async () => {
|
|
const result = await evalWithResults.getTablePage({ filterMode: 'errors', filters: [] });
|
|
|
|
// Ensure there are results for the test to be meaningful
|
|
const hasResults = result.body.length > 0;
|
|
expect(hasResults).toBe(true);
|
|
|
|
// Check all outputs for errors
|
|
for (const row of result.body) {
|
|
const hasError = row.outputs.some(
|
|
(output) =>
|
|
output.error?.includes('error') ||
|
|
output.text.includes('error') ||
|
|
(output.gradingResult?.reason || '').includes('error'),
|
|
);
|
|
expect(hasError).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should filter by failures', async () => {
|
|
const result = await evalWithResults.getTablePage({ filterMode: 'failures', filters: [] });
|
|
|
|
// Ensure there are results for the test to be meaningful
|
|
const hasResults = result.body.length > 0;
|
|
expect(hasResults).toBe(true);
|
|
|
|
// All results should contain at least one failed output that's not an error
|
|
for (const row of result.body) {
|
|
const hasFailure = row.outputs.some(
|
|
(output) => !output.pass && !output.text.includes('error'),
|
|
);
|
|
expect(hasFailure).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should filter by passes', async () => {
|
|
const result = await evalWithResults.getTablePage({ filterMode: 'passes', filters: [] });
|
|
|
|
// Ensure there are results for the test to be meaningful
|
|
const hasResults = result.body.length > 0;
|
|
expect(hasResults).toBe(true);
|
|
|
|
// All results should contain at least one successful output
|
|
for (const row of result.body) {
|
|
const hasSuccess = row.outputs.some((output) => output.pass === true);
|
|
expect(hasSuccess).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should filter by specific test indices', async () => {
|
|
const testIndices = [1, 3, 5];
|
|
const result = await evalWithResults.getTablePage({ testIndices, filters: [] });
|
|
|
|
// Should only return results for the specified test indices
|
|
const returnedIndices = result.body.map((row) => row.testIdx);
|
|
for (const idx of returnedIndices) {
|
|
expect(testIndices).toContain(idx);
|
|
}
|
|
|
|
// Should return at most the number of requested indices
|
|
expect(result.body.length).toBeLessThanOrEqual(testIndices.length);
|
|
});
|
|
|
|
it('should handle search queries across fields', async () => {
|
|
// This test requires setting up specific search terms in the eval factory
|
|
const searchTerm = 'searchable_content';
|
|
const result = await evalWithResults.getTablePage({ searchQuery: searchTerm, filters: [] });
|
|
|
|
// Results should contain the search term in at least one field
|
|
expect(result.body.length).toBeGreaterThan(0);
|
|
expect(result.filteredCount).toBeLessThan(result.totalCount);
|
|
});
|
|
|
|
it('should filter by specific metrics', async () => {
|
|
// This test requires setting up results with named scores in the eval factory
|
|
const result = await evalWithResults.getTablePage({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metric',
|
|
operator: 'equals',
|
|
value: 'accuracy',
|
|
}),
|
|
],
|
|
});
|
|
|
|
// All results should have the specified metric
|
|
for (const row of result.body) {
|
|
const hasMetric = row.outputs.some(
|
|
(output) => output.namedScores && output.namedScores['accuracy'] !== undefined,
|
|
);
|
|
expect(hasMetric).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should combine multiple filter types', async () => {
|
|
const result = await evalWithResults.getTablePage({
|
|
filterMode: 'passes',
|
|
searchQuery: 'searchable_content',
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metric',
|
|
operator: 'equals',
|
|
value: 'relevance',
|
|
}),
|
|
],
|
|
limit: 10,
|
|
});
|
|
|
|
// Results should satisfy all conditions and respect limit
|
|
expect(result.body.length).toBeLessThanOrEqual(10);
|
|
expect(result.filteredCount).toBeLessThan(result.totalCount);
|
|
});
|
|
|
|
it('should be filterable by multiple metrics', async () => {
|
|
const result = await evalWithResults.getTablePage({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'or',
|
|
type: 'metric',
|
|
operator: 'equals',
|
|
value: 'accuracy',
|
|
}),
|
|
JSON.stringify({
|
|
logicOperator: 'or',
|
|
type: 'metric',
|
|
operator: 'equals',
|
|
value: 'relevance',
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Row should have at least one of the metrics
|
|
for (const row of result.body) {
|
|
const hasMetric1 = row.outputs.some(
|
|
(output) => output.namedScores && output.namedScores['accuracy'] !== undefined,
|
|
);
|
|
const hasMetric2 = row.outputs.some(
|
|
(output) => output.namedScores && output.namedScores['relevance'] !== undefined,
|
|
);
|
|
expect(hasMetric1 || hasMetric2).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should return correct counts for filtered results', async () => {
|
|
const allResults = await evalWithResults.getTablePage({ filters: [] });
|
|
const filteredResults = await evalWithResults.getTablePage({
|
|
filterMode: 'passes',
|
|
filters: [],
|
|
});
|
|
|
|
// Total count should be the same for both queries
|
|
expect(filteredResults.totalCount).toBe(allResults.totalCount);
|
|
|
|
// Filtered count should be less than or equal to total count
|
|
expect(filteredResults.filteredCount).toBeLessThanOrEqual(filteredResults.totalCount);
|
|
});
|
|
|
|
it('should sanitize SQL inputs properly', async () => {
|
|
// Test with input containing SQL injection attempt
|
|
const result = await evalWithResults.getTablePage({
|
|
searchQuery: "'; DROP TABLE eval_results; --",
|
|
filters: [],
|
|
});
|
|
|
|
// Should still return results without error
|
|
expect(result).toBeDefined();
|
|
expect(result).toHaveProperty('body');
|
|
});
|
|
|
|
it('should handle empty result sets', async () => {
|
|
// Create an eval with no results
|
|
const emptyEval = await EvalFactory.create({ numResults: 0 });
|
|
|
|
const result = await emptyEval.getTablePage({ filters: [] });
|
|
|
|
expect(result.body).toEqual([]);
|
|
expect(result.totalCount).toBe(0);
|
|
expect(result.filteredCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('EvalQueries.getMetadataKeysFromEval', () => {
|
|
it('should return unique metadata keys from all eval results', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
// Add eval results with different metadata
|
|
const db = await getDb();
|
|
await db.run(
|
|
`INSERT INTO eval_results (
|
|
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
|
|
success, score, metadata
|
|
) VALUES
|
|
('result1', '${eval_.id}', 0, 0, '{}', '{}', '{}', 1, 1.0, '{"key1": "value1", "key2": "value2"}'),
|
|
('result2', '${eval_.id}', 0, 1, '{}', '{}', '{}', 1, 1.0, '{"key2": "value3", "key3": "value4"}'),
|
|
('result3', '${eval_.id}', 0, 2, '{}', '{}', '{}', 1, 1.0, '{"key1": "value5", "key4": "value6"}')`,
|
|
);
|
|
|
|
const keys = await EvalQueries.getMetadataKeysFromEval(eval_.id);
|
|
|
|
expect(keys).toEqual(['key1', 'key2', 'key3', 'key4']);
|
|
});
|
|
|
|
it('should return empty array for eval with no metadata', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const keys = await EvalQueries.getMetadataKeysFromEval(eval_.id);
|
|
|
|
expect(keys).toEqual([]);
|
|
});
|
|
|
|
it('should handle empty metadata objects gracefully', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
// Add eval result with empty metadata
|
|
const db = await getDb();
|
|
await db.run(
|
|
`INSERT INTO eval_results (
|
|
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
|
|
success, score, metadata
|
|
) VALUES
|
|
('result1', '${eval_.id}', 0, 0, '{}', '{}', '{}', 1, 1.0, '{}')`,
|
|
);
|
|
|
|
const keys = await EvalQueries.getMetadataKeysFromEval(eval_.id);
|
|
|
|
expect(keys).toEqual([]);
|
|
});
|
|
|
|
it('hides the reserved __promptfoo namespace from key listings', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`INSERT INTO eval_results (
|
|
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
|
|
success, score, metadata
|
|
) VALUES
|
|
('promptfoo-ns-1', '${eval_.id}', 0, 0, '{}', '{}', '{}', 1, 1.0,
|
|
'{"userKey": "shown", "__promptfoo": {"traceLinkage": {"traceId": "abc"}}}')`,
|
|
);
|
|
|
|
const keys = await EvalQueries.getMetadataKeysFromEval(eval_.id);
|
|
expect(keys).toContain('userKey');
|
|
expect(keys).not.toContain('__promptfoo');
|
|
});
|
|
});
|
|
|
|
describe('EvalQueries.getMetadataValuesFromEval', () => {
|
|
it('refuses to return values under the reserved __promptfoo namespace', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`INSERT INTO eval_results (
|
|
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
|
|
success, score, metadata
|
|
) VALUES
|
|
('promptfoo-val-1', '${eval_.id}', 0, 0, '{}', '{}', '{}', 1, 1.0,
|
|
'{"__promptfoo": {"traceLinkage": {"traceId": "abc"}}}')`,
|
|
);
|
|
|
|
expect(await EvalQueries.getMetadataValuesFromEval(eval_.id, '__promptfoo')).toEqual([]);
|
|
expect(
|
|
await EvalQueries.getMetadataValuesFromEval(eval_.id, '__promptfoo.traceLinkage'),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('EvalQueries.getVarsFromEvals', () => {
|
|
it('returns each evaluations var keys sorted alphabetically for stable list output', async () => {
|
|
const eval1 = await EvalFactory.create({ numResults: 1 });
|
|
const eval2 = await EvalFactory.create({ numResults: 1 });
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET test_case = json('{"vars":{"zebra":"z","apple":"a","mango":"m"}}') WHERE eval_id = '${eval1.id}'`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET test_case = json('{"vars":{"yellow":"y","banana":"b"}}') WHERE eval_id = '${eval2.id}'`,
|
|
);
|
|
|
|
const vars = await EvalQueries.getVarsFromEvals([eval1, eval2]);
|
|
|
|
expect(vars[eval1.id]).toEqual(['apple', 'mango', 'zebra']);
|
|
expect(vars[eval2.id]).toEqual(['banana', 'yellow']);
|
|
});
|
|
|
|
it('returns an empty object for an empty evals list', async () => {
|
|
const vars = await EvalQueries.getVarsFromEvals([]);
|
|
|
|
expect(vars).toEqual({});
|
|
});
|
|
|
|
it('omits evals whose test_case has no $.vars from the result map', async () => {
|
|
const evalWithVars = await EvalFactory.create({ numResults: 1 });
|
|
const evalWithoutVars = await EvalFactory.create({ numResults: 1 });
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET test_case = json('{"vars":{"foo":"f"}}') WHERE eval_id = '${evalWithVars.id}'`,
|
|
);
|
|
// Strip $.vars so json_each(t.vars) yields no rows for this eval_id.
|
|
await db.run(
|
|
`UPDATE eval_results SET test_case = json('{}') WHERE eval_id = '${evalWithoutVars.id}'`,
|
|
);
|
|
|
|
const vars = await EvalQueries.getVarsFromEvals([evalWithVars, evalWithoutVars]);
|
|
|
|
expect(vars[evalWithVars.id]).toEqual(['foo']);
|
|
expect(vars).not.toHaveProperty(evalWithoutVars.id);
|
|
});
|
|
});
|
|
|
|
describe('queryTestIndices', () => {
|
|
it('returns indices with default params and correct ordering', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
const { testIndices, filteredCount } = await (eval_ as any).queryTestIndices({});
|
|
|
|
expect(filteredCount).toBe(10);
|
|
expect(testIndices.length).toBeLessThanOrEqual(10);
|
|
// Ensure ascending order
|
|
const sorted = [...testIndices].sort((a, b) => a - b);
|
|
expect(testIndices).toEqual(sorted);
|
|
});
|
|
|
|
it('respects offset and limit', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 12,
|
|
resultTypes: ['success', 'failure', 'error'],
|
|
});
|
|
const page1 = await (eval_ as any).queryTestIndices({ offset: 0, limit: 5 });
|
|
const page2 = await (eval_ as any).queryTestIndices({ offset: 5, limit: 5 });
|
|
|
|
expect(page1.testIndices.length).toBeLessThanOrEqual(5);
|
|
expect(page2.testIndices.length).toBeLessThanOrEqual(5);
|
|
|
|
// Verify disjoint sets for the first 10 indices
|
|
const overlap = page1.testIndices.filter((i: number) => page2.testIndices.includes(i));
|
|
expect(overlap).toHaveLength(0);
|
|
});
|
|
|
|
it('Mode Filtering: filters by errors, failures, and passes', async () => {
|
|
const numResults = 15;
|
|
const eval_ = await EvalFactory.create({
|
|
numResults,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const errors = await (eval_ as any).queryTestIndices({ filterMode: 'errors' });
|
|
const failures = await (eval_ as any).queryTestIndices({ filterMode: 'failures' });
|
|
const passes = await (eval_ as any).queryTestIndices({ filterMode: 'passes' });
|
|
|
|
// With the cycling order, counts should roughly split by thirds
|
|
expect(errors.filteredCount).toBeGreaterThan(0);
|
|
expect(failures.filteredCount).toBeGreaterThan(0);
|
|
expect(passes.filteredCount).toBeGreaterThan(0);
|
|
|
|
// No index should appear in more than one mode simultaneously
|
|
const sErrors = new Set(errors.testIndices);
|
|
const sFailures = new Set(failures.testIndices);
|
|
const sPasses = new Set(passes.testIndices);
|
|
for (const idx of sErrors) {
|
|
expect(sFailures.has(idx)).toBe(false);
|
|
expect(sPasses.has(idx)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('filters by metric equals', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 8,
|
|
resultTypes: ['success', 'failure'],
|
|
withNamedScores: true,
|
|
});
|
|
const { testIndices, filteredCount } = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metric',
|
|
operator: 'equals',
|
|
value: 'accuracy',
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(testIndices.length).toBeGreaterThan(0);
|
|
expect(filteredCount).toBeGreaterThan(0);
|
|
// With named scores on every row, all indices should match
|
|
expect(filteredCount).toBe(8);
|
|
});
|
|
|
|
it('filters by metadata equals and contains', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 6,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"unit","note":"hello world"}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"integration"}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
|
|
// equals on source=unit should return only test 1
|
|
const eqRes = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'equals',
|
|
field: 'source',
|
|
value: 'unit',
|
|
}),
|
|
],
|
|
});
|
|
expect(eqRes.filteredCount).toBe(1);
|
|
expect(eqRes.testIndices).toEqual([1]);
|
|
|
|
// contains on note
|
|
const containsRes = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'contains',
|
|
field: 'note',
|
|
value: 'hello',
|
|
}),
|
|
],
|
|
});
|
|
expect(containsRes.filteredCount).toBe(1);
|
|
expect(containsRes.testIndices).toEqual([1]);
|
|
});
|
|
|
|
it('filters by metadata not_contains without dropping missing fields', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 4,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"note":"contains risky phrase"}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"note":"boring"}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"other":"risky"}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
|
|
const result = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'not_contains',
|
|
field: 'note',
|
|
value: 'risky',
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(result.filteredCount).toBe(3);
|
|
expect(result.testIndices).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('filters by metadata exists operator (non-empty values only)', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
// Set up test data with various field states
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"unit","note":"hello"}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"","note":"hello"}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":" ","note":"hello"}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"note":"hello"}') WHERE eval_id = '${eval_.id}' AND test_idx = 3`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":null,"note":"hello"}') WHERE eval_id = '${eval_.id}' AND test_idx = 4`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"integration","note":"hello"}') WHERE eval_id = '${eval_.id}' AND test_idx = 5`,
|
|
);
|
|
|
|
// exists should only return rows where source field has meaningful non-empty values
|
|
const existsRes = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'source',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Should only include test_idx 0 and 5 (has "unit" and "integration")
|
|
// Should exclude: empty string (1), whitespace (2), missing field (3), null (4)
|
|
expect(existsRes.filteredCount).toBe(2);
|
|
expect(existsRes.testIndices).toEqual([0, 5]);
|
|
});
|
|
|
|
it('filters by metadata exists operator with various data types', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
// Set up test data with different data types
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"count":42}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"count":0}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"active":true}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"active":false}') WHERE eval_id = '${eval_.id}' AND test_idx = 3`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"tags":["a","b"]}') WHERE eval_id = '${eval_.id}' AND test_idx = 4`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"config":{"key":"value"}}') WHERE eval_id = '${eval_.id}' AND test_idx = 5`,
|
|
);
|
|
|
|
// Test exists for numeric field (should include both 42 and 0)
|
|
const countExists = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'count',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(countExists.filteredCount).toBe(2);
|
|
expect(countExists.testIndices).toEqual([0, 1]);
|
|
|
|
// Test exists for boolean field (should include both true and false)
|
|
const activeExists = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'active',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(activeExists.filteredCount).toBe(2);
|
|
expect(activeExists.testIndices).toEqual([2, 3]);
|
|
|
|
// Test exists for array field
|
|
const tagsExists = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'tags',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(tagsExists.filteredCount).toBe(1);
|
|
expect(tagsExists.testIndices).toEqual([4]);
|
|
|
|
// Test exists for object field
|
|
const configExists = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'config',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(configExists.filteredCount).toBe(1);
|
|
expect(configExists.testIndices).toEqual([5]);
|
|
});
|
|
|
|
it('filters by metadata with special characters in field names (security test)', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 5,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
// Test metadata keys with quotes and backslashes that could cause JSON path injection
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"field\\"with\\"quotes":"value1"}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"field\\\\with\\\\backslashes":"value2"}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"normal_field":"value3"}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
const attackField = `field"'; DROP TABLE eval_results; --`;
|
|
await db
|
|
.update(evalResultsTable)
|
|
.set({ metadata: { [attackField]: 'value4' } })
|
|
.where(sql`${evalResultsTable.evalId} = ${eval_.id} AND ${evalResultsTable.testIdx} = ${3}`)
|
|
.run();
|
|
|
|
// Test equals filter with quotes in field name
|
|
const quotesResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'equals',
|
|
field: 'field"with"quotes',
|
|
value: 'value1',
|
|
}),
|
|
],
|
|
});
|
|
expect(quotesResult.filteredCount).toBe(1);
|
|
expect(quotesResult.testIndices).toEqual([0]);
|
|
|
|
// Test equals filter with backslashes in field name
|
|
const backslashResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'equals',
|
|
field: 'field\\with\\backslashes',
|
|
value: 'value2',
|
|
}),
|
|
],
|
|
});
|
|
expect(backslashResult.filteredCount).toBe(1);
|
|
expect(backslashResult.testIndices).toEqual([1]);
|
|
|
|
// Test exists filter with quotes in field name
|
|
const existsQuotesResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'field"with"quotes',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(existsQuotesResult.filteredCount).toBe(1);
|
|
expect(existsQuotesResult.testIndices).toEqual([0]);
|
|
|
|
// Test exists filter with backslashes in field name
|
|
const existsBackslashResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'field\\with\\backslashes',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(existsBackslashResult.filteredCount).toBe(1);
|
|
expect(existsBackslashResult.testIndices).toEqual([1]);
|
|
|
|
// Field names stay bound JSON paths even when they look like SQL.
|
|
const attackResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'equals',
|
|
field: attackField,
|
|
value: 'value4',
|
|
}),
|
|
],
|
|
});
|
|
expect(attackResult.filteredCount).toBe(1);
|
|
expect(attackResult.testIndices).toEqual([3]);
|
|
|
|
// The eval results table remains queryable after the attack-shaped field is filtered.
|
|
const tableAfterAttack = await eval_.getTablePage({ filters: [] });
|
|
expect(tableAfterAttack.totalCount).toBe(5);
|
|
});
|
|
|
|
it('filters by metadata exists operator with empty arrays and objects', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 6,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
// Test empty array - should match (not empty)
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"arrayField":[]}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
// Test empty object - should match (not empty)
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"objectField":{}}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
// Test non-empty array - should match
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"arrayField":["item"]}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
// Test non-empty object - should match
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"objectField":{"key":"value"}}') WHERE eval_id = '${eval_.id}' AND test_idx = 3`,
|
|
);
|
|
// Test null field - should not match
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"nullField":null}') WHERE eval_id = '${eval_.id}' AND test_idx = 4`,
|
|
);
|
|
// Test missing field - should not match
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"otherField":"value"}') WHERE eval_id = '${eval_.id}' AND test_idx = 5`,
|
|
);
|
|
|
|
// Test exists filter with empty array - should match because [] is not empty
|
|
const emptyArrayResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'arrayField',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(emptyArrayResult.filteredCount).toBe(2); // Both empty and non-empty arrays
|
|
expect(emptyArrayResult.testIndices).toEqual([0, 2]);
|
|
|
|
// Test exists filter with empty object - should match because {} is not empty
|
|
const emptyObjectResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'objectField',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(emptyObjectResult.filteredCount).toBe(2); // Both empty and non-empty objects
|
|
expect(emptyObjectResult.testIndices).toEqual([1, 3]);
|
|
|
|
// Test exists filter with null field - should not match
|
|
const nullFieldResult = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'exists',
|
|
field: 'nullField',
|
|
value: '',
|
|
}),
|
|
],
|
|
});
|
|
expect(nullFieldResult.filteredCount).toBe(0); // null should not match exists
|
|
expect(nullFieldResult.testIndices).toEqual([]);
|
|
});
|
|
|
|
it('filters by plugin and strategy', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 6,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
const db = await getDb();
|
|
// Set pluginId on one row and strategyId on another
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"pluginId":"harmful:harassment"}') WHERE eval_id = '${eval_.id}' AND test_idx = 3`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"strategyId":"s1"}') WHERE eval_id = '${eval_.id}' AND test_idx = 5`,
|
|
);
|
|
|
|
// Category filter should match pluginId that starts with harmful:
|
|
const pluginCategory = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'plugin',
|
|
operator: 'equals',
|
|
value: 'harmful',
|
|
}),
|
|
],
|
|
});
|
|
expect(pluginCategory.testIndices).toEqual([3]);
|
|
|
|
// Strategy equals specific id
|
|
const strategyEq = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'strategy',
|
|
operator: 'equals',
|
|
value: 's1',
|
|
}),
|
|
],
|
|
});
|
|
expect(strategyEq.testIndices).toEqual([5]);
|
|
});
|
|
|
|
it('filters plugin results with not_equals operator for ids and categories', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 5,
|
|
resultTypes: ['success'],
|
|
});
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"pluginId":"harmful:harassment"}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"pluginId":"bias:toxicity"}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"pluginId":"custom-plugin"}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
// Leave test_idx = 3 without pluginId to ensure nulls are included
|
|
|
|
const excludeSpecificPlugin = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'plugin',
|
|
operator: 'not_equals',
|
|
value: 'custom-plugin',
|
|
}),
|
|
],
|
|
});
|
|
expect(excludeSpecificPlugin.testIndices).toEqual([0, 1, 3, 4]);
|
|
|
|
const excludeCategory = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'plugin',
|
|
operator: 'not_equals',
|
|
value: 'harmful',
|
|
}),
|
|
],
|
|
});
|
|
expect(excludeCategory.testIndices).toEqual([1, 2, 3, 4]);
|
|
});
|
|
|
|
it('filters by explicit severity override', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 4,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"severity":"high"}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
|
|
const { testIndices, filteredCount } = await (eval_ as any).queryTestIndices({
|
|
filters: [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'severity',
|
|
operator: 'equals',
|
|
value: 'high',
|
|
}),
|
|
],
|
|
});
|
|
expect(filteredCount).toBe(1);
|
|
expect(testIndices).toEqual([0]);
|
|
});
|
|
|
|
it('searches across response, grading, named scores, metadata, and vars', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 9,
|
|
resultTypes: ['success', 'failure', 'error'],
|
|
withNamedScores: true,
|
|
searchableContent: 'needle',
|
|
});
|
|
const { testIndices, filteredCount } = await (eval_ as any).queryTestIndices({
|
|
searchQuery: 'needle',
|
|
});
|
|
|
|
expect(testIndices.length).toBeGreaterThan(0);
|
|
expect(filteredCount).toBeGreaterThan(0);
|
|
// Sanity: limit should still apply
|
|
const limited = await (eval_ as any).queryTestIndices({ searchQuery: 'needle', limit: 2 });
|
|
expect(limited.testIndices.length).toBeLessThanOrEqual(2);
|
|
expect(limited.filteredCount).toBeGreaterThanOrEqual(limited.testIndices.length);
|
|
});
|
|
});
|
|
|
|
describe('getTraces', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should return traces with properly formatted data', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const mockTraces = [
|
|
{
|
|
traceId: 'trace-123',
|
|
evaluationId: eval_.id,
|
|
testCaseId: 'test-case-1',
|
|
metadata: { test: 'value' },
|
|
spans: [
|
|
{
|
|
spanId: 'span-1',
|
|
name: 'Test Span',
|
|
startTime: 1000000000,
|
|
endTime: 2000000000,
|
|
statusCode: 1,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const mockTraceStore = {
|
|
getTracesByEvaluation: vi.fn().mockResolvedValue(mockTraces),
|
|
};
|
|
|
|
vi.doMock('../../src/tracing/store', () => ({
|
|
getTraceStore: vi.fn().mockReturnValue(mockTraceStore),
|
|
}));
|
|
|
|
// Force reload the module to use the mock
|
|
vi.resetModules();
|
|
const { default: EvalReloaded } = await import('../../src/models/eval');
|
|
const evalInstance = Object.assign(Object.create(EvalReloaded.prototype), eval_);
|
|
|
|
const traces = await evalInstance.getTraces();
|
|
|
|
expect(traces).toHaveLength(1);
|
|
expect(traces[0]).toMatchObject({
|
|
traceId: 'trace-123',
|
|
evaluationId: eval_.id,
|
|
testCaseId: 'test-case-1',
|
|
metadata: { test: 'value' },
|
|
});
|
|
expect(traces[0].spans).toHaveLength(1);
|
|
expect(mockTraceStore.getTracesByEvaluation).toHaveBeenCalledWith(eval_.id);
|
|
});
|
|
|
|
it('should return empty array when no traces exist', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const mockTraceStore = {
|
|
getTracesByEvaluation: vi.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
vi.doMock('../../src/tracing/store', () => ({
|
|
getTraceStore: vi.fn().mockReturnValue(mockTraceStore),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
const { default: EvalReloaded } = await import('../../src/models/eval');
|
|
const evalInstance = Object.assign(Object.create(EvalReloaded.prototype), eval_);
|
|
|
|
const traces = await evalInstance.getTraces();
|
|
|
|
expect(traces).toEqual([]);
|
|
expect(mockTraceStore.getTracesByEvaluation).toHaveBeenCalledWith(eval_.id);
|
|
});
|
|
|
|
it('should handle spans with missing endTime', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const mockTraces = [
|
|
{
|
|
traceId: 'trace-incomplete',
|
|
evaluationId: eval_.id,
|
|
testCaseId: 'test-case-2',
|
|
metadata: {},
|
|
spans: [
|
|
{
|
|
spanId: 'span-no-end',
|
|
name: 'Incomplete Span',
|
|
startTime: 1000000000,
|
|
endTime: null,
|
|
statusCode: 0,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const mockTraceStore = {
|
|
getTracesByEvaluation: vi.fn().mockResolvedValue(mockTraces),
|
|
};
|
|
|
|
vi.doMock('../../src/tracing/store', () => ({
|
|
getTraceStore: vi.fn().mockReturnValue(mockTraceStore),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
const { default: EvalReloaded } = await import('../../src/models/eval');
|
|
const evalInstance = Object.assign(Object.create(EvalReloaded.prototype), eval_);
|
|
|
|
const traces = await evalInstance.getTraces();
|
|
|
|
expect(traces).toHaveLength(1);
|
|
expect(traces[0].spans[0]).toHaveProperty('spanId', 'span-no-end');
|
|
});
|
|
|
|
it('should map status codes correctly', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const mockTraces = [
|
|
{
|
|
traceId: 'trace-status-codes',
|
|
evaluationId: eval_.id,
|
|
testCaseId: 'test-case-3',
|
|
metadata: {},
|
|
spans: [
|
|
{
|
|
spanId: 'span-ok',
|
|
name: 'OK Span',
|
|
startTime: 1000000000,
|
|
endTime: 2000000000,
|
|
statusCode: 1,
|
|
},
|
|
{
|
|
spanId: 'span-error',
|
|
name: 'Error Span',
|
|
startTime: 2000000000,
|
|
endTime: 3000000000,
|
|
statusCode: 2,
|
|
},
|
|
{
|
|
spanId: 'span-unset',
|
|
name: 'Unset Span',
|
|
startTime: 3000000000,
|
|
endTime: 4000000000,
|
|
statusCode: 0,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const mockTraceStore = {
|
|
getTracesByEvaluation: vi.fn().mockResolvedValue(mockTraces),
|
|
};
|
|
|
|
vi.doMock('../../src/tracing/store', () => ({
|
|
getTraceStore: vi.fn().mockReturnValue(mockTraceStore),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
const { default: EvalReloaded } = await import('../../src/models/eval');
|
|
const evalInstance = Object.assign(Object.create(EvalReloaded.prototype), eval_);
|
|
|
|
const traces = await evalInstance.getTraces();
|
|
|
|
expect(traces[0].spans).toHaveLength(3);
|
|
// Note: The actual status mapping logic may vary based on implementation
|
|
expect(traces[0].spans[0].spanId).toBe('span-ok');
|
|
expect(traces[0].spans[1].spanId).toBe('span-error');
|
|
expect(traces[0].spans[2].spanId).toBe('span-unset');
|
|
});
|
|
|
|
it('should handle errors gracefully and return empty array', async () => {
|
|
const eval_ = await EvalFactory.create();
|
|
|
|
const mockTraceStore = {
|
|
getTracesByEvaluation: vi.fn().mockRejectedValue(new Error('Database error')),
|
|
};
|
|
|
|
vi.doMock('../../src/tracing/store', () => ({
|
|
getTraceStore: vi.fn().mockReturnValue(mockTraceStore),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
const { default: EvalReloaded } = await import('../../src/models/eval');
|
|
const evalInstance = Object.assign(Object.create(EvalReloaded.prototype), eval_);
|
|
|
|
const traces = await evalInstance.getTraces();
|
|
|
|
expect(traces).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('escapeJsonPathKey', () => {
|
|
it('should return simple keys unchanged', () => {
|
|
expect(escapeJsonPathKey('field')).toBe('field');
|
|
expect(escapeJsonPathKey('simple_field')).toBe('simple_field');
|
|
expect(escapeJsonPathKey('field123')).toBe('field123');
|
|
});
|
|
|
|
it('should escape double quotes in keys', () => {
|
|
expect(escapeJsonPathKey('field"with"quotes')).toBe('field\\"with\\"quotes');
|
|
expect(escapeJsonPathKey('"quoted"')).toBe('\\"quoted\\"');
|
|
});
|
|
|
|
it('should escape backslashes in keys', () => {
|
|
expect(escapeJsonPathKey('field\\with\\backslash')).toBe('field\\\\with\\\\backslash');
|
|
expect(escapeJsonPathKey('\\\\double')).toBe('\\\\\\\\double');
|
|
});
|
|
|
|
it('should escape both quotes and backslashes together', () => {
|
|
expect(escapeJsonPathKey('field\\"mixed')).toBe('field\\\\\\"mixed');
|
|
expect(escapeJsonPathKey('"\\key\\"')).toBe('\\"\\\\key\\\\\\"');
|
|
});
|
|
|
|
it('should handle SQL injection attempts in keys', () => {
|
|
// These should be safely escaped, not executed
|
|
const injection1 = 'field"; DROP TABLE users; --';
|
|
const escaped1 = escapeJsonPathKey(injection1);
|
|
// The double quote is escaped with a backslash, preventing JSON path breakout
|
|
expect(escaped1).toBe('field\\"; DROP TABLE users; --');
|
|
|
|
const injection2 = "field' OR 1=1; --";
|
|
const escaped2 = escapeJsonPathKey(injection2);
|
|
// Single quotes stay in the JSON path value and are bound as parameters.
|
|
expect(escaped2).toBe("field' OR 1=1; --");
|
|
});
|
|
});
|
|
|
|
describe('buildSafeJsonPath', () => {
|
|
it('should build valid JSON paths for simple field names', () => {
|
|
expect(buildSafeJsonPath('field')).toBe('$."field"');
|
|
});
|
|
|
|
it('should properly escape double quotes in field names', () => {
|
|
expect(buildSafeJsonPath('field"with"quotes')).toBe(String.raw`$."field\"with\"quotes"`);
|
|
});
|
|
|
|
it('should leave single quotes in the bound JSON path value', () => {
|
|
expect(buildSafeJsonPath("field'with'single'quotes")).toBe("$.\"field'with'single'quotes\"");
|
|
});
|
|
|
|
it('should handle complex SQL injection attempts', () => {
|
|
const attack = `field"'; DROP TABLE users; --`;
|
|
expect(buildSafeJsonPath(attack)).toBe(String.raw`$."field\"'; DROP TABLE users; --"`);
|
|
});
|
|
|
|
it('should handle backslashes correctly', () => {
|
|
expect(buildSafeJsonPath('path\\to\\field')).toBe(String.raw`$."path\\to\\field"`);
|
|
});
|
|
});
|
|
|
|
describe('combineFilterConditions', () => {
|
|
it('should return null for empty array', () => {
|
|
const result = combineFilterConditions([]);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return single condition unwrapped', () => {
|
|
const condition = sql`field = ${1}`;
|
|
const result = combineFilterConditions([{ condition, logicOperator: 'AND' }]);
|
|
expect(result).toBe(condition);
|
|
});
|
|
|
|
it('should combine two conditions with AND', () => {
|
|
const cond1 = sql`field1 = ${1}`;
|
|
const cond2 = sql`field2 = ${2}`;
|
|
const result = combineFilterConditions([
|
|
{ condition: cond1, logicOperator: 'AND' },
|
|
{ condition: cond2, logicOperator: 'AND' },
|
|
]);
|
|
expect(result).not.toBeNull();
|
|
// Verify the result contains both conditions
|
|
expect(result!.queryChunks.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('should combine two conditions with OR', () => {
|
|
const cond1 = sql`field1 = ${1}`;
|
|
const cond2 = sql`field2 = ${2}`;
|
|
const result = combineFilterConditions([
|
|
{ condition: cond1, logicOperator: 'AND' },
|
|
{ condition: cond2, logicOperator: 'OR' },
|
|
]);
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it('should handle mixed AND/OR operators', () => {
|
|
const cond1 = sql`a = ${1}`;
|
|
const cond2 = sql`b = ${2}`;
|
|
const cond3 = sql`c = ${3}`;
|
|
const cond4 = sql`d = ${4}`;
|
|
|
|
const result = combineFilterConditions([
|
|
{ condition: cond1, logicOperator: 'AND' },
|
|
{ condition: cond2, logicOperator: 'AND' },
|
|
{ condition: cond3, logicOperator: 'OR' },
|
|
{ condition: cond4, logicOperator: 'AND' },
|
|
]);
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it('should use AND as default for unrecognized operators', () => {
|
|
const cond1 = sql`field1 = ${1}`;
|
|
const cond2 = sql`field2 = ${2}`;
|
|
const result = combineFilterConditions([
|
|
{ condition: cond1, logicOperator: 'UNKNOWN' },
|
|
{ condition: cond2, logicOperator: 'INVALID' },
|
|
]);
|
|
expect(result).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getTablePage sessionId header detection', () => {
|
|
it('sorts legacy backfilled vars before appending metadata-only sessionId', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET
|
|
metadata = json('{"sessionId":"session-123"}'),
|
|
test_case = json('{"vars":{"zebra":"z","apple":"a"}}')
|
|
WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
await db.run(`UPDATE evals SET vars = json('[]') WHERE id = '${eval_.id}'`);
|
|
|
|
const reloadedEval = await Eval.findById(eval_.id);
|
|
const result = await reloadedEval!.getTablePage({ filters: [] });
|
|
|
|
expect(reloadedEval!.vars).toEqual(['apple', 'zebra']);
|
|
expect(result.head.vars).toEqual(['apple', 'zebra', 'sessionId']);
|
|
});
|
|
|
|
it('should add sessionId to vars header when metadata.sessionId exists but not in vars', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
|
|
// Set metadata.sessionId on the result
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"sessionId":"session-123"}') WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
|
|
const result = await eval_.getTablePage({ filters: [] });
|
|
|
|
// sessionId should be added to vars header
|
|
expect(result.head.vars).toContain('sessionId');
|
|
});
|
|
|
|
it('should add sessionId to vars header when metadata.sessionIds array exists', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
|
|
// Set metadata.sessionIds array on the result (multi-turn strategy format)
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"sessionIds":["session-a","session-b","session-c"]}') WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
|
|
const result = await eval_.getTablePage({ filters: [] });
|
|
|
|
// sessionId should be added to vars header
|
|
expect(result.head.vars).toContain('sessionId');
|
|
});
|
|
|
|
it('should not add sessionId to vars header when already in testCase.vars', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
|
|
// Set both metadata.sessionIds and testCase.vars.sessionId
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET
|
|
metadata = json('{"sessionIds":["session-a","session-b"]}'),
|
|
test_case = json('{"vars":{"state":"colorado","sessionId":"user-session"}}')
|
|
WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
|
|
// Force refresh the eval vars (we need to reload the eval for accurate state)
|
|
const reloadedEval = await Eval.findById(eval_.id);
|
|
expect(reloadedEval).toBeDefined();
|
|
|
|
const result = await reloadedEval!.getTablePage({ filters: [] });
|
|
|
|
// sessionId should be in vars header since it's in testCase.vars
|
|
// The check is that the vars header includes sessionId but not duplicated
|
|
const sessionIdCount = result.head.vars.filter((v) => v === 'sessionId').length;
|
|
expect(sessionIdCount).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('should handle empty sessionIds array (should not add sessionId header)', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
|
|
// Set empty sessionIds array
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"sessionIds":[]}') WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
|
|
const result = await eval_.getTablePage({ filters: [] });
|
|
|
|
// sessionId should NOT be in vars header since sessionIds is empty
|
|
expect(result.head.vars).not.toContain('sessionId');
|
|
});
|
|
|
|
it('should handle both sessionIds array and sessionId (sessionIds takes precedence for header check)', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
|
|
// Set both sessionIds array and sessionId
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"sessionId":"single","sessionIds":["multi-1","multi-2"]}') WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
|
|
const result = await eval_.getTablePage({ filters: [] });
|
|
|
|
// sessionId should be in vars header
|
|
expect(result.head.vars).toContain('sessionId');
|
|
});
|
|
|
|
it('should handle multiple results with varying sessionId/sessionIds configurations', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 3 });
|
|
|
|
const db = await getDb();
|
|
// Result 0: Has sessionIds array (multi-turn)
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"sessionIds":["multi-0a","multi-0b"]}') WHERE eval_id = '${eval_.id}' AND test_idx = 0`,
|
|
);
|
|
// Result 1: Has single sessionId
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"sessionId":"single-1"}') WHERE eval_id = '${eval_.id}' AND test_idx = 1`,
|
|
);
|
|
// Result 2: No sessionId or sessionIds
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{}') WHERE eval_id = '${eval_.id}' AND test_idx = 2`,
|
|
);
|
|
|
|
const result = await eval_.getTablePage({ filters: [] });
|
|
|
|
// sessionId should be in vars header (at least one result has it)
|
|
expect(result.head.vars).toContain('sessionId');
|
|
});
|
|
|
|
it('should not add sessionId to header when no results have sessionId or sessionIds', async () => {
|
|
const eval_ = await EvalFactory.create({ numResults: 2 });
|
|
|
|
// Set empty metadata on all results
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"otherField":"value"}') WHERE eval_id = '${eval_.id}'`,
|
|
);
|
|
|
|
const result = await eval_.getTablePage({ filters: [] });
|
|
|
|
// sessionId should NOT be in vars header
|
|
expect(result.head.vars).not.toContain('sessionId');
|
|
});
|
|
});
|
|
|
|
describe('parameterization verification', () => {
|
|
it('should use parameterized queries for filter field and value', async () => {
|
|
// Both metric and metadata filters now use json_each(...) WHERE key = ${field}
|
|
// AND value = ${value}, so user-controlled `field` and `value` are bound as
|
|
// parameters rather than interpolated. The "filters by metadata with special
|
|
// characters in field names" test above exercises this against the live DB —
|
|
// no string-escaping helper is needed because nothing reaches sql.raw().
|
|
const attackField = "field'; DROP TABLE evals; --";
|
|
const eval_ = await EvalFactory.create({ numResults: 1 });
|
|
await expect(
|
|
eval_.getTablePage({
|
|
filters: [
|
|
JSON.stringify({
|
|
type: 'metadata',
|
|
field: attackField,
|
|
operator: 'equals',
|
|
value: 'whatever',
|
|
logicOperator: 'AND',
|
|
}),
|
|
],
|
|
}),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|
|
});
|