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

453 lines
15 KiB
TypeScript

import './setup';
import { randomUUID } from 'crypto';
import { expect, it, vi } from 'vitest';
import { clearCache, getCache } from '../../src/cache';
import cliState from '../../src/cliState';
import { evaluate, runEval } from '../../src/evaluator';
import { runExtensionHook } from '../../src/evaluatorHelpers';
import Eval from '../../src/models/eval';
import {
type ApiProvider,
type Prompt,
type ProviderResponse,
ResultFailureReason,
type TestSuite,
} from '../../src/types/index';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
import { toPrompt } from './helpers';
import { describeEvaluator } from './lifecycle';
describeEvaluator('evaluator repeat cache isolation', () => {
it('preserves provider cache settings for repeat iterations', async () => {
const contexts: Array<Record<string, any> | undefined> = [];
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (_prompt: string, context?: Record<string, any>) => {
contexts.push(context);
return {
output: 'result',
tokenUsage: createEmptyTokenUsage(),
};
}),
};
const baseOptions = {
provider,
prompt: { raw: 'Test prompt', label: 'test-label' } as Prompt,
delay: 0,
nunjucksFilters: undefined,
evaluateOptions: {},
testIdx: 0,
promptIdx: 0,
conversations: {},
registers: {},
isRedteam: false,
};
await runEval({
...baseOptions,
test: { assert: [] },
repeatIndex: 0,
});
expect(contexts).toHaveLength(1);
expect(contexts[0]).toBeDefined();
expect(contexts[0]!.bustCache).toBeFalsy();
contexts.length = 0;
await runEval({
...baseOptions,
test: { assert: [] },
repeatIndex: 1,
});
expect(contexts).toHaveLength(1);
expect(contexts[0]).toBeDefined();
expect(contexts[0]!.bustCache).toBeFalsy();
});
it('isolates per-test repeat provider cache entries from the default namespace', async () => {
await clearCache();
const baselineResponse: ProviderResponse = {
output: 'baseline-result',
tokenUsage: createEmptyTokenUsage(),
};
await getCache().set('manual-provider-key', baselineResponse);
let cacheMissCount = 0;
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (_prompt: string, context?: Record<string, any>) => {
const cache = await context?.getCache();
const cachedResponse = await cache?.get('manual-provider-key');
if (cachedResponse) {
return {
...(cachedResponse as ProviderResponse),
cached: true,
};
}
cacheMissCount += 1;
const response = {
cached: false,
output: `result-repeat-${context?.repeatIndex}-miss-${cacheMissCount}`,
tokenUsage: createEmptyTokenUsage(),
};
await cache?.set('manual-provider-key', response);
return response;
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{ options: { repeat: 2 } }],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1 });
const firstSummary = await firstEval.toEvaluateSummary();
expect(cacheMissCount).toBe(2);
expect(firstSummary.results.map((result) => result.response?.output)).toEqual([
'result-repeat-0-miss-1',
'result-repeat-1-miss-2',
]);
expect(firstSummary.results.map((result) => result.response?.cached)).toEqual([false, false]);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1 });
const secondSummary = await secondEval.toEvaluateSummary();
expect(cacheMissCount).toBe(2);
expect(secondSummary.results.map((result) => result.response?.output)).toEqual([
'result-repeat-0-miss-1',
'result-repeat-1-miss-2',
]);
expect(secondSummary.results.map((result) => result.response?.cached)).toEqual([true, true]);
expect(await getCache().get('manual-provider-key')).toEqual(baselineResponse);
});
it('isolates beforeEach extension cache entries by repeat index', async () => {
await clearCache();
let extensionCacheMissCount = 0;
vi.mocked(runExtensionHook).mockImplementation(async (_extensions, hookName, context) => {
if (hookName !== 'beforeEach' || !('test' in context)) {
return context;
}
const hookContext = context as typeof context & {
test: {
vars?: Record<string, unknown>;
};
};
const cache = getCache();
const cachedHookValue = await cache.get<string>('extension-hook-key');
if (cachedHookValue) {
return {
...hookContext,
test: {
...hookContext.test,
vars: {
...hookContext.test.vars,
hookValue: cachedHookValue,
},
},
};
}
extensionCacheMissCount += 1;
const hookValue = `hook-repeat-${extensionCacheMissCount}`;
await cache.set('extension-hook-key', hookValue);
return {
...hookContext,
test: {
...hookContext.test,
vars: {
...hookContext.test.vars,
hookValue,
},
},
};
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (_prompt: string, context?: Record<string, any>) => ({
cached: false,
output: context?.vars?.hookValue,
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{ vars: {} }],
extensions: ['file://hook.js'],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
const firstSummary = await firstEval.toEvaluateSummary();
expect(extensionCacheMissCount).toBe(2);
expect(firstSummary.results.map((result) => result.response?.output)).toEqual([
'hook-repeat-1',
'hook-repeat-2',
]);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
const secondSummary = await secondEval.toEvaluateSummary();
expect(extensionCacheMissCount).toBe(2);
expect(secondSummary.results.map((result) => result.response?.output)).toEqual([
'hook-repeat-1',
'hook-repeat-2',
]);
});
it('isolates afterEach extension cache entries by repeat index', async () => {
await clearCache();
let extensionCacheMissCount = 0;
const afterEachHookValues: string[] = [];
vi.mocked(runExtensionHook).mockImplementation(async (_extensions, hookName, context) => {
if (hookName !== 'afterEach' || !('result' in context)) {
return context;
}
const cache = getCache();
let hookValue = await cache.get<string>('after-each-extension-key');
if (!hookValue) {
extensionCacheMissCount += 1;
hookValue = `after-hook-repeat-${extensionCacheMissCount}`;
await cache.set('after-each-extension-key', hookValue);
}
afterEachHookValues.push(hookValue);
return context;
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi.fn().mockResolvedValue({
cached: false,
output: 'provider-output',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Test prompt')],
tests: [{}],
extensions: ['file://hook.js'],
};
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
expect(extensionCacheMissCount).toBe(2);
expect(afterEachHookValues).toEqual(['after-hook-repeat-1', 'after-hook-repeat-2']);
afterEachHookValues.length = 0;
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
expect(extensionCacheMissCount).toBe(2);
expect(afterEachHookValues).toEqual(['after-hook-repeat-1', 'after-hook-repeat-2']);
});
it('isolates select-best comparison cache entries by repeat index', async () => {
await clearCache();
const matchers = await import('../../src/matchers/comparison');
let comparisonCacheMissCount = 0;
const comparisonRepeatIndexes: Array<number | undefined> = [];
const matchesSelectBestSpy = vi
.spyOn(matchers, 'matchesSelectBest')
.mockImplementation(async (_criteria, outputs, _grading, _vars, context) => {
comparisonRepeatIndexes.push(context?.repeatIndex);
const cache = context?.getCache?.() ?? getCache();
const cachedResult = (await cache.get('select-best-comparison-key')) as string | undefined;
if (!cachedResult) {
comparisonCacheMissCount += 1;
await cache.set('select-best-comparison-key', `repeat-${context?.repeatIndex}`);
}
return outputs.map((_output, index) => ({
pass: index === 0,
score: index === 0 ? 1 : 0,
reason: index === 0 ? 'Selected as best' : 'Not selected',
}));
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi
.fn()
.mockImplementation(async (prompt: string, context?: Record<string, any>) => ({
cached: false,
output: `${prompt}-repeat-${context?.repeatIndex}`,
tokenUsage: createEmptyTokenUsage(),
})),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [
{
type: 'select-best',
value: 'choose the best one',
},
],
},
],
};
try {
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
expect(comparisonCacheMissCount).toBe(2);
expect(matchesSelectBestSpy).toHaveBeenCalledTimes(2);
expect(comparisonRepeatIndexes).toEqual([0, 1]);
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
expect(comparisonCacheMissCount).toBe(2);
expect(matchesSelectBestSpy).toHaveBeenCalledTimes(4);
expect(comparisonRepeatIndexes).toEqual([0, 1, 0, 1]);
} finally {
matchesSelectBestSpy.mockRestore();
}
});
it('isolates resumed select-best comparison cache entries by original repeat index', async () => {
await clearCache();
const originalResume = cliState.resume;
const { default: EvalResultModel } = await import('../../src/models/evalResult');
const getCompletedIndexPairsSpy = vi
.spyOn(EvalResultModel, 'getCompletedIndexPairs')
.mockResolvedValue(new Set(['0:0', '0:1', '1:0', '1:1']));
const matchers = await import('../../src/matchers/comparison');
let comparisonCacheMissCount = 0;
const comparisonRepeatIndexes: Array<number | undefined> = [];
const matchesSelectBestSpy = vi
.spyOn(matchers, 'matchesSelectBest')
.mockImplementation(async (_criteria, outputs, _grading, _vars, context) => {
comparisonRepeatIndexes.push(context?.repeatIndex);
const cache = context?.getCache?.() ?? getCache();
const cachedResult = (await cache.get('resume-select-best-key')) as string | undefined;
if (!cachedResult) {
comparisonCacheMissCount += 1;
await cache.set('resume-select-best-key', `repeat-${context?.repeatIndex}`);
}
return outputs.map((_output, index) => ({
pass: index === 0,
score: index === 0 ? 1 : 0,
reason: index === 0 ? 'Selected as best' : 'Not selected',
}));
});
const provider: ApiProvider = {
id: () => 'mock-provider',
callApi: vi.fn().mockResolvedValue({
cached: false,
output: 'should be skipped by resume',
tokenUsage: createEmptyTokenUsage(),
}),
};
const testSuite: TestSuite = {
providers: [provider],
prompts: [toPrompt('Prompt A'), toPrompt('Prompt B')],
tests: [
{
assert: [
{
type: 'select-best',
value: 'choose the best one',
},
],
},
],
};
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
const fetchResultsByTestIdxSpy = vi
.spyOn(evalRecord, 'fetchResultsByTestIdx')
.mockImplementation(
async (testIdx: number) =>
testSuite.prompts.map((prompt, promptIdx) => ({
failureReason: ResultFailureReason.NONE,
gradingResult: {
componentResults: [],
pass: true,
reason: 'Existing result',
score: 1,
},
prompt,
promptIdx,
provider: { id: provider.id() },
response: {
output: `${prompt.raw}-test-${testIdx}`,
tokenUsage: createEmptyTokenUsage(),
},
save: vi.fn().mockResolvedValue(undefined),
score: 1,
success: true,
testCase: {
...testSuite.tests![0],
vars: {},
},
testIdx,
})) as any,
);
evalRecord.persisted = true;
cliState.resume = true;
try {
await evaluate(testSuite, evalRecord, { maxConcurrency: 1, repeat: 2 });
expect(getCompletedIndexPairsSpy).toHaveBeenCalledWith(evalRecord.id, {
excludeErrors: cliState.retryMode,
});
expect(fetchResultsByTestIdxSpy).toHaveBeenCalledTimes(2);
expect(provider.callApi).not.toHaveBeenCalled();
expect(comparisonCacheMissCount).toBe(2);
expect(matchesSelectBestSpy).toHaveBeenCalledTimes(2);
expect(comparisonRepeatIndexes).toEqual([0, 1]);
} finally {
cliState.resume = originalResume;
getCompletedIndexPairsSpy.mockRestore();
matchesSelectBestSpy.mockRestore();
fetchResultsByTestIdxSpy.mockRestore();
}
});
});