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

613 lines
19 KiB
TypeScript

import * as path from 'path';
import { describe, expect, it } from 'vitest';
import {
deduplicateTestCases,
extractRuntimeVars,
filterRuntimeVars,
getTestCaseDeduplicationKey,
isRuntimeVar,
resultIsForTestCase,
varsMatch,
} from '../../src/util/comparison';
import { createEvaluateResult } from '../factories/eval';
import type { TestCase } from '../../src/types/index';
describe('varsMatch', () => {
it('true with both undefined', () => {
expect(varsMatch(undefined, undefined)).toBe(true);
});
it('false with one undefined', () => {
expect(varsMatch(undefined, {})).toBe(false);
expect(varsMatch({}, undefined)).toBe(false);
});
it('true with matching non-empty objects', () => {
expect(varsMatch({ key: 'value' }, { key: 'value' })).toBe(true);
expect(varsMatch({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
});
it('false with different values', () => {
expect(varsMatch({ key: 'value1' }, { key: 'value2' })).toBe(false);
expect(varsMatch({ a: 1 }, { a: 2 })).toBe(false);
});
it('false with different keys', () => {
expect(varsMatch({ a: 1 }, { b: 1 })).toBe(false);
});
});
describe('resultIsForTestCase', () => {
const testCase: TestCase = {
provider: 'provider',
vars: {
key: 'value',
},
};
const result = createEvaluateResult({
provider: 'provider' as any,
vars: { key: 'value' },
});
it('is true', async () => {
expect(resultIsForTestCase(result, testCase)).toBe(true);
});
it('is false if provider is different', async () => {
const nonMatchTestCase: TestCase = {
provider: 'different',
vars: {
key: 'value',
},
};
expect(resultIsForTestCase(result, nonMatchTestCase)).toBe(false);
});
it('matches when test has provider but result provider is null', async () => {
// This covers agentic providers (like agentic:memory-poisoning) where
// the result's provider is null/undefined (e.g., from cloud results)
const testCaseWithProvider: TestCase = {
provider: 'agentic:memory-poisoning',
vars: { key: 'value' },
};
const resultWithNullProvider = createEvaluateResult({
provider: null as any,
vars: { key: 'value' },
});
// Should match because we can't compare when result provider is missing
expect(resultIsForTestCase(resultWithNullProvider, testCaseWithProvider)).toBe(true);
});
it('matches when test has provider but result provider is undefined', async () => {
const testCaseWithProvider: TestCase = {
provider: 'agentic:memory-poisoning',
vars: { key: 'value' },
};
const resultWithUndefinedProvider = createEvaluateResult({
provider: undefined as any,
vars: { key: 'value' },
});
expect(resultIsForTestCase(resultWithUndefinedProvider, testCaseWithProvider)).toBe(true);
});
it('matches when test has no provider and result has provider', async () => {
const testCaseNoProvider: TestCase = {
vars: { key: 'value' },
};
const resultWithProvider = createEvaluateResult({
provider: { id: 'some-provider' },
vars: { key: 'value' },
});
expect(resultIsForTestCase(resultWithProvider, testCaseNoProvider)).toBe(true);
});
it('does not match when agentic provider differs from result target provider (both present)', async () => {
// This documents intentional strict behavior: when BOTH providers are present,
// they must match. Agentic providers (like agentic:memory-poisoning) that differ
// from the target provider in the result should NOT match.
// Lenient matching only applies when one side is missing provider info.
const testCaseWithAgenticProvider: TestCase = {
provider: 'agentic:memory-poisoning',
vars: { key: 'value' },
};
const resultWithTargetProvider = createEvaluateResult({
provider: { id: 'openai:gpt-4' },
vars: { key: 'value' },
});
// Both providers present and different → no match (strict comparison)
expect(resultIsForTestCase(resultWithTargetProvider, testCaseWithAgenticProvider)).toBe(false);
});
it('is false if vars are different', async () => {
const nonMatchTestCase: TestCase = {
provider: 'provider',
vars: {
key: 'different',
},
};
expect(resultIsForTestCase(result, nonMatchTestCase)).toBe(false);
});
it('matches when test provider is label and result provider has label and id', async () => {
const labelledResult = createEvaluateResult({
provider: { id: 'file://provider.js', label: 'provider' },
vars: { key: 'value' },
});
expect(resultIsForTestCase(labelledResult, testCase)).toBe(true);
});
it('matches when test provider is relative path and result provider is absolute', async () => {
const relativePathTestCase: TestCase = {
provider: 'file://./provider.js',
vars: { key: 'value' },
};
const absolutePathResult = createEvaluateResult({
provider: { id: `file://${path.join(process.cwd(), 'provider.js')}` },
vars: { key: 'value' },
});
expect(resultIsForTestCase(absolutePathResult, relativePathTestCase)).toBe(true);
});
it('matches when test provider has no file:// prefix and result has absolute path', async () => {
const noPathTestCase: TestCase = {
provider: './provider.js',
vars: { key: 'value' },
};
const absolutePathResult = createEvaluateResult({
provider: `file://${path.join(process.cwd(), 'provider.js')}` as any,
vars: { key: 'value' },
});
expect(resultIsForTestCase(absolutePathResult, noPathTestCase)).toBe(true);
});
it('matches when result.vars has runtime variables like _conversation', async () => {
// This tests the fix for issue #5849 - cache behavior regression
// result.vars contains runtime variables that should be filtered out
const testCase: TestCase = {
provider: 'provider',
vars: {
input: 'hello',
language: 'en',
},
};
const result = createEvaluateResult({
provider: 'provider' as any,
vars: {
input: 'hello',
language: 'en',
_conversation: [], // Runtime variable added during evaluation
},
testCase: {
vars: {
input: 'hello',
language: 'en',
},
},
});
// Should match because _conversation is filtered out
expect(resultIsForTestCase(result, testCase)).toBe(true);
});
it('matches when result.vars has runtime variables and testCase also has them', async () => {
// Edge case: both have runtime vars (shouldn't happen in practice but should still work)
const testCase: TestCase = {
provider: 'provider',
vars: {
input: 'hello',
language: 'en',
_conversation: [],
},
};
const result = createEvaluateResult({
provider: 'provider' as any,
vars: {
input: 'hello',
language: 'en',
_conversation: [],
},
});
// Should match because both have same vars after filtering
expect(resultIsForTestCase(result, testCase)).toBe(true);
});
it('matches when result.vars has sessionId runtime variable from GOAT/Crescendo providers', async () => {
// This tests the fix for multi-turn strategy providers (GOAT, Crescendo)
// that add sessionId to vars during execution
const testCase: TestCase = {
provider: 'provider',
vars: {
prompt: 'test prompt',
goal: 'test goal',
},
};
const result = createEvaluateResult({
provider: 'provider' as any,
vars: {
prompt: 'test prompt',
goal: 'test goal',
sessionId: 'goat-session-abc123', // Added by GOAT provider during evaluation
},
});
// Should match because sessionId is filtered out
expect(resultIsForTestCase(result, testCase)).toBe(true);
});
it('matches when result.vars has both _conversation and sessionId runtime variables', async () => {
const testCase: TestCase = {
provider: 'provider',
vars: {
input: 'hello',
},
};
const result = createEvaluateResult({
provider: 'provider' as any,
vars: {
input: 'hello',
_conversation: [{ role: 'user', content: 'hi' }],
sessionId: 'session-xyz789',
},
});
// Should match because both runtime vars are filtered out
expect(resultIsForTestCase(result, testCase)).toBe(true);
});
it('matches when testCase also has sessionId (both filtered)', async () => {
// Edge case: if user explicitly sets sessionId in test config, it should still match
const testCase: TestCase = {
provider: 'provider',
vars: {
prompt: 'test',
sessionId: 'user-defined-session',
},
};
const result = createEvaluateResult({
provider: 'provider' as any,
vars: {
prompt: 'test',
sessionId: 'different-runtime-session',
},
});
// Should match because sessionId is filtered from both sides
expect(resultIsForTestCase(result, testCase)).toBe(true);
});
});
describe('isRuntimeVar', () => {
it('should return true for underscore-prefixed variables', () => {
expect(isRuntimeVar('_conversation')).toBe(true);
expect(isRuntimeVar('_metadata')).toBe(true);
expect(isRuntimeVar('_internal')).toBe(true);
expect(isRuntimeVar('_')).toBe(true);
});
it('should return true for explicit runtime vars like sessionId', () => {
expect(isRuntimeVar('sessionId')).toBe(true);
});
it('should return false for regular variables', () => {
expect(isRuntimeVar('prompt')).toBe(false);
expect(isRuntimeVar('input')).toBe(false);
expect(isRuntimeVar('context')).toBe(false);
expect(isRuntimeVar('goal')).toBe(false);
});
it('should return false for variables that contain underscore but do not start with it', () => {
expect(isRuntimeVar('my_variable')).toBe(false);
expect(isRuntimeVar('some_conversation')).toBe(false);
expect(isRuntimeVar('session_id')).toBe(false);
});
});
describe('filterRuntimeVars', () => {
it('should filter out _conversation', () => {
const vars = { input: 'hello', _conversation: [{ role: 'user', content: 'hi' }] };
const result = filterRuntimeVars(vars);
expect(result).toEqual({ input: 'hello' });
expect(result).not.toHaveProperty('_conversation');
});
it('should filter out sessionId', () => {
const vars = { input: 'hello', sessionId: 'test-session-123' };
const result = filterRuntimeVars(vars);
expect(result).toEqual({ input: 'hello' });
expect(result).not.toHaveProperty('sessionId');
});
it('should filter out any underscore-prefixed variables', () => {
const vars = {
input: 'hello',
_conversation: [],
_metadata: { key: 'value' },
_internal: 'data',
_customRuntimeVar: 'test',
};
const result = filterRuntimeVars(vars);
expect(result).toEqual({ input: 'hello' });
expect(result).not.toHaveProperty('_conversation');
expect(result).not.toHaveProperty('_metadata');
expect(result).not.toHaveProperty('_internal');
expect(result).not.toHaveProperty('_customRuntimeVar');
});
it('should filter out multiple runtime vars', () => {
const vars = {
input: 'hello',
goal: 'test goal',
_conversation: [],
sessionId: 'test-session-123',
};
const result = filterRuntimeVars(vars);
expect(result).toEqual({ input: 'hello', goal: 'test goal' });
expect(result).not.toHaveProperty('_conversation');
expect(result).not.toHaveProperty('sessionId');
});
it('should handle undefined vars', () => {
expect(filterRuntimeVars(undefined)).toBeUndefined();
});
it('should handle empty vars', () => {
expect(filterRuntimeVars({})).toEqual({});
});
it('should not mutate original vars', () => {
const vars = { input: 'hello', sessionId: 'test-123', _conversation: [] };
const original = { ...vars };
filterRuntimeVars(vars);
expect(vars).toEqual(original);
});
it('should preserve all non-runtime vars', () => {
const vars = {
input: 'hello',
output: 'world',
context: 'some context',
nested: { key: 'value' },
sessionId: 'to-be-removed',
};
const result = filterRuntimeVars(vars);
expect(result).toEqual({
input: 'hello',
output: 'world',
context: 'some context',
nested: { key: 'value' },
});
});
it('should preserve variables with underscore in middle of name', () => {
const vars = {
my_variable: 'value1',
some_conversation: 'value2',
session_id: 'value3',
_runtime: 'filtered',
};
const result = filterRuntimeVars(vars);
expect(result).toEqual({
my_variable: 'value1',
some_conversation: 'value2',
session_id: 'value3',
});
});
});
describe('extractRuntimeVars', () => {
it('should extract _conversation', () => {
const vars = { input: 'hello', _conversation: [{ role: 'user', content: 'hi' }] };
const result = extractRuntimeVars(vars);
expect(result).toEqual({ _conversation: [{ role: 'user', content: 'hi' }] });
});
it('should extract sessionId', () => {
const vars = { input: 'hello', sessionId: 'test-session-123' };
const result = extractRuntimeVars(vars);
expect(result).toEqual({ sessionId: 'test-session-123' });
});
it('should extract any underscore-prefixed variables', () => {
const vars = {
input: 'hello',
_conversation: [],
_metadata: { key: 'value' },
_internal: 'data',
_customRuntimeVar: 'test',
};
const result = extractRuntimeVars(vars);
expect(result).toEqual({
_conversation: [],
_metadata: { key: 'value' },
_internal: 'data',
_customRuntimeVar: 'test',
});
});
it('should extract multiple runtime vars', () => {
const vars = {
input: 'hello',
goal: 'test goal',
_conversation: [{ role: 'assistant', content: 'response' }],
sessionId: 'test-session-123',
};
const result = extractRuntimeVars(vars);
expect(result).toEqual({
_conversation: [{ role: 'assistant', content: 'response' }],
sessionId: 'test-session-123',
});
});
it('should return undefined for undefined vars', () => {
expect(extractRuntimeVars(undefined)).toBeUndefined();
});
it('should return undefined for empty vars', () => {
expect(extractRuntimeVars({})).toBeUndefined();
});
it('should return undefined when no runtime vars exist', () => {
const vars = { input: 'hello', output: 'world' };
expect(extractRuntimeVars(vars)).toBeUndefined();
});
it('should not mutate original vars', () => {
const vars = { input: 'hello', sessionId: 'test-123', _conversation: [] };
const original = { ...vars };
extractRuntimeVars(vars);
expect(vars).toEqual(original);
});
it('should not extract variables with underscore in middle of name', () => {
const vars = {
my_variable: 'value1',
some_conversation: 'value2',
session_id: 'value3',
_runtime: 'filtered',
};
const result = extractRuntimeVars(vars);
expect(result).toEqual({ _runtime: 'filtered' });
expect(result).not.toHaveProperty('my_variable');
expect(result).not.toHaveProperty('some_conversation');
expect(result).not.toHaveProperty('session_id');
});
it('should be inverse of filterRuntimeVars', () => {
const vars = {
input: 'hello',
goal: 'test',
_conversation: [{ role: 'user', content: 'hi' }],
sessionId: 'session-123',
_metadata: { custom: true },
};
const filtered = filterRuntimeVars(vars);
const extracted = extractRuntimeVars(vars);
// Combining filtered and extracted should give back original vars
expect({ ...filtered, ...extracted }).toEqual(vars);
// No overlap between filtered and extracted
const filteredKeys = Object.keys(filtered || {});
const extractedKeys = Object.keys(extracted || {});
expect(filteredKeys.filter((k) => extractedKeys.includes(k))).toEqual([]);
});
});
describe('getTestCaseDeduplicationKey', () => {
it('should generate key from vars and strategyId', () => {
const testCase: TestCase = {
vars: { prompt: 'hello' },
metadata: { strategyId: 'jailbreak' },
};
const key = getTestCaseDeduplicationKey(testCase);
expect(JSON.parse(key)).toEqual({
vars: { prompt: 'hello' },
strategyId: 'jailbreak',
});
});
it('should use "none" for tests without strategyId', () => {
const testCase: TestCase = {
vars: { prompt: 'hello' },
};
const key = getTestCaseDeduplicationKey(testCase);
expect(JSON.parse(key)).toEqual({
vars: { prompt: 'hello' },
strategyId: 'none',
});
});
it('should filter out runtime vars from key', () => {
const testCase: TestCase = {
vars: { prompt: 'hello', _conversation: [], sessionId: 'abc' },
metadata: { strategyId: 'basic' },
};
const key = getTestCaseDeduplicationKey(testCase);
expect(JSON.parse(key)).toEqual({
vars: { prompt: 'hello' },
strategyId: 'basic',
});
});
it('should handle undefined vars', () => {
const testCase: TestCase = {
metadata: { strategyId: 'test' },
};
const key = getTestCaseDeduplicationKey(testCase);
expect(JSON.parse(key)).toEqual({
vars: undefined,
strategyId: 'test',
});
});
});
describe('deduplicateTestCases', () => {
it('should remove duplicate tests with same vars and strategyId', () => {
const tests: TestCase[] = [
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'basic' } },
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'basic' } },
{ vars: { prompt: 'world' }, metadata: { strategyId: 'basic' } },
];
const result = deduplicateTestCases(tests);
expect(result).toHaveLength(2);
expect(result[0].vars).toEqual({ prompt: 'hello' });
expect(result[1].vars).toEqual({ prompt: 'world' });
});
it('should keep tests with same vars but different strategyId', () => {
const tests: TestCase[] = [
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'basic' } },
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'jailbreak' } },
];
const result = deduplicateTestCases(tests);
expect(result).toHaveLength(2);
});
it('should filter runtime vars when checking for duplicates', () => {
const tests: TestCase[] = [
{ vars: { prompt: 'hello', sessionId: 'abc' }, metadata: { strategyId: 'basic' } },
{ vars: { prompt: 'hello', sessionId: 'xyz' }, metadata: { strategyId: 'basic' } },
];
const result = deduplicateTestCases(tests);
// These should be considered duplicates after filtering sessionId
expect(result).toHaveLength(1);
});
it('should handle empty array', () => {
expect(deduplicateTestCases([])).toEqual([]);
});
it('should preserve order (keep first occurrence)', () => {
const tests: TestCase[] = [
{ vars: { prompt: 'first' }, metadata: { strategyId: 'basic', order: 1 } },
{ vars: { prompt: 'first' }, metadata: { strategyId: 'basic', order: 2 } },
];
const result = deduplicateTestCases(tests);
expect(result).toHaveLength(1);
expect(result[0].metadata?.order).toBe(1);
});
});