import './setup';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import cliState from '../../src/cliState';
import { __resetPromptConversationCacheForTests, evaluate } from '../../src/evaluator';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import { providerRegistry } from '../../src/providers/providerRegistry';
import {
type ApiProvider,
type ProviderResponse,
ResultFailureReason,
type TestSuite,
} from '../../src/types/index';
import { JsonlFileWriter } from '../../src/util/exportToFile/writeToFile';
import { sleep } from '../../src/util/time';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
import { toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
beforeEach(() => {
__resetPromptConversationCacheForTests();
});
afterEach(() => {
vi.useRealTimers();
});
describeEvaluator('evaluator execution control', () => {
it('evaluates with provider delay', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
delay: 100,
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(sleep).toHaveBeenCalledWith(100);
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
});
it('evaluates with no provider delay', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(sleep).not.toHaveBeenCalled();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
});
it('closes JSONL writers after evaluation', async () => {
const outputPath = path.join(os.tmpdir(), `promptfoo-evaluator-${randomUUID()}.jsonl`);
const closeSpy = vi.spyOn(JsonlFileWriter.prototype, 'close');
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
fs.writeFileSync(outputPath, 'stale row\n');
const evalRecord = new Eval({ outputPath });
await evaluate(testSuite, evalRecord, {});
expect(closeSpy).toHaveBeenCalledOnce();
const output = fs.readFileSync(outputPath, 'utf8');
expect(output).not.toContain('stale row');
expect(output.trim()).not.toBe('');
} finally {
closeSpy.mockRestore();
fs.rmSync(outputPath, { force: true });
}
});
it('continues cleanup and recovers a JSONL close failure when results persisted', async () => {
const outputPath = path.join(os.tmpdir(), `promptfoo-evaluator-${randomUUID()}.jsonl`);
const originalClose = JsonlFileWriter.prototype.close;
const closeSpy = vi
.spyOn(JsonlFileWriter.prototype, 'close')
.mockImplementationOnce(async function (this: JsonlFileWriter) {
await originalClose.call(this);
throw new Error('simulated close failure');
});
const shutdownSpy = vi.spyOn(providerRegistry, 'shutdownAll').mockResolvedValue();
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
const evalRecord = new Eval({ outputPath });
// Results persisted, so the close failure is recoverable (the output file is
// regenerated from the database) — the run still succeeds and cleanup still runs.
await expect(evaluate(testSuite, evalRecord, {})).resolves.toBeDefined();
expect(shutdownSpy).toHaveBeenCalledOnce();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('simulated close failure'));
} finally {
warnSpy.mockRestore();
closeSpy.mockRestore();
shutdownSpy.mockRestore();
fs.rmSync(outputPath, { force: true });
}
});
it('appends JSONL rows when resuming an evaluation', async () => {
const outputPath = path.join(os.tmpdir(), `promptfoo-evaluator-${randomUUID()}.jsonl`);
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
fs.writeFileSync(outputPath, 'existing row\n');
cliState.resume = true;
const evalRecord = new Eval({ outputPath });
await evaluate(testSuite, evalRecord, {});
const output = fs.readFileSync(outputPath, 'utf8');
expect(output).toContain('existing row');
expect(output.trim().split('\n')).toHaveLength(2);
} finally {
cliState.resume = false;
fs.rmSync(outputPath, { force: true });
}
});
it('keeps raw SVG text available to llm-rubric judges while indexing media metadata', async () => {
const svg = '';
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('svg-provider'),
callApi: vi.fn().mockResolvedValue({
output: svg,
tokenUsage: createEmptyTokenUsage(),
}),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async (prompt) => {
const messages = JSON.parse(String(prompt)) as Array<{ content: string }>;
const judgeInput = messages.at(-1)?.content;
expect(judgeInput).toContain(svg);
expect(judgeInput).not.toContain('promptfoo://blob/');
return {
output: JSON.stringify({ pass: true, score: 1, reason: 'saw raw SVG' }),
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Generate SVG')],
tests: [
{
assert: [{ type: 'llm-rubric', value: 'Output should be SVG', provider: judge }],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const completedEval = await evaluate(testSuite, evalRecord, {});
const results = await completedEval.getResults();
expect(judge.callApi).toHaveBeenCalledTimes(1);
expect(results[0].response).toEqual(
expect.objectContaining({
output: svg,
metadata: expect.objectContaining({
blobUris: [expect.stringMatching(/^promptfoo:\/\/blob\/[a-f0-9]{64}$/)],
}),
}),
);
});
type ConcurrencyProbe = {
maxActiveCalls: number;
callApi: ReturnType;
};
async function runConcurrencyProbe(
rawPrompt: string,
testCount = 4,
providerOverrides: Partial = {},
): Promise {
let activeCalls = 0;
let maxActiveCalls = 0;
// Explicit barrier so every concurrent slot actually observes peak
// overlap, independent of wall-clock timing. With setTimeout-only
// sleeps a slow CI runner can serialize short sleeps and falsely
// report maxActiveCalls=1 even when the evaluator dispatched in
// parallel.
let releaseHold: (() => void) | undefined;
let holdPromise = new Promise((resolve) => {
releaseHold = resolve;
});
const targetConcurrency = Math.min(testCount, 4);
const callApi = vi.fn().mockImplementation(async (prompt) => {
activeCalls += 1;
maxActiveCalls = Math.max(maxActiveCalls, activeCalls);
if (activeCalls >= targetConcurrency) {
releaseHold?.();
}
// Wait until either the barrier trips (parallel path) or a short
// fallback fires (serial path — we'll never reach targetConcurrency).
let resolveFallback!: () => void;
const fallback = new Promise((r) => {
resolveFallback = r;
});
const fallbackHandle = setTimeout(() => resolveFallback(), 100);
try {
await Promise.race([holdPromise, fallback]);
} finally {
clearTimeout(fallbackHandle);
}
activeCalls -= 1;
return {
output: String(prompt),
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
};
});
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi,
...providerOverrides,
};
const tests = Array.from({ length: testCount }, (_, idx) => ({
vars: { question: `Turn ${idx + 1}` },
}));
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt(rawPrompt)],
tests,
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: targetConcurrency });
expect(callApi).toHaveBeenCalledTimes(testCount);
// Release the barrier if it's still held (e.g. serial path never tripped it).
releaseHold?.();
// Suppress unused-variable lint noise for holdPromise.
holdPromise = holdPromise.then(() => undefined);
return { maxActiveCalls, callApi };
}
it('does not force concurrency to 1 for _conversation substrings', async () => {
const { maxActiveCalls } = await runConcurrencyProbe(
'Summarize the pre_conversation_context for {{ question }}',
);
expect(maxActiveCalls).toBeGreaterThan(1);
});
it('persists configured variable order when concurrent rows complete out of order', async () => {
let releaseFirst!: () => void;
const waitForSecond = new Promise((resolve) => {
releaseFirst = resolve;
});
let invocation = 0;
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('out-of-order-provider'),
callApi: vi.fn(async () => {
invocation += 1;
if (invocation === 1) {
await waitForSecond;
}
return {
output: 'ok',
tokenUsage: { total: 1, prompt: 1, completion: 0, cached: 0, numRequests: 1 },
};
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Static prompt')],
tests: [{ vars: { zebra: 'first' } }, { vars: { apple: 'second' } }],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const addResult = evalRecord.addResult.bind(evalRecord);
vi.spyOn(evalRecord, 'addResult').mockImplementation(async (row) => {
await addResult(row);
if (row.testIdx === 1) {
releaseFirst();
}
});
await evaluate(testSuite, evalRecord, { maxConcurrency: 2 });
expect(evalRecord.vars).toEqual(['zebra', 'apple']);
});
it('forces concurrency to 1 for real _conversation template references', async () => {
const { maxActiveCalls } = await runConcurrencyProbe(
'{{ {"history": _conversation}["history"][0].output }} {{ question }}',
2,
);
expect(maxActiveCalls).toBe(1);
});
it('forces concurrency to 1 for browser providers with persistent sessions', async () => {
const { maxActiveCalls } = await runConcurrencyProbe('Ask: {{ question }}', 2, {
config: { persistSession: true },
id: vi.fn().mockReturnValue('browser-provider'),
});
expect(maxActiveCalls).toBe(1);
});
it('keeps non-persistent browser provider calls concurrent', async () => {
const { maxActiveCalls } = await runConcurrencyProbe('Ask: {{ question }}', 2, {
config: { persistSession: false },
id: vi.fn().mockReturnValue('browser-provider'),
});
expect(maxActiveCalls).toBeGreaterThan(1);
});
it('falls back to serial execution when a prompt that mentions _conversation fails to parse', async () => {
// Invalid Nunjucks ({% if %} with no condition), but the literal text
// contains `_conversation`. The old substring check always forced serial
// mode here, and the new parser-based check must preserve that safety
// envelope instead of silently running in parallel. The render itself
// will fail for an invalid template, so assert on the evaluator's
// concurrency-adjustment log rather than on peak in-flight calls.
const infoSpy = vi.spyOn(logger, 'info');
try {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'ok',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('{{ _conversation[0].output }} {% if %} {{ question }}')],
tests: [{ vars: { question: 'a' } }, { vars: { question: 'b' } }],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, { maxConcurrency: 4 });
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Setting concurrency to 1'));
} finally {
infoSpy.mockRestore();
}
});
it('propagates prior turn output into _conversation on the next serial call', async () => {
const { callApi } = await runConcurrencyProbe(
'{% if _conversation and _conversation|length > 0 %}prior={{ _conversation[0].response.output }} {% endif %}now={{ question }}',
2,
);
// First call has no prior history; second call should have received
// turn 1's rendered output threaded through the `_conversation` variable.
const firstRenderedPrompt = String(callApi.mock.calls[0][0]);
const secondRenderedPrompt = String(callApi.mock.calls[1][0]);
expect(firstRenderedPrompt).not.toContain('prior=');
expect(secondRenderedPrompt).toContain('prior=');
expect(secondRenderedPrompt).toContain('now=Turn 2');
});
it('persists updated prompt metrics between serial eval steps for live result refreshes', async () => {
let releaseSecondCall!: () => void;
let markSecondCallStarted!: () => void;
const secondCallStarted = new Promise((resolve) => {
markSecondCallStarted = resolve;
});
let callCount = 0;
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn(async () => {
callCount += 1;
if (callCount === 2) {
markSecondCallStarted();
await new Promise((resolve) => {
releaseSecondCall = resolve;
});
}
return {
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
};
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{ options: { runSerially: true } }, { options: { runSerially: true } }],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const addPromptsSpy = vi.spyOn(evalRecord, 'addPrompts');
const evalPromise = evaluate(testSuite, evalRecord, { maxConcurrency: 1, timeoutMs: 10000 });
await secondCallStarted;
expect(addPromptsSpy).toHaveBeenCalledTimes(2);
expect(addPromptsSpy.mock.calls[1][0][0].metrics?.testPassCount).toBe(1);
const refreshedEval = await Eval.findById(evalRecord.id);
expect(refreshedEval?.prompts[0].metrics?.testPassCount).toBe(1);
releaseSecondCall();
await evalPromise;
});
it('throttles rapid prompt metric persistence between serial eval steps', async () => {
let releaseThirdCall!: () => void;
let markThirdCallStarted!: () => void;
const thirdCallStarted = new Promise((resolve) => {
markThirdCallStarted = resolve;
});
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10_000);
let callCount = 0;
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn(async () => {
callCount += 1;
if (callCount === 3) {
markThirdCallStarted();
await new Promise((resolve) => {
releaseThirdCall = resolve;
});
}
return {
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
};
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{ options: { runSerially: true } },
{ options: { runSerially: true } },
{ options: { runSerially: true } },
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const addPromptsSpy = vi.spyOn(evalRecord, 'addPrompts');
const evalPromise = evaluate(testSuite, evalRecord, { maxConcurrency: 1, timeoutMs: 10000 });
await thirdCallStarted;
const promptWriteCountDuringThirdCall = addPromptsSpy.mock.calls.length;
const refreshedEval = await Eval.findById(evalRecord.id);
const persistedPassCountDuringThirdCall = refreshedEval?.prompts[0].metrics?.testPassCount;
releaseThirdCall();
await evalPromise;
dateNowSpy.mockRestore();
expect(promptWriteCountDuringThirdCall).toBe(2);
expect(persistedPassCountDuringThirdCall).toBe(1);
});
it('persists updated prompt metrics between grouped eval steps without deferred grading', async () => {
let releaseSecondCall!: () => void;
let markSecondCallStarted!: () => void;
const secondCallStarted = new Promise((resolve) => {
markSecondCallStarted = resolve;
});
let callCount = 0;
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn(async () => {
callCount += 1;
if (callCount === 2) {
markSecondCallStarted();
await new Promise((resolve) => {
releaseSecondCall = resolve;
});
}
return {
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
};
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}, {}],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const addPromptsSpy = vi.spyOn(evalRecord, 'addPrompts');
const evalPromise = evaluate(testSuite, evalRecord, { maxConcurrency: 1 });
await secondCallStarted;
expect(addPromptsSpy).toHaveBeenCalledTimes(2);
expect(addPromptsSpy.mock.calls[1][0][0].metrics?.testPassCount).toBe(1);
const refreshedEval = await Eval.findById(evalRecord.id);
expect(refreshedEval?.prompts[0].metrics?.testPassCount).toBe(1);
releaseSecondCall();
await evalPromise;
});
it('skips delay for cached responses', async () => {
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
delay: 100,
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
cached: true,
}),
};
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
expect(sleep).not.toHaveBeenCalled();
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(1);
});
it('handles circular references when logging errors during result saving', async () => {
// Create a circular reference object that would cause JSON.stringify to fail
type CircularType = { prop: string; self?: CircularType };
const circularObj: CircularType = { prop: 'value' };
circularObj.self = circularObj;
const mockApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider'),
callApi: vi.fn().mockResolvedValue({
output: 'Test output',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
}),
};
// Create a test suite that will generate a result with a circular reference
const testSuite: TestSuite = {
providers: [mockApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [
{
vars: { circular: circularObj },
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const mockAddResult = vi
.spyOn(evalRecord, 'addResult')
.mockRejectedValue(new Error('Mock save error'));
const errorSpy = vi.spyOn(logger, 'error');
try {
await evaluate(testSuite, evalRecord, {});
expect(errorSpy).toHaveBeenCalledWith(
'[Evaluator] Error saving result',
expect.objectContaining({
error: expect.any(Error),
resultSummary: expect.objectContaining({
testIdx: 0,
promptIdx: 0,
success: true,
}),
}),
);
expect(evalRecord.resultPersistenceFailed).toBe(true);
expect(evalRecord.hasResultPersistenceFailure({ promptIdx: 0, testIdx: 0 })).toBe(true);
} finally {
mockAddResult.mockRestore();
errorSpy.mockRestore();
}
});
it('evaluate with provider error response', async () => {
const mockApiProviderWithError: ApiProvider = {
id: vi.fn().mockReturnValue('test-provider-error'),
callApi: vi.fn().mockResolvedValue({
output: 'Some output',
error: 'API error occurred',
tokenUsage: { total: 5, prompt: 5, completion: 0, cached: 0, numRequests: 1 },
}),
};
const testSuite: TestSuite = {
providers: [mockApiProviderWithError],
prompts: [toPrompt('Test prompt')],
tests: [],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, evalRecord, {});
const summary = await evalRecord.toEvaluateSummary();
expect(summary).toEqual(
expect.objectContaining({
stats: expect.objectContaining({
successes: 0,
errors: 1,
failures: 0,
}),
results: expect.arrayContaining([
expect.objectContaining({
error: 'API error occurred',
failureReason: ResultFailureReason.ERROR,
success: false,
score: 0,
}),
]),
}),
);
expect(mockApiProviderWithError.callApi).toHaveBeenCalledTimes(1);
});
it('should handle evaluation timeout without tearing down the shared provider', async () => {
vi.useFakeTimers();
const mockAddResult = vi.fn().mockResolvedValue(undefined);
let longTimer: NodeJS.Timeout | null = null;
const slowApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('slow-provider'),
callApi: vi.fn().mockImplementation(() => {
return new Promise((resolve) => {
longTimer = setTimeout(() => {
resolve({
output: 'Slow response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
});
}, 5000);
});
}),
cleanup: vi.fn(),
};
const mockEval = {
id: 'mock-eval-id',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 1,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn().mockResolvedValue(undefined),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [slowApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
const evalPromise = evaluate(testSuite, mockEval as unknown as Eval, { timeoutMs: 100 });
await vi.advanceTimersByTimeAsync(100);
await evalPromise;
expect(slowApiProvider.callApi).toHaveBeenCalledWith(
'Test prompt',
expect.anything(),
expect.objectContaining({
abortSignal: expect.any(AbortSignal),
}),
);
expect(mockAddResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('Evaluation timed out after 100ms'),
success: false,
failureReason: ResultFailureReason.ERROR,
}),
);
expect(slowApiProvider.cleanup).not.toHaveBeenCalled();
} finally {
if (longTimer) {
clearTimeout(longTimer);
}
vi.useRealTimers();
}
});
it('should not block timeout rows when a provider call does not settle after abort', async () => {
vi.useFakeTimers();
const mockAddResult = vi.fn().mockResolvedValue(undefined);
const hangingProvider: ApiProvider = {
id: vi.fn().mockReturnValue('hanging-provider'),
callApi: vi.fn().mockImplementation(
() =>
new Promise(() => {
// Intentionally never resolves; timeout handling must still emit a row.
}),
),
cleanup: vi.fn(),
};
const mockEval = {
id: 'mock-eval-id',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 1,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn().mockResolvedValue(undefined),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [hangingProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
const evalPromise = evaluate(testSuite, mockEval as unknown as Eval, { timeoutMs: 50 });
await vi.advanceTimersByTimeAsync(50);
await evalPromise;
expect(hangingProvider.cleanup).not.toHaveBeenCalled();
expect(mockAddResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('Evaluation timed out after 50ms'),
success: false,
failureReason: ResultFailureReason.ERROR,
}),
);
} finally {
vi.useRealTimers();
}
});
it('should ignore stale provider rows that resolve after a timeout row is recorded', async () => {
vi.useFakeTimers();
const mockAddResult = vi.fn().mockResolvedValue(undefined);
let resolveLateResponse!: (value: ProviderResponse) => void;
const slowApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('slow-provider'),
callApi: vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveLateResponse = resolve;
}),
),
cleanup: vi.fn(),
};
const mockEval = {
id: 'mock-eval-id',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 1,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn().mockResolvedValue(undefined),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [slowApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
const evalPromise = evaluate(testSuite, mockEval as unknown as Eval, { timeoutMs: 50 });
await vi.advanceTimersByTimeAsync(50);
await evalPromise;
expect(mockAddResult).toHaveBeenCalledTimes(1);
expect(mockAddResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('Evaluation timed out after 50ms'),
success: false,
failureReason: ResultFailureReason.ERROR,
}),
);
resolveLateResponse({
output: 'Late response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
});
await Promise.resolve();
await Promise.resolve();
expect(mockAddResult).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('should honor external abortSignal when timeoutMs is set', async () => {
vi.useFakeTimers();
const mockAddResult = vi.fn().mockResolvedValue(undefined);
let longTimer: NodeJS.Timeout | null = null;
let abortTimer: NodeJS.Timeout | null = null;
const abortController = new AbortController();
const slowApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('slow-provider'),
callApi: vi.fn().mockImplementation((_, __, opts) => {
return new Promise((resolve, reject) => {
longTimer = setTimeout(() => {
resolve({
output: 'Slow response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
});
}, 200);
abortTimer = setTimeout(() => {
abortController.abort();
}, 10);
opts?.abortSignal?.addEventListener('abort', () => {
if (longTimer) {
clearTimeout(longTimer);
}
if (abortTimer) {
clearTimeout(abortTimer);
}
reject(new Error('aborted'));
});
});
}),
cleanup: vi.fn(),
};
const mockEval = {
id: 'mock-eval-id',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 1,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn().mockResolvedValue(undefined),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [slowApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
};
try {
const evalPromise = evaluate(testSuite, mockEval as unknown as Eval, {
timeoutMs: 1000,
abortSignal: abortController.signal,
});
await vi.advanceTimersByTimeAsync(10);
await evalPromise;
expect(mockAddResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('aborted'),
success: false,
failureReason: ResultFailureReason.ERROR,
}),
);
} finally {
if (longTimer) {
clearTimeout(longTimer);
}
if (abortTimer) {
clearTimeout(abortTimer);
}
vi.useRealTimers();
}
});
it('should abort when exceeding maxEvalTimeMs', async () => {
vi.useFakeTimers();
const mockAddResult = vi.fn().mockResolvedValue(undefined);
let longTimer: NodeJS.Timeout | null = null;
const slowApiProvider: ApiProvider = {
id: vi.fn().mockReturnValue('slow-provider'),
callApi: vi.fn().mockImplementation((_, __, opts) => {
return new Promise((resolve, reject) => {
longTimer = setTimeout(() => {
resolve({
output: 'Slow response',
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0, numRequests: 1 },
});
}, 1000);
opts?.abortSignal?.addEventListener('abort', () => {
if (longTimer) {
clearTimeout(longTimer);
}
reject(new Error('aborted'));
});
});
}),
cleanup: vi.fn(),
};
const mockEval = {
id: 'mock-eval-id',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 2,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn().mockResolvedValue(undefined),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [slowApiProvider],
prompts: [toPrompt('Test prompt')],
tests: [{}, {}],
};
try {
const evalPromise = evaluate(testSuite, mockEval as unknown as Eval, { maxEvalTimeMs: 100 });
await vi.advanceTimersByTimeAsync(100);
await evalPromise;
expect(mockAddResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('aborted'),
success: false,
failureReason: ResultFailureReason.ERROR,
}),
);
} finally {
if (longTimer) {
clearTimeout(longTimer);
}
vi.useRealTimers();
}
});
it('flushes queued grouped grading before writing max-duration timeout rows', async () => {
vi.useFakeTimers();
const results: any[] = [];
const waitForTarget = (ms: number, signal?: AbortSignal) => {
let resolveSleep!: () => void;
let rejectSleep!: (err: Error) => void;
const sleepPromise = new Promise((resolve, reject) => {
resolveSleep = resolve;
rejectSleep = reject;
});
const timeout = setTimeout(() => resolveSleep(), ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timeout);
rejectSleep(new Error('target aborted'));
},
{ once: true },
);
return sleepPromise;
};
const provider: ApiProvider = {
id: vi.fn().mockReturnValue('target-provider'),
callApi: vi.fn(async (prompt: string, _context, options) => {
await waitForTarget(40, options?.abortSignal);
return {
output: `Target output for ${prompt}`,
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const judge: ApiProvider = {
id: vi.fn().mockReturnValue('judge'),
callApi: vi.fn(async () => ({
output: JSON.stringify({ pass: true, score: 1, reason: 'judge passed' }),
tokenUsage: createEmptyTokenUsage(),
})),
};
const evalRecord = {
id: 'grouped-timeout-eval',
results,
prompts: [],
persisted: false,
config: {},
addPrompts: vi.fn().mockResolvedValue(undefined),
addResult: vi.fn(async (result) => {
results.push(result);
}),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue(results),
save: vi.fn().mockResolvedValue(undefined),
setDurationMs: vi.fn(),
setVars: vi.fn(),
toEvaluateSummary: vi.fn(),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt {{topic}}')],
tests: ['alpha', 'beta', 'gamma'].map((topic) => ({
vars: { topic },
assert: [{ type: 'llm-rubric', value: `Judge ${topic}`, provider: judge }],
})),
};
try {
const evalPromise = evaluate(testSuite, evalRecord as unknown as Eval, {
maxConcurrency: 1,
maxEvalTimeMs: 55,
});
await vi.advanceTimersByTimeAsync(40);
await vi.advanceTimersByTimeAsync(15);
await evalPromise;
} finally {
vi.useRealTimers();
}
const resultByTopic = new Map(results.map((result) => [result.vars.topic, result]));
expect(judge.callApi).toHaveBeenCalledTimes(1);
expect(resultByTopic.get('alpha')).toEqual(
expect.objectContaining({
success: true,
response: expect.objectContaining({
output: 'Target output for Test prompt alpha',
}),
}),
);
expect(resultByTopic.get('alpha')?.error).toBeUndefined();
expect(resultByTopic.get('gamma')?.error).toContain('Evaluation exceeded max duration');
});
});