Files
wehub-resource-sync 0d3cb498a3
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

4246 lines
145 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs';
import cliProgress from 'cli-progress';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import logger from '../../src/logger';
import { loadApiProvider } from '../../src/providers/index';
import {
getDefaultNFanout,
HARM_PLUGINS,
MULTI_INPUT_VAR,
PII_PLUGINS,
} from '../../src/redteam/constants';
import { extractEntities } from '../../src/redteam/extraction/entities';
import { extractSystemPurpose } from '../../src/redteam/extraction/purpose';
import {
calculateTotalTests,
getTestCount,
resolvePluginConfig,
synthesize,
} from '../../src/redteam/index';
import { Plugins } from '../../src/redteam/plugins/index';
import { getRemoteHealthUrl, shouldGenerateRemote } from '../../src/redteam/remoteGeneration';
import { Strategies, validateStrategies } from '../../src/redteam/strategies/index';
import { checkRemoteHealth } from '../../src/util/apiHealth';
import { extractVariablesFromTemplates } from '../../src/util/templates';
import { loadYaml } from '../../src/util/yamlLoad';
import { mockProcessEnv, stripAnsi } from '../util/utils';
import type { Inputs } from '../../src/types/shared';
vi.mock('cli-progress');
vi.mock('../../src/logger');
vi.mock('../../src/providers');
vi.mock('../../src/redteam/extraction/entities');
vi.mock('../../src/redteam/extraction/purpose');
vi.mock('../../src/util/templates', async () => {
const originalModule = await vi.importActual('../../src/util/templates');
return {
...originalModule,
extractVariablesFromTemplates: vi.fn().mockReturnValue(['query']),
};
});
vi.mock('../../src/redteam/strategies', async () => ({
...(await vi.importActual('../../src/redteam/strategies')),
validateStrategies: vi.fn().mockImplementation(function (strategies) {
if (strategies.some((s: { id: string }) => s.id === 'invalid-strategy')) {
throw new Error('Invalid strategies');
}
}),
}));
vi.mock('../../src/util/apiHealth');
vi.mock('../../src/redteam/remoteGeneration');
vi.mock('../../src/redteam/sharpAvailability', async () => ({
...(await vi.importActual('../../src/redteam/sharpAvailability')),
validateSharpDependency: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/redteam/util', async () => ({
...(await vi.importActual('../../src/redteam/util')),
extractGoalFromPrompt: vi.fn().mockResolvedValue('mocked goal'),
}));
describe('synthesize', () => {
const mockProvider = {
callApi: vi.fn(),
generate: vi.fn(),
id: () => 'test-provider',
};
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.resetAllMocks();
// Set up logger mocks
vi.mocked(logger.info).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.warn).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.error).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.debug).mockImplementation(function () {
return logger as any;
});
// Set up templates mock with consistent default behavior
vi.mocked(extractVariablesFromTemplates).mockImplementation(function () {
return ['query'];
});
vi.mocked(extractEntities).mockResolvedValue(['entity1', 'entity2']);
vi.mocked(extractSystemPurpose).mockResolvedValue('Test purpose');
vi.mocked(loadApiProvider).mockResolvedValue(mockProvider);
vi.spyOn(process, 'exit').mockImplementation(function (
code?: string | number | null | undefined,
) {
throw new Error(`Process.exit called with code ${code}`);
});
vi.mocked(validateStrategies).mockImplementation(async function () {});
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
increment: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
update: vi.fn(),
} as any;
});
// Disable remote generation by default to avoid health checks interfering
// with tests that don't explicitly set this behaviour
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(getRemoteHealthUrl).mockImplementation(function () {
return 'https://api.test/health';
});
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'OK',
message: 'Cloud API is healthy',
});
});
// Input handling tests
describe('Input handling', () => {
it('should use provided purpose and entities if given', async () => {
const result = await synthesize({
entities: ['custom-entity'],
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
purpose: 'Custom purpose',
strategies: [],
targetIds: ['test-provider'],
});
expect(result).toEqual(
expect.objectContaining({
entities: ['custom-entity'],
purpose: 'Custom purpose',
}),
);
expect(extractEntities).not.toHaveBeenCalled();
expect(extractSystemPurpose).not.toHaveBeenCalled();
});
it('should pass resolved target context when extracting purpose and entities', async () => {
const generationContext = {
providerTargetIds: ['file://local-provider.ts'],
cloudTargetId: 'cloud-target-123',
};
await synthesize({
cloudTargetDatabaseId: 'cloud-target-123',
language: 'english',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['file://local-provider.ts'],
});
expect(extractEntities).toHaveBeenCalledWith(
expect.any(Object),
['Test prompt'],
generationContext,
);
expect(extractSystemPurpose).toHaveBeenCalledWith(
expect.any(Object),
['Test prompt'],
generationContext,
);
});
it('should handle empty prompts array', async () => {
await expect(
synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: [] as unknown as [string, ...string[]],
strategies: [],
targetIds: ['test-provider'],
}),
).rejects.toThrow('Prompts array cannot be empty');
});
it('should correctly process multiple prompts', async () => {
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Prompt 1', 'Prompt 2', 'Prompt 3'],
strategies: [],
targetIds: ['test-provider'],
});
expect(extractSystemPurpose).toHaveBeenCalledWith(
expect.any(Object),
['Prompt 1', 'Prompt 2', 'Prompt 3'],
expect.any(Object),
);
expect(extractEntities).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(Function) }),
['Prompt 1', 'Prompt 2', 'Prompt 3'],
expect.any(Object),
);
});
});
// API provider tests
describe('API provider', () => {
it('should use the provided API provider if given', async () => {
const customProvider = {
callApi: vi.fn(),
generate: vi.fn(),
id: () => 'custom-provider',
};
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
provider: customProvider,
strategies: [],
targetIds: ['custom-provider'],
});
expect(loadApiProvider).not.toHaveBeenCalled();
});
});
// Plugin and strategy tests
describe('Plugins and strategies', () => {
it('should generate test cases for each plugin', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'plugin1', numTests: 2 },
{ id: 'plugin2', numTests: 3 },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(mockPluginAction).toHaveBeenCalledTimes(2);
expect(result.testCases).toEqual([
expect.objectContaining({ metadata: expect.objectContaining({ pluginId: 'plugin1' }) }),
expect.objectContaining({ metadata: expect.objectContaining({ pluginId: 'plugin2' }) }),
]);
});
it('should pass maxCharsPerMessage through synthesize into plugin metadata and strategy config', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'short' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((testCase: any) => ({
...testCase,
metadata: {
...testCase.metadata,
strategyId: 'goat',
},
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
action: mockStrategyAction,
id: 'goat',
});
const result = await synthesize({
maxCharsPerMessage: 12,
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'goat' }],
targetIds: ['test-provider'],
});
expect(mockPluginAction).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
maxCharsPerMessage: 12,
modifiers: expect.objectContaining({
maxCharsPerMessage: 'Each generated user message must be 12 characters or fewer.',
}),
}),
}),
);
expect(mockStrategyAction).toHaveBeenCalledWith(
expect.any(Array),
'query',
expect.objectContaining({
maxCharsPerMessage: 12,
}),
'goat',
);
expect(result.testCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
metadata: expect.objectContaining({
pluginId: 'test-plugin',
pluginConfig: expect.objectContaining({
maxCharsPerMessage: 12,
}),
modifiers: expect.objectContaining({
maxCharsPerMessage: 'Each generated user message must be 12 characters or fewer.',
}),
}),
}),
expect.objectContaining({
metadata: expect.objectContaining({
strategyConfig: expect.objectContaining({
maxCharsPerMessage: 12,
}),
}),
}),
]),
);
});
it('should pass shared generation options through custom file plugins', async () => {
vi.spyOn(Plugins, 'find').mockReturnValue(undefined);
const pluginPath = 'test/redteam/fixtures/custom-plugin-shared-options.yaml';
const fileUtil = await import('../../src/util/file');
vi.spyOn(fileUtil, 'maybeLoadFromExternalFile').mockImplementation((filePath) => {
if (String(filePath).endsWith(pluginPath)) {
return {
id: 'stable-shared-options',
generator: 'Prompt: shared option probe',
grader: 'Grade the response based on {{ purpose }}',
metric: 'custom-shared-options',
};
}
return filePath;
});
mockProvider.callApi.mockImplementation(async (input: string) => ({
output: input.includes('language: fr')
? 'Prompt: bonjour'
: input.includes('language: de')
? 'Prompt: guten tag'
: 'Prompt: fallback',
}));
const result = await synthesize({
language: 'en',
maxCharsPerMessage: 50,
numTests: 1,
plugins: [
{
id: `file://./${pluginPath}`,
numTests: 1,
config: {
language: ['fr', 'de'],
modifiers: { tone: 'formal' },
},
severity: 'high',
},
],
prompts: ['Test prompt'],
provider: mockProvider,
purpose: 'Custom plugin purpose',
strategies: [],
targetIds: ['test-provider'],
testGenerationInstructions: 'Prefer edge-case probes',
});
expect(mockProvider.callApi).toHaveBeenCalledTimes(2);
for (const call of mockProvider.callApi.mock.calls) {
expect(call[0]).toContain('testGenerationInstructions: Prefer edge-case probes');
expect(call[0]).toContain('tone: formal');
expect(call[0]).toContain(
'maxCharsPerMessage: Each generated user message must be 50 characters or fewer.',
);
}
expect(mockProvider.callApi.mock.calls.map((call) => call[0])).toEqual(
expect.arrayContaining([
expect.stringContaining('language: fr'),
expect.stringContaining('language: de'),
]),
);
expect(result.testCases).toHaveLength(2);
expect(result.testCases.map((testCase) => testCase.metadata?.language).sort()).toEqual([
'de',
'fr',
]);
expect(result.testCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
metadata: expect.objectContaining({
pluginId: `file://./${pluginPath}`,
pluginConfig: expect.objectContaining({
language: expect.stringMatching(/^(fr|de)$/),
maxCharsPerMessage: 50,
}),
severity: 'high',
modifiers: expect.objectContaining({
language: expect.stringMatching(/^(fr|de)$/),
maxCharsPerMessage: 'Each generated user message must be 50 characters or fewer.',
testGenerationInstructions: 'Prefer edge-case probes',
tone: 'formal',
}),
}),
}),
]),
);
});
it('should report failed custom file plugin language batches', async () => {
vi.spyOn(Plugins, 'find').mockReturnValue(undefined);
const pluginPath = 'test/redteam/fixtures/custom-plugin-language-failure.yaml';
const fileUtil = await import('../../src/util/file');
vi.spyOn(fileUtil, 'maybeLoadFromExternalFile').mockImplementation((filePath) => {
if (String(filePath).endsWith(pluginPath)) {
return {
generator: 'Prompt: language-specific probe',
grader: 'Grade the response based on {{ purpose }}',
metric: 'custom-language-failure',
};
}
return filePath;
});
mockProvider.callApi.mockImplementation(async (input: string) => {
if (input.includes('language: fr')) {
throw new Error('French generation failed');
}
return { output: 'Prompt: guten tag' };
});
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{
id: `file://./${pluginPath}`,
numTests: 1,
config: {
language: ['fr', 'de'],
},
},
],
prompts: ['Test prompt'],
provider: mockProvider,
purpose: 'Custom plugin purpose',
strategies: [],
targetIds: ['test-provider'],
});
expect(result.testCases).toHaveLength(1);
expect(result.testCases[0].metadata?.language).toBe('de');
expect(result.failedPlugins).toEqual([
{
pluginId: `(fr) file://./${pluginPath}`,
requested: 1,
},
]);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
`[Language Processing] Error generating tests for custom plugin file://./${pluginPath}: Error: French generation failed`,
),
);
});
it('should pass target inputs into custom file plugins in multi-input mode', async () => {
vi.spyOn(Plugins, 'find').mockReturnValue(undefined);
const pluginPath = 'test/redteam/fixtures/custom-plugin-multi-input.yaml';
const fileUtil = await import('../../src/util/file');
vi.spyOn(fileUtil, 'maybeLoadFromExternalFile').mockImplementation((filePath) => {
if (String(filePath).endsWith(pluginPath)) {
return {
generator: `
{{ outputFormat }}
{% if hasCustomOutputFormat %}
<Prompt>{"user_message":"Ignore the retrieved answer","retrieved_context":"Approved policy text"}</Prompt>
{% else %}
Prompt: missing multi-input mode
{% endif %}
`,
grader: 'Grade the response based on {{ purpose }}',
metric: 'custom-multi-input',
};
}
return filePath;
});
mockProvider.callApi.mockImplementation(async (input: string) => ({ output: input }));
const result = await synthesize({
inputs: {
user_message: 'The attacker-controlled user request',
retrieved_context: 'Retrieved context passed to the target',
},
numTests: 1,
plugins: [{ id: `file://./${pluginPath}`, numTests: 1 }],
prompts: ['{{user_message}}\n{{retrieved_context}}'],
provider: mockProvider,
purpose: 'Custom plugin purpose',
strategies: [],
targetIds: ['test-provider'],
});
expect(mockProvider.callApi).toHaveBeenCalledWith(
expect.stringContaining('Required keys: "user_message", "retrieved_context"'),
);
expect(result.injectVar).toBe(MULTI_INPUT_VAR);
expect(result.testCases).toEqual([
expect.objectContaining({
vars: expect.objectContaining({
[MULTI_INPUT_VAR]:
'{"user_message":"Ignore the retrieved answer","retrieved_context":"Approved policy text"}',
user_message: 'Ignore the retrieved answer',
retrieved_context: 'Approved policy text',
}),
metadata: expect.objectContaining({
inputVars: {
user_message: 'Ignore the retrieved answer',
retrieved_context: 'Approved policy text',
},
modifiers: expect.objectContaining({
__outputFormat: 'multi-input-mode: user_message, retrieved_context',
}),
pluginConfig: expect.objectContaining({
inputs: {
user_message: 'The attacker-controlled user request',
retrieved_context: 'Retrieved context passed to the target',
},
}),
}),
}),
]);
});
it('should warn about unregistered plugins', async () => {
vi.spyOn(Plugins, 'find').mockReturnValue(undefined);
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'unregistered-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(logger.warn).toHaveBeenCalledWith(
'Plugin unregistered-plugin not registered, skipping',
);
});
it('should handle HARM_PLUGINS and PII_PLUGINS correctly', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'harmful', numTests: 2 },
{ id: 'pii', numTests: 3 },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Verify the test cases by checking each one individually rather than hardcoding a number
// Each test case should have a valid plugin ID that comes from one of our plugin sets
const testCases = result.testCases;
// All test cases should have valid plugin IDs
const pluginIds = testCases.map((tc) => tc.metadata.pluginId);
// Check that each plugin ID belongs to one of our known plugin categories
const allValidPluginIds = [...Object.keys(HARM_PLUGINS), ...PII_PLUGINS];
// Every plugin ID should be in our list of valid plugins
pluginIds.forEach((id) => {
expect(allValidPluginIds).toContain(id);
});
// Check for uniqueness - we should have unique plugin IDs (no duplicates of the same plugin)
const uniquePluginIds = new Set(pluginIds);
// The expected number of test cases is the number of unique plugin IDs we actually got
// This is more reliable than trying to predict the exact expansion logic
const expectedTestCount = uniquePluginIds.size;
// Assert that we got exactly the expected number of test cases
expect(testCases).toHaveLength(expectedTestCount);
});
it('should generate a correct report for plugins and strategies', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockStrategyAction = vi.fn().mockReturnValue([{ test: 'strategy case' }]);
vi.spyOn(Strategies, 'find').mockReturnValue({
action: mockStrategyAction,
id: 'mockStrategy',
});
await synthesize({
language: 'en',
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [{ id: 'mockStrategy' }],
targetIds: ['test-provider'],
});
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Test Generation Report:'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('test-plugin'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('mockStrategy'));
});
it('should use default fan-out values when strategy config omits n', async () => {
const pluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
const pluginFindSpy = vi
.spyOn(Plugins, 'find')
.mockReturnValue({ action: pluginAction, key: 'mockPlugin' });
const strategyAction = vi.fn().mockImplementation(async function () {
return Array.from({ length: 5 }, (_, idx) => ({ vars: { query: `fanout-${idx}` } }));
});
const strategyFindSpy = vi.spyOn(Strategies, 'find').mockImplementation(function (predicate) {
if (typeof predicate === 'function') {
const strategies = [{ action: strategyAction, id: 'jailbreak:composite' }];
return strategies.find(predicate);
}
return undefined;
});
try {
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak:composite' }],
targetIds: ['test-provider'],
});
const reportMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string =>
typeof arg === 'string' && arg.includes('Test Generation Report'),
);
expect(reportMessage).toBeDefined();
const stripped = stripAnsi(reportMessage!);
const lines = stripped.split('\n');
// Find header row and parse column indexes
const headerRow = lines.find(
(line) => line.includes('Requested') && line.includes('Generated'),
);
expect(headerRow).toBeDefined();
const headerColumns = headerRow!
.split('│')
.map((col) => col.trim())
.filter((col) => col.length > 0);
const requestedIndex = headerColumns.indexOf('Requested');
const generatedIndex = headerColumns.indexOf('Generated');
expect(requestedIndex).toBeGreaterThan(-1);
expect(generatedIndex).toBeGreaterThan(-1);
// Find the jailbreak:composite row
const row = lines.find((line) => line.includes('jailbreak:composite'));
expect(row).toBeDefined();
const columns = row!
.split('│')
.map((col) => col.trim())
.filter((col) => col.length > 0);
// Requested = default fan-out * 1 base test
expect(columns[requestedIndex]).toBe(getDefaultNFanout('jailbreak:composite').toString());
// Generated = what our mock strategy returned
expect(columns[generatedIndex]).toBe(getDefaultNFanout('jailbreak:composite').toString());
// Verify the "Using strategies:" summary shows correct fan-out
const summaryMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string => typeof arg === 'string' && arg.includes('Using strategies:'),
);
expect(summaryMessage).toBeDefined();
const summaryStripped = stripAnsi(summaryMessage!);
const summaryLine = summaryStripped
.split('\n')
.find((line) => line.includes('jailbreak:composite'));
expect(summaryLine).toBeDefined();
expect(summaryLine).toContain(
`(${getDefaultNFanout('jailbreak:composite')} additional tests)`,
);
// Verify the "Test Generation Summary:" shows correct total
const totalSummaryMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string =>
typeof arg === 'string' && arg.includes('Test Generation Summary:'),
);
expect(totalSummaryMessage).toBeDefined();
const totalStripped = stripAnsi(totalSummaryMessage!);
// 1 base + default fan-out
expect(totalStripped).toContain(
`• Total tests: ${1 + getDefaultNFanout('jailbreak:composite')}`,
);
} finally {
pluginFindSpy.mockRestore();
strategyFindSpy.mockRestore();
}
});
it('should expand strategy collections into individual strategies', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockStrategyAction = vi.fn().mockReturnValue([{ test: 'strategy case' }]);
vi.spyOn(Strategies, 'find').mockImplementation(function (s: any) {
if (['morse', 'piglatin'].includes(s.id)) {
return { action: mockStrategyAction, id: s.id };
}
return undefined;
});
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [
{
id: 'other-encodings',
config: { customOption: 'test-value' },
},
],
targetIds: ['test-provider'],
});
expect(validateStrategies).toHaveBeenCalledWith(expect.any(Array));
});
it('should deduplicate strategies with the same ID', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
// Create a spy on validateStrategies to capture the strategies array
const validateStrategiesSpy = vi.mocked(validateStrategies);
validateStrategiesSpy.mockClear();
// Include both the collection and an individual strategy that's part of the collection
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [
{ id: 'other-encodings' },
{ id: 'morse' }, // This is already included in other-encodings
],
targetIds: ['test-provider'],
});
// Check that validateStrategies was called
expect(validateStrategiesSpy).toHaveBeenCalledWith(expect.any(Array));
// Look at the strategies that were passed to validateStrategies
// The array should have no duplicate ids
const strategiesArg = validateStrategiesSpy.mock.calls[0][0];
const strategyIds = strategiesArg.map((s: any) => s.id);
// Check for duplicates
const uniqueIds = new Set(strategyIds);
expect(uniqueIds.size).toBe(strategyIds.length);
// Should have morse only once
expect(strategyIds.filter((id: string) => id === 'morse')).toHaveLength(1);
// Should have at least morse and piglatin
expect(strategyIds).toContain('morse');
expect(strategyIds).toContain('piglatin');
});
it('should handle missing strategy collections gracefully', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [
{ id: 'unknown-collection' }, // This doesn't exist in the mappings
],
targetIds: ['test-provider'],
});
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('unknown-collection'));
});
it('should find exact strategy IDs like jailbreak:composite', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockJailbreakCompositeAction = vi.fn().mockReturnValue([
{
vars: { query: 'composite test' },
metadata: { strategyId: 'jailbreak:composite' },
},
]);
// Mock the Strategies array to include both jailbreak and jailbreak:composite
vi.spyOn(Strategies, 'find').mockImplementation(function (predicate) {
if (typeof predicate === 'function') {
const strategies = [
{
id: 'jailbreak',
action: vi.fn().mockReturnValue([{ vars: { query: 'basic jailbreak' } }]),
},
{ id: 'jailbreak:composite', action: mockJailbreakCompositeAction },
];
return strategies.find(predicate);
}
return undefined;
});
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak:composite' }],
targetIds: ['test-provider'],
});
// Should have called the composite action, not the basic jailbreak action
expect(mockJailbreakCompositeAction).toHaveBeenCalled();
// Check that the strategy test cases have the correct strategy ID
const strategyTestCases = result.testCases.filter((tc) => tc.metadata?.strategyId);
expect(strategyTestCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
metadata: expect.objectContaining({ strategyId: 'jailbreak:composite' }),
}),
]),
);
});
it('should re-materialize DOCX inputs after strategies mutate multi-input prompts', async () => {
const inputs = {
document: {
config: {
injectionPlacements: ['comment'],
inputPurpose: 'Summarize an uploaded policy draft',
},
description: 'DOCX document to summarize',
type: 'docx',
},
question: {
config: {
benign: true,
},
description: 'Benign user question',
type: 'text',
},
} satisfies Inputs;
const stalePayload = 'stale pre-strategy payload';
const strategyPayload = 'strategy-mutated payload';
const strategyQuestion = 'Please summarize the uploaded document.';
const wrapperSummary = 'Fresh strategy wrapper summary';
const injectedInstruction = 'Fresh strategy injected instruction';
const baseMultiInputPrompt = JSON.stringify({
document: stalePayload,
question: 'base question',
});
const strategyMultiInputPrompt = JSON.stringify({
document: strategyPayload,
question: strategyQuestion,
});
mockProvider.callApi.mockResolvedValue({
output: JSON.stringify({
bodyText: 'Fresh strategy document body',
injectedInstruction,
injectionPlacement: 'comment',
wrapperSummary,
}),
});
const mockPluginAction = vi.fn().mockResolvedValue([
{
metadata: {
inputMaterialization: {
document: {
injectedInstruction: 'stale injected instruction',
injectionPlacement: 'body',
inputPurpose: 'Summarize an uploaded policy draft',
wrapperSummary: 'stale wrapper summary',
},
},
pluginConfig: { inputs },
pluginId: 'prompt-extraction',
},
vars: {
[MULTI_INPUT_VAR]: baseMultiInputPrompt,
document: 'stale-document-data-uri',
question: 'base question',
},
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'prompt-extraction',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases: any[]) =>
testCases.map((testCase: any) => ({
...testCase,
vars: {
...testCase.vars,
[MULTI_INPUT_VAR]: strategyMultiInputPrompt,
},
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
action: mockStrategyAction,
id: 'jailbreak:meta',
});
const result = await synthesize({
inputs,
language: 'en',
numTests: 1,
plugins: [{ id: 'prompt-extraction', numTests: 1 }],
prompts: ['{{document}} {{question}}'],
provider: mockProvider,
purpose: 'Summarize uploaded documents',
strategies: [{ id: 'jailbreak:meta' }],
targetIds: ['test-provider'],
});
const strategyTestCase = result.testCases.find(
(testCase) => testCase.metadata?.strategyId === 'jailbreak:meta',
);
expect(strategyTestCase).toBeDefined();
expect(mockProvider.callApi).toHaveBeenCalledWith(expect.stringContaining(strategyPayload));
expect(mockProvider.callApi).not.toHaveBeenCalledWith(expect.stringContaining(stalePayload));
expect(strategyTestCase?.vars?.document).toEqual(
expect.stringMatching(
/^data:application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document;base64,/,
),
);
expect(strategyTestCase?.vars?.question).toBe(strategyQuestion);
expect(strategyTestCase?.metadata?.inputMaterialization).toMatchObject({
document: {
injectedInstruction,
injectionPlacement: 'comment',
inputPurpose: 'Summarize an uploaded policy draft',
wrapperSummary,
},
});
});
it('should preserve existing DOCX materialization when strategies leave the multi-input prompt unchanged', async () => {
const inputs = {
document: {
config: {
benign: false,
injectionPlacements: ['comment'],
inputPurpose: 'Summarize an uploaded policy draft',
},
description: 'DOCX document to summarize',
type: 'docx',
},
question: {
config: {
benign: true,
},
description: 'Benign user question',
type: 'text',
},
} satisfies Inputs;
const baseMultiInputPrompt = JSON.stringify({
document: 'stale pre-strategy payload',
question: 'base question',
});
const mockPluginAction = vi.fn().mockResolvedValue([
{
metadata: {
inputMaterialization: {
document: {
injectedInstruction: 'stale injected instruction',
injectionPlacement: 'body',
inputPurpose: 'Summarize an uploaded policy draft',
wrapperSummary: 'stale wrapper summary',
},
},
pluginConfig: { inputs },
pluginId: 'prompt-extraction',
},
vars: {
[MULTI_INPUT_VAR]: baseMultiInputPrompt,
document: 'stale-document-data-uri',
question: 'base question',
},
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'prompt-extraction',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases: any[]) => testCases);
vi.spyOn(Strategies, 'find').mockReturnValue({
action: mockStrategyAction,
id: 'jailbreak:meta',
});
const result = await synthesize({
inputs,
language: 'en',
numTests: 1,
plugins: [{ id: 'prompt-extraction', numTests: 1 }],
prompts: ['{{document}} {{question}}'],
provider: mockProvider,
purpose: 'Summarize uploaded documents',
strategies: [{ id: 'jailbreak:meta' }],
targetIds: ['test-provider'],
});
const strategyTestCase = result.testCases.find(
(testCase) => testCase.metadata?.strategyId === 'jailbreak:meta',
);
expect(strategyTestCase).toBeDefined();
expect(mockProvider.callApi).not.toHaveBeenCalled();
expect(strategyTestCase?.vars?.document).toBe('stale-document-data-uri');
expect(strategyTestCase?.metadata?.inputMaterialization).toMatchObject({
document: {
injectedInstruction: 'stale injected instruction',
injectionPlacement: 'body',
inputPurpose: 'Summarize an uploaded policy draft',
wrapperSummary: 'stale wrapper summary',
},
});
});
it('should find exact strategy ID for custom strategy', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockCustomAction = vi.fn().mockReturnValue([
{
vars: { query: 'custom test' },
metadata: { strategyId: 'custom' },
},
]);
// Mock the Strategies array to include the exact 'custom' strategy
vi.spyOn(Strategies, 'find').mockImplementation(function (predicate) {
if (typeof predicate === 'function') {
const strategies = [{ id: 'custom', action: mockCustomAction }];
return strategies.find(predicate);
}
return undefined;
});
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'custom' }],
targetIds: ['test-provider'],
});
// Should have found the exact 'custom' strategy and called its action
expect(mockCustomAction).toHaveBeenCalled();
// Check that the strategy test cases have the correct strategy ID
const strategyTestCases = result.testCases.filter((tc) => tc.metadata?.strategyId);
expect(strategyTestCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
metadata: expect.objectContaining({ strategyId: 'custom' }),
}),
]),
);
});
it('should fall back to base strategy ID for custom variants', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockCustomAction = vi.fn().mockReturnValue([
{
vars: { query: 'custom test' },
metadata: { strategyId: 'custom:aggressive' },
},
]);
// Mock the Strategies array to include only the base 'custom' strategy
vi.spyOn(Strategies, 'find').mockImplementation(function (predicate) {
if (typeof predicate === 'function') {
const strategies = [{ id: 'custom', action: mockCustomAction }];
return strategies.find(predicate);
}
return undefined;
});
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'custom:aggressive' }],
targetIds: ['test-provider'],
});
// Should have found the base 'custom' strategy and called its action
expect(mockCustomAction).toHaveBeenCalled();
// Check that the strategy test cases have the correct strategy ID
const strategyTestCases = result.testCases.filter((tc) => tc.metadata?.strategyId);
expect(strategyTestCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
metadata: expect.objectContaining({ strategyId: 'custom:aggressive' }),
}),
]),
);
});
it('should warn when strategy is not found', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
// Mock Strategies.find to return undefined (strategy not found)
vi.spyOn(Strategies, 'find').mockReturnValue(undefined);
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'nonexistent-strategy' }],
targetIds: ['test-provider'],
});
expect(logger.warn).toHaveBeenCalledWith(
'Strategy nonexistent-strategy not registered, skipping',
);
});
it('should prioritize exact strategy match over base strategy for colon-separated IDs', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
const mockJailbreakAction = vi.fn().mockReturnValue([
{
vars: { query: 'basic jailbreak' },
metadata: { strategyId: 'jailbreak' },
},
]);
const mockJailbreakCompositeAction = vi.fn().mockReturnValue([
{
vars: { query: 'composite jailbreak' },
metadata: { strategyId: 'jailbreak:composite' },
},
]);
// Mock the Strategies array to include both strategies
vi.spyOn(Strategies, 'find').mockImplementation(function (predicate) {
if (typeof predicate === 'function') {
const strategies = [
{ id: 'jailbreak', action: mockJailbreakAction },
{ id: 'jailbreak:composite', action: mockJailbreakCompositeAction },
];
return strategies.find(predicate);
}
return undefined;
});
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak:composite' }],
targetIds: ['test-provider'],
});
// Should have called the composite action, not the basic jailbreak action
expect(mockJailbreakCompositeAction).toHaveBeenCalled();
expect(mockJailbreakAction).not.toHaveBeenCalled();
});
it('should skip plugins that fail validation and not throw', async () => {
const failingPlugin = {
id: 'fail-plugin',
numTests: 1,
};
const passingPlugin = {
id: 'pass-plugin',
numTests: 1,
};
vi.spyOn(Plugins, 'find')
.mockReturnValueOnce({
key: 'fail-plugin',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
validate: () => {
throw new Error('Validation failed!');
},
})
.mockReturnValue({
key: 'pass-plugin',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
validate: vi.fn(),
});
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [failingPlugin, passingPlugin],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(result.testCases).toHaveLength(1);
expect(result.testCases[0].metadata.pluginId).toBe('pass-plugin');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Validation failed for plugin fail-plugin: Error: Validation failed!, skipping plugin',
),
);
});
it('should not store full config in metadata for intent plugin to prevent bloating', async () => {
vi.clearAllMocks();
const mockProvider = {
id: () => 'test-provider',
callApi: vi.fn().mockResolvedValue({ output: 'Test response' }),
};
const intentPlugin = {
id: 'intent',
numTests: 2,
config: {
intent: ['intent1', 'intent2', 'intent3', 'intent4', 'intent5'],
},
};
const regularPlugin = {
id: 'contracts',
numTests: 1,
config: {
someConfig: 'value',
},
};
const mockIntentAction = vi.fn().mockResolvedValue([
{
vars: { prompt: 'intent1' },
assert: [{ type: 'promptfoo:redteam:intent', metric: 'Intent' }],
metadata: {
intent: 'intent1',
pluginId: 'intent',
pluginConfig: undefined,
},
},
{
vars: { prompt: 'intent2' },
assert: [{ type: 'promptfoo:redteam:intent', metric: 'Intent' }],
metadata: {
intent: 'intent2',
pluginId: 'intent',
pluginConfig: undefined,
},
},
]);
const mockContractsAction = vi.fn().mockResolvedValue([
{
vars: { prompt: 'contract test' },
assert: [{ type: 'promptfoo:redteam:contracts', metric: 'Contracts' }],
metadata: {
pluginId: 'contracts',
},
},
]);
vi.spyOn(Plugins, 'find').mockImplementation(function (predicate) {
const mockPlugins = [
{ key: 'intent', action: mockIntentAction },
{ key: 'contracts', action: mockContractsAction },
];
if (typeof predicate === 'function') {
return mockPlugins.find(predicate);
}
return undefined;
});
const result = await synthesize({
plugins: [intentPlugin, regularPlugin],
prompts: ['Test prompt'],
provider: mockProvider,
purpose: 'Test purpose',
strategies: [],
injectVar: 'prompt',
language: 'en',
numTests: 5,
targetIds: ['test'],
});
const intentTestCases = result.testCases.filter((tc) => tc.metadata?.pluginId === 'intent');
expect(intentTestCases.length).toBeGreaterThan(0);
intentTestCases.forEach((tc) => {
expect(tc.metadata?.pluginConfig).toBeUndefined();
expect(tc.metadata?.pluginId).toBe('intent');
});
const contractsTestCases = result.testCases.filter(
(tc) => tc.metadata?.pluginId === 'contracts',
);
expect(contractsTestCases.length).toBeGreaterThan(0);
contractsTestCases.forEach((tc) => {
expect(tc.metadata?.pluginConfig).toBeDefined();
expect(tc.metadata?.pluginConfig).toEqual({ someConfig: 'value' });
expect(tc.metadata?.pluginId).toBe('contracts');
});
});
it('should handle strategies that return undefined test cases', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([
{ vars: { query: 'test1' }, metadata: { pluginId: 'test-plugin' } },
{ vars: { query: 'test2' }, metadata: { pluginId: 'test-plugin' } },
]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
// Mock a strategy that returns an array with some undefined values
const mockStrategyAction = vi.fn().mockReturnValue([
{ vars: { query: 'strategy test 1' }, metadata: { strategyId: 'test-strategy' } },
undefined, // This undefined should be filtered out
{ vars: { query: 'strategy test 2' }, metadata: { strategyId: 'test-strategy' } },
undefined, // This undefined should be filtered out
]);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'test-strategy',
action: mockStrategyAction,
});
const result = await synthesize({
language: 'en',
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [{ id: 'test-strategy' }],
targetIds: ['test-provider'],
});
// Should have 2 base tests + 2 strategy tests (undefined values filtered out)
expect(result.testCases).toHaveLength(4);
// Verify that no undefined values made it through
expect(result.testCases.every((tc) => tc !== undefined && tc !== null)).toBe(true);
// Verify strategy tests are present (only the non-undefined ones)
const strategyTests = result.testCases.filter((tc) => tc.metadata?.strategyId);
expect(strategyTests).toHaveLength(2);
});
it('should handle strategies that return all undefined test cases', async () => {
const mockPluginAction = vi
.fn()
.mockResolvedValue([{ vars: { query: 'test1' }, metadata: { pluginId: 'test-plugin' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
// Mock a strategy that returns only undefined values
const mockStrategyAction = vi.fn().mockReturnValue([undefined, undefined, undefined]);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'failing-strategy',
action: mockStrategyAction,
});
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'failing-strategy' }],
targetIds: ['test-provider'],
});
// Should only have the base test (no strategy tests since all were undefined)
expect(result.testCases).toHaveLength(1);
expect(result.testCases[0].metadata?.pluginId).toBe('test-plugin');
expect(result.testCases[0].metadata?.strategyId).toBeUndefined();
});
it('should preserve language metadata when filtering undefined strategy test cases', async () => {
const mockPluginAction = vi
.fn()
.mockResolvedValue([
{ vars: { query: 'test1' }, metadata: { pluginId: 'test-plugin', language: 'fr' } },
]);
vi.spyOn(Plugins, 'find').mockReturnValue({ action: mockPluginAction, key: 'mockPlugin' });
// Mock a strategy that returns mixed undefined and valid test cases with language metadata
const mockStrategyAction = vi.fn().mockReturnValue([
{
vars: { query: 'strategy test 1' },
metadata: { strategyId: 'test-strategy', language: 'fr' },
},
undefined,
{
vars: { query: 'strategy test 2' },
metadata: { strategyId: 'test-strategy', language: 'fr' },
},
]);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'test-strategy',
action: mockStrategyAction,
});
const result = await synthesize({
language: 'fr',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'test-strategy' }],
targetIds: ['test-provider'],
});
// Should have 1 base test + 2 strategy tests
expect(result.testCases).toHaveLength(3);
// All test cases should have language metadata preserved
result.testCases.forEach((tc) => {
expect(tc.metadata?.language).toBe('fr');
});
// Verify strategy tests preserved language
const strategyTests = result.testCases.filter((tc) => tc.metadata?.strategyId);
expect(strategyTests).toHaveLength(2);
strategyTests.forEach((tc) => {
expect(tc.metadata?.language).toBe('fr');
});
});
});
describe('Logger', () => {
it('debug log level hides progress bar', async () => {
const restoreEnv = mockProcessEnv({ LOG_LEVEL: 'debug' });
try {
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(cliProgress.SingleBar).not.toHaveBeenCalled();
} finally {
restoreEnv();
}
});
});
describe('Progress bar', () => {
it('should initialize progress bar with totalTests including strategies', async () => {
const mockStart = vi.fn();
const mockIncrement = vi.fn();
const mockUpdate = vi.fn();
const mockStop = vi.fn();
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
start: mockStart,
increment: mockIncrement,
update: mockUpdate,
stop: mockStop,
} as any;
});
// Mock plugin to return test cases
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
// 2 plugins × 1 test = 2 plugin tests
// 2 strategies × 2 tests = 4 strategy tests (jailbreak strategies add 1:1)
// Total = 6 tests
await synthesize({
numTests: 1,
plugins: [
{ id: 'test-plugin', numTests: 1 },
{ id: 'test-plugin', numTests: 1 },
],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }, { id: 'jailbreak:tree' }],
targetIds: ['test-provider'],
});
// Progress bar should be started with totalTests (not just plugin tests)
expect(mockStart).toHaveBeenCalled();
const startCall = mockStart.mock.calls[0];
// totalTests should be greater than just plugin tests (2)
expect(startCall[0]).toBeGreaterThan(2);
});
it('should increment progress bar for strategy tests', async () => {
const mockStart = vi.fn();
const mockIncrement = vi.fn();
const mockUpdate = vi.fn();
const mockStop = vi.fn();
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
start: mockStart,
increment: mockIncrement,
update: mockUpdate,
stop: mockStop,
} as any;
});
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
await synthesize({
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }],
targetIds: ['test-provider'],
});
// Should have increment calls for both plugin tests and strategy tests
expect(mockIncrement).toHaveBeenCalled();
// At least one increment call should be for strategy tests (with a number argument)
const incrementCalls = mockIncrement.mock.calls;
const hasStrategyIncrement = incrementCalls.some(
(call) => typeof call[0] === 'number' && call[0] > 0,
);
expect(hasStrategyIncrement).toBe(true);
});
it('should include strategy tests in progress bar for multilingual configs', async () => {
const mockStart = vi.fn();
const mockIncrement = vi.fn();
const mockUpdate = vi.fn();
const mockStop = vi.fn();
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
start: mockStart,
increment: mockIncrement,
update: mockUpdate,
stop: mockStop,
} as any;
});
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
// 2 plugins × 1 test × 2 languages = 4 plugin tests
// 1 strategy × 4 plugin tests = 4 strategy tests
// Total = 8 tests
await synthesize({
numTests: 1,
language: ['Hmong', 'Zulu'],
plugins: [
{ id: 'policy', numTests: 1, config: { policy: 'Policy 1' } },
{ id: 'policy', numTests: 1, config: { policy: 'Policy 2' } },
],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }],
targetIds: ['test-provider'],
});
// Progress bar should be started with totalTests including strategies
expect(mockStart).toHaveBeenCalled();
const startCall = mockStart.mock.calls[0];
// Should be 8 (4 plugin + 4 strategy), not 4 (just plugins)
expect(startCall[0]).toBeGreaterThanOrEqual(8);
});
});
describe('Report status for policy plugins', () => {
it('should show Failed status when plugin generates 0 test cases', async () => {
// Mock plugin to return empty array (failed generation)
const mockPluginAction = vi.fn().mockResolvedValue([]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
// Capture the report output
let reportMessage: string | undefined;
vi.mocked(logger.info).mockImplementation(function (msg: any) {
if (typeof msg === 'string' && msg.includes('Test Generation Report')) {
reportMessage = msg;
}
return logger as any;
});
await synthesize({
numTests: 2,
plugins: [{ id: 'policy', numTests: 2, config: { policy: 'Test policy' } }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(reportMessage).toBeDefined();
const cleanReport = stripAnsi(reportMessage || '');
// Should show 2 requested, 0 generated
expect(cleanReport).toContain('2');
expect(cleanReport).toContain('0');
expect(cleanReport).toContain('Failed');
});
it('should show Partial status when plugin generates fewer tests than requested', async () => {
// Mock plugin to return only 1 test case when 2 are requested
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test1' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
// Capture the report output
let reportMessage: string | undefined;
vi.mocked(logger.info).mockImplementation(function (msg: any) {
if (typeof msg === 'string' && msg.includes('Test Generation Report')) {
reportMessage = msg;
}
return logger as any;
});
await synthesize({
numTests: 2,
plugins: [{ id: 'policy', numTests: 2, config: { policy: 'Test policy' } }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(reportMessage).toBeDefined();
const cleanReport = stripAnsi(reportMessage || '');
// Should show 2 requested, 1 generated
expect(cleanReport).toContain('2');
expect(cleanReport).toContain('1');
expect(cleanReport).toContain('Partial');
});
it('should show individual status for each policy when one fails and others succeed', async () => {
// First policy returns 0, second policy returns 2
let callCount = 0;
const mockPluginAction = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve([]); // First policy fails
}
return Promise.resolve([{ vars: { query: 'test1' } }, { vars: { query: 'test2' } }]);
});
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
// Capture the report output
let reportMessage: string | undefined;
vi.mocked(logger.info).mockImplementation(function (msg: any) {
if (typeof msg === 'string' && msg.includes('Test Generation Report')) {
reportMessage = msg;
}
return logger as any;
});
await synthesize({
numTests: 2,
plugins: [
{ id: 'policy', numTests: 2, config: { policy: 'Failing policy' } },
{ id: 'policy', numTests: 2, config: { policy: 'Succeeding policy' } },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(reportMessage).toBeDefined();
const cleanReport = stripAnsi(reportMessage || '');
// Should have both policies with display format "policy [hash]: preview..."
expect(cleanReport).toMatch(/policy \[[a-f0-9]{12}\]:/);
// Inline policies show hash and preview
// Should show both Failed and Success statuses
expect(cleanReport).toContain('Failed');
expect(cleanReport).toContain('Success');
});
});
describe('API Health Check', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
// Reset logger mocks
vi.mocked(logger.info).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.warn).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.error).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.debug).mockImplementation(function () {
return logger as any;
});
// Set up templates mock with consistent default behavior
vi.mocked(extractVariablesFromTemplates).mockImplementation(function () {
return ['query'];
});
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(getRemoteHealthUrl).mockImplementation(function () {
return 'https://api.test/health';
});
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'OK',
message: 'Cloud API is healthy',
});
});
it('should check API health when remote generation is enabled', async () => {
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(shouldGenerateRemote).toHaveBeenCalledWith();
expect(getRemoteHealthUrl).toHaveBeenCalledWith();
expect(checkRemoteHealth).toHaveBeenCalledWith('https://api.test/health');
});
it('should skip health check when remote generation is disabled', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(shouldGenerateRemote).toHaveBeenCalledWith();
expect(getRemoteHealthUrl).not.toHaveBeenCalled();
expect(checkRemoteHealth).not.toHaveBeenCalled();
});
it('should throw error when health check fails', async () => {
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'ERROR',
message: 'API is not accessible',
});
await expect(
synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
}),
).rejects.toThrow('Unable to proceed with test generation: API is not accessible');
});
it('should skip health check when URL is null', async () => {
vi.mocked(getRemoteHealthUrl).mockImplementation(function () {
return null;
});
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(shouldGenerateRemote).toHaveBeenCalledWith();
expect(getRemoteHealthUrl).toHaveBeenCalledWith();
expect(checkRemoteHealth).not.toHaveBeenCalled();
});
});
it('should handle basic strategy configuration', async () => {
vi.mocked(loadApiProvider).mockResolvedValue({
id: () => 'test',
callApi: vi.fn().mockResolvedValue({ output: 'test output' }),
});
const mockPlugin = {
id: 'test-plugin',
numTests: 1,
};
const mockProvider = {
id: () => 'test',
callApi: vi.fn().mockResolvedValue({ output: 'test output' }),
};
const mockTestPluginAction = vi.fn().mockResolvedValue([
{
vars: { input: 'test input' },
assert: [{ type: 'test-assertion', metric: 'Test' }],
metadata: {
pluginId: 'test-plugin',
},
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
key: 'test-plugin',
action: mockTestPluginAction,
});
const resultEnabled = await synthesize({
plugins: [mockPlugin],
strategies: [{ id: 'basic', config: { enabled: true } }],
prompts: ['test prompt'],
injectVar: 'input',
provider: mockProvider,
language: 'en',
numTests: 1,
targetIds: ['test-provider'],
});
expect(resultEnabled.testCases.length).toBeGreaterThan(0);
const resultDisabled = await synthesize({
plugins: [mockPlugin],
strategies: [{ id: 'basic', config: { enabled: false } }],
prompts: ['test prompt'],
injectVar: 'input',
provider: mockProvider,
language: 'en',
numTests: 1,
targetIds: ['test-provider'],
});
expect(resultDisabled.testCases).toHaveLength(0);
});
describe('Direct plugin handling', () => {
it('should recognize and not expand direct plugins like bias:gender', async () => {
const mockPluginAction = vi.fn().mockImplementation(function ({ n }) {
return Array(n).fill({ vars: { query: 'test' } });
});
vi.spyOn(Plugins, 'find').mockReturnValue({ key: 'bias:gender', action: mockPluginAction });
const result = await synthesize({
language: 'en',
numTests: 2,
plugins: [{ id: 'bias:gender', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Check that the plugin wasn't expanded and was used directly
expect(mockPluginAction).toHaveBeenCalledWith(
expect.objectContaining({
provider: expect.anything(),
purpose: expect.any(String),
n: 2,
}),
);
// Check that the test cases have the correct plugin ID
const testCases = result.testCases;
testCases.forEach((tc) => {
expect(tc.metadata.pluginId).toBe('bias:gender');
});
// Should have exactly the number of test cases we requested
expect(testCases).toHaveLength(2);
});
it('should still expand category plugins with new bias category', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({ key: 'mockPlugin', action: mockPluginAction });
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'bias', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Check that we have test cases for each bias plugin
const biasPluginIds = Object.keys(HARM_PLUGINS).filter((p) => p.startsWith('bias:'));
const testCasePluginIds = result.testCases.map((tc) => tc.metadata.pluginId);
// Every bias plugin should have a test case
biasPluginIds.forEach((id) => {
expect(testCasePluginIds).toContain(id);
});
});
});
});
vi.mock('fs');
vi.mock('../../src/util/yamlLoad');
describe('resolvePluginConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
// Set up logger mocks
vi.mocked(logger.info).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.warn).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.error).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.debug).mockImplementation(function () {
return logger as any;
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('should return an empty object if config is undefined', () => {
const result = resolvePluginConfig(undefined);
expect(result).toEqual({});
});
it('should return the original config if no file references are present', () => {
const config = { key: 'value' };
const result = resolvePluginConfig(config);
expect(result).toEqual(config);
});
it('should resolve YAML file references', () => {
const config = { key: 'file://test.yaml' };
const yamlContent = { nested: 'value' };
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('yaml content');
vi.mocked(loadYaml).mockImplementation(function () {
return yamlContent;
});
const result = resolvePluginConfig(config);
expect(result).toEqual({ key: yamlContent });
expect(fs.existsSync).toHaveBeenCalledWith('test.yaml');
expect(fs.readFileSync).toHaveBeenCalledWith('test.yaml', 'utf8');
expect(loadYaml).toHaveBeenCalledWith('yaml content');
});
it('should resolve JSON file references', () => {
const config = { key: 'file://test.json' };
const jsonContent = { nested: 'value' };
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(jsonContent));
const result = resolvePluginConfig(config);
expect(result).toEqual({ key: jsonContent });
expect(fs.existsSync).toHaveBeenCalledWith('test.json');
expect(fs.readFileSync).toHaveBeenCalledWith('test.json', 'utf8');
});
it('should resolve text file references', () => {
const config = { key: 'file://test.txt' };
const fileContent = 'text content';
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(fileContent);
const result = resolvePluginConfig(config);
expect(result).toEqual({ key: fileContent });
expect(fs.existsSync).toHaveBeenCalledWith('test.txt');
expect(fs.readFileSync).toHaveBeenCalledWith('test.txt', 'utf8');
});
it('should throw an error if the file does not exist', () => {
const config = { key: 'file://nonexistent.yaml' };
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
expect(() => resolvePluginConfig(config)).toThrow('File not found: nonexistent.yaml');
});
it('should handle multiple file references', () => {
const config = {
yaml: 'file://test.yaml',
json: 'file://test.json',
txt: 'file://test.txt',
};
const yamlContent = { nested: 'yaml' };
const jsonContent = { nested: 'json' };
const txtContent = 'text content';
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync')
.mockReturnValueOnce('yaml content')
.mockReturnValueOnce(JSON.stringify(jsonContent))
.mockReturnValueOnce(txtContent);
vi.mocked(loadYaml).mockImplementation(function () {
return yamlContent;
});
const result = resolvePluginConfig(config);
expect(result).toEqual({
yaml: yamlContent,
json: jsonContent,
txt: txtContent,
});
});
});
describe('calculateTotalTests', () => {
const mockPlugins = [
{ id: 'plugin1', numTests: 2 },
{ id: 'plugin2', numTests: 3 },
];
it('should calculate basic test counts with no strategies', () => {
const result = calculateTotalTests(mockPlugins, []);
expect(result).toEqual({
totalTests: 5,
totalPluginTests: 5,
effectiveStrategyCount: 0,
includeBasicTests: true,
});
});
it('should handle basic strategy when enabled', () => {
const strategies = [{ id: 'basic', config: { enabled: true } }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 5,
totalPluginTests: 5,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
it('should handle basic strategy when disabled', () => {
const strategies = [{ id: 'basic', config: { enabled: false } }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 0,
totalPluginTests: 5,
effectiveStrategyCount: 0,
includeBasicTests: false,
});
});
it('should count custom intents instead of numTests for intent plugins', () => {
const plugins = [
{
id: 'intent',
numTests: 1,
config: { intent: ['intent1', 'intent2', 'intent3'] },
},
];
const result = calculateTotalTests(plugins, []);
expect(result).toEqual({
totalTests: 3,
totalPluginTests: 3,
effectiveStrategyCount: 0,
includeBasicTests: true,
});
});
it('should multiply intent count by language count for multilingual intent plugins', () => {
const plugins = [
{
id: 'intent',
numTests: 1,
config: {
intent: ['intent1', 'intent2'],
language: ['en', 'es', 'fr'],
},
},
];
const result = calculateTotalTests(plugins, []);
expect(result).toEqual({
totalTests: 6,
totalPluginTests: 6,
effectiveStrategyCount: 0,
includeBasicTests: true,
});
});
it('should resolve file:// intents from a JSON array for pre-generation totals', () => {
// fs is mocked at the module level (see vi.mock('fs') below). Stub existsSync/readFileSync
// so maybeLoadFromExternalFile sees an existing file with a JSON array of intents.
const existsSyncSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const readFileSyncSpy = vi
.spyOn(fs, 'readFileSync')
.mockReturnValue(JSON.stringify(['do thing 1', 'do thing 2', 'do thing 3', 'do thing 4']));
try {
const plugins = [
{
id: 'intent',
numTests: 1,
config: { intent: 'file://intents.json' },
},
];
const result = calculateTotalTests(plugins, []);
expect(result).toEqual({
totalTests: 4,
totalPluginTests: 4,
effectiveStrategyCount: 0,
includeBasicTests: true,
});
} finally {
readFileSyncSpy.mockRestore();
existsSyncSpy.mockRestore();
}
});
it('should fall back to count of 1 when file:// intent path is unreadable', () => {
const plugins = [
{
id: 'intent',
numTests: 1,
config: { intent: 'file:///definitely/does/not/exist/intents.txt' },
},
];
const result = calculateTotalTests(plugins, []);
// The file can't be read, so the helper falls back to treating the literal
// string as a single intent. This matches the pre-fix behavior and keeps
// the helper from throwing during early UI calculations.
expect(result.totalPluginTests).toBe(1);
});
it('should report 0 plugin tests when an intent plugin is missing config.intent', () => {
const plugins = [
{
id: 'intent',
numTests: 5,
config: {},
},
];
const result = calculateTotalTests(plugins, []);
expect(result.totalPluginTests).toBe(0);
});
it('should handle retry strategy with default numTests', () => {
const strategies = [{ id: 'retry' }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 10,
totalPluginTests: 5,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
it('should handle retry strategy with custom numTests', () => {
const strategies = [{ id: 'retry', config: { numTests: 3 } }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 8,
totalPluginTests: 5,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
it('should handle retry strategy combined with other strategies', () => {
const strategies = [{ id: 'retry' }, { id: 'rot13' }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 15,
totalPluginTests: 5,
effectiveStrategyCount: 2,
includeBasicTests: true,
});
});
it('should correctly calculate total tests for multiple plugins with jailbreak strategy', () => {
const plugins = Array(10).fill({ numTests: 5 });
const strategies = [{ id: 'jailbreak' }];
const result = calculateTotalTests(plugins, strategies);
expect(result).toEqual({
totalTests: 100,
totalPluginTests: 50,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
it('should add tests for each strategy instead of replacing the total', () => {
const strategies = [{ id: 'morse' }, { id: 'piglatin' }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 15,
totalPluginTests: 5,
effectiveStrategyCount: 2,
includeBasicTests: true,
});
});
it('should handle multiple strategies', () => {
const strategies = [{ id: 'morse' }, { id: 'piglatin' }, { id: 'rot13' }];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 20,
totalPluginTests: 5,
effectiveStrategyCount: 3,
includeBasicTests: true,
});
});
it('should handle multiple strategies with basic strategy disabled', () => {
const strategies = [
{ id: 'basic', config: { enabled: false } },
{ id: 'morse' },
{ id: 'piglatin' },
];
const result = calculateTotalTests(mockPlugins, strategies);
expect(result).toEqual({
totalTests: 10,
totalPluginTests: 5,
effectiveStrategyCount: 2,
includeBasicTests: false,
});
});
});
describe('getTestCount', () => {
it('should return totalPluginTests when basic strategy is enabled', () => {
const strategy = { id: 'basic', config: { enabled: true } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(10);
});
it('should return 0 when basic strategy is disabled', () => {
const strategy = { id: 'basic', config: { enabled: false } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(0);
});
it('should add configured number of tests for retry strategy', () => {
const strategy = { id: 'retry', config: { numTests: 5 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(15);
});
it('should add totalPluginTests for retry strategy when numTests not specified', () => {
const strategy = { id: 'retry' };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(20);
});
it('should return totalPluginTests for other strategies', () => {
const strategy = { id: 'morse' };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(10);
});
it('should return totalPluginTests for layer strategy', () => {
const strategy = {
id: 'layer',
config: {
steps: ['base64', 'rot13'],
},
};
const result = getTestCount(strategy, 10, []);
expect(result).toBe(10);
});
describe('numTests cap', () => {
it('should cap test count when numTests is less than calculated count', () => {
// Fan-out strategy with default n=5 would produce 50 tests
const strategy = { id: 'jailbreak', config: { numTests: 10 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(10);
});
it('should return 0 when numTests is 0', () => {
const strategy = { id: 'base64', config: { numTests: 0 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(0);
});
it('should not affect count when numTests is larger than calculated', () => {
// Non-fanout strategy produces 10 tests (1:1)
const strategy = { id: 'morse', config: { numTests: 100 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(10);
});
it('should cap basic strategy tests', () => {
const strategy = { id: 'basic', config: { numTests: 5 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(5);
});
it('should cap layer strategy tests', () => {
const strategy = {
id: 'layer',
config: {
numTests: 3,
steps: ['base64', 'rot13'],
},
};
const result = getTestCount(strategy, 10, []);
expect(result).toBe(3);
});
it('should not apply numTests cap to retry strategy (different semantics)', () => {
// Retry has additive semantics: totalPluginTests + numTests
const strategy = { id: 'retry', config: { numTests: 5 } };
const result = getTestCount(strategy, 10, []);
// Should be 10 + 5 = 15, not capped
expect(result).toBe(15);
});
it('should cap fan-out strategy with custom n and numTests', () => {
// n=3 would produce 30 tests, but numTests caps at 15
const strategy = { id: 'jailbreak', config: { numTests: 15, n: 3 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(15);
});
it('should handle numTests equal to calculated count (no-op)', () => {
// Non-fanout strategy produces 10 tests (1:1), numTests also set to 10
const strategy = { id: 'morse', config: { numTests: 10 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(10);
});
it('should handle numTests equal to fan-out calculated count (no-op)', () => {
// Fan-out with explicit n=5 produces 50 tests, numTests also set to 50
const strategy = { id: 'jailbreak', config: { numTests: 50, n: 5 } };
const result = getTestCount(strategy, 10, []);
expect(result).toBe(50);
});
it('should ignore invalid numTests values at runtime (NaN)', () => {
// Defensive check should skip NaN
const strategy = { id: 'base64', config: { numTests: NaN } };
const result = getTestCount(strategy, 10, []);
// Should return uncapped count (10 for non-fanout)
expect(result).toBe(10);
});
it('should ignore invalid numTests values at runtime (Infinity)', () => {
// Defensive check should skip Infinity
const strategy = { id: 'base64', config: { numTests: Infinity } };
const result = getTestCount(strategy, 10, []);
// Should return uncapped count (10 for non-fanout)
expect(result).toBe(10);
});
it('should ignore invalid numTests values at runtime (negative)', () => {
// Defensive check should skip negative values
const strategy = { id: 'base64', config: { numTests: -5 } };
const result = getTestCount(strategy, 10, []);
// Should return uncapped count (10 for non-fanout)
expect(result).toBe(10);
});
});
});
describe('Language configuration', () => {
const mockProvider = {
callApi: vi.fn(),
generate: vi.fn(),
id: () => 'test-provider',
};
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
// Set up logger mocks
vi.mocked(logger.info).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.warn).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.error).mockImplementation(function () {
return logger as any;
});
vi.mocked(logger.debug).mockImplementation(function () {
return logger as any;
});
// Set up templates mock
vi.mocked(extractVariablesFromTemplates).mockImplementation(function () {
return ['query'];
});
vi.mocked(extractEntities).mockResolvedValue(['entity1', 'entity2']);
vi.mocked(extractSystemPurpose).mockResolvedValue('Test purpose');
vi.mocked(loadApiProvider).mockResolvedValue(mockProvider);
vi.mocked(validateStrategies).mockImplementation(async function () {});
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
increment: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
update: vi.fn(),
} as any;
});
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(getRemoteHealthUrl).mockImplementation(function () {
return 'https://api.test/health';
});
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'OK',
message: 'OK',
});
// Mock plugin action to return test cases
const mockPluginAction = vi
.fn()
.mockResolvedValue([{ vars: { query: 'test1' } }, { vars: { query: 'test2' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'mockPlugin',
});
});
describe('calculateTotalTests with language', () => {
it('should calculate correct test count for single language', () => {
const plugins = [
{ id: 'plugin1', numTests: 2 },
{ id: 'plugin2', numTests: 3 },
];
const result = calculateTotalTests(plugins, [], 'en');
expect(result).toEqual({
totalTests: 5,
totalPluginTests: 5,
effectiveStrategyCount: 0,
includeBasicTests: true,
});
});
it('should calculate correct test count for multiple languages', () => {
const plugins = [
{ id: 'plugin1', numTests: 2 },
{ id: 'plugin2', numTests: 3 },
];
// With 3 languages, we expect 5 tests * 3 = 15 tests
const result = calculateTotalTests(plugins, [], ['en', 'fr', 'de']);
expect(result).toEqual({
totalTests: 15,
totalPluginTests: 15,
effectiveStrategyCount: 0,
includeBasicTests: true,
});
});
it('should calculate correct test count with strategies and multiple languages', () => {
const plugins = [
{ id: 'plugin1', numTests: 2 },
{ id: 'plugin2', numTests: 3 },
];
const strategies = [{ id: 'rot13' }];
// Base tests: 5 * 3 languages = 15
// Strategy tests: 5 * 3 languages = 15
// Total: 30
const result = calculateTotalTests(plugins, strategies, ['en', 'fr', 'de']);
expect(result).toEqual({
totalTests: 30,
totalPluginTests: 15,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
it('should handle layer strategy with multiple languages', () => {
const plugins = [{ id: 'plugin1', numTests: 5 }];
const strategies = [
{
id: 'layer',
config: {
steps: ['base64', 'rot13'],
},
},
];
// With 2 languages, plugin tests: 5 * 2 = 10
// Layer strategy adds 10 more tests
const result = calculateTotalTests(plugins, strategies, ['en', 'fr']);
expect(result).toEqual({
totalTests: 20,
totalPluginTests: 10,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
it('should handle retry strategy with multiple languages', () => {
const plugins = [{ id: 'plugin1', numTests: 2 }];
const strategies = [{ id: 'retry', config: { numTests: 2 } }];
// With 3 languages: 2 * 3 = 6 base tests
// Retry adds 2 tests per base test: 6 + 2 = 8
const result = calculateTotalTests(plugins, strategies, ['en', 'fr', 'de']);
expect(result).toEqual({
totalTests: 8,
totalPluginTests: 6,
effectiveStrategyCount: 1,
includeBasicTests: true,
});
});
});
describe('synthesize with language configuration', () => {
it('should generate test cases with single language', async () => {
const result = await synthesize({
language: 'en',
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(result.testCases).toHaveLength(2);
expect(result.testCases.every((tc) => tc.metadata?.language === 'en')).toBe(true);
});
it('should generate test cases for each language when multiple languages specified', async () => {
const result = await synthesize({
language: ['en', 'fr'],
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Should generate 2 tests * 2 languages = 4 test cases
expect(result.testCases).toHaveLength(4);
// Check that we have tests for both languages
const languageCounts = result.testCases.reduce<Record<string, number>>((acc, tc) => {
const lang = tc.metadata?.language || 'en';
acc[lang] = (acc[lang] || 0) + 1;
return acc;
}, {});
expect(languageCounts).toEqual({
en: 2,
fr: 2,
});
});
it('should apply strategies to multilingual test cases', async () => {
// Mock strategy action
const mockStrategyAction = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `transformed: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'rot13' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'rot13',
action: mockStrategyAction,
});
const result = await synthesize({
language: ['en', 'fr'],
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [{ id: 'rot13' }],
targetIds: ['test-provider'],
});
// Base tests: 2 * 2 languages = 4
// Strategy tests: 2 * 2 languages = 4
// Total: 8
expect(result.testCases.length).toBeGreaterThanOrEqual(4);
// Verify strategy was applied to tests in both languages
const strategyTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'rot13');
expect(strategyTests.length).toBeGreaterThan(0);
});
it('should include language in test metadata', async () => {
// Mock plugin to return only 1 test case instead of 2
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test1' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'mockPlugin',
});
const result = await synthesize({
language: ['en', 'es', 'zh'],
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(result.testCases).toHaveLength(3);
// Each test case should have language metadata
for (const testCase of result.testCases) {
expect(testCase.metadata?.language).toBeDefined();
expect(['en', 'es', 'zh']).toContain(testCase.metadata?.language);
}
});
it('should handle plugin-level language configuration', async () => {
const result = await synthesize({
language: 'en', // Global default
numTests: 2,
plugins: [
{
id: 'test-plugin',
numTests: 2,
config: {
language: ['fr', 'de'], // Plugin-specific override
},
},
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Should generate 2 tests * 2 plugin languages = 4 test cases
expect(result.testCases).toHaveLength(4);
// All tests should be in plugin-specified languages
const languages = result.testCases.map((tc) => tc.metadata?.language);
expect(languages.every((lang) => ['fr', 'de'].includes(lang || ''))).toBe(true);
});
it('should handle when no language is specified', async () => {
const result = await synthesize({
// No language specified - will use undefined which plugins interpret as 'en'
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(result.testCases).toHaveLength(2);
// Check that all tests have the same language
const languages = result.testCases.map((tc) => tc.metadata?.language);
const uniqueLanguages = [...new Set(languages)];
expect(uniqueLanguages.length).toBe(1); // Only one language used
});
it('should not include language in config when no language is specified', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
await synthesize({
// No language specified
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Verify action was called with config that does NOT contain language property
expect(mockPluginAction).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.not.objectContaining({
language: expect.anything(),
}),
}),
);
});
it('should not include language in modifiers when no language is specified', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
await synthesize({
// No language specified
numTests: 1,
plugins: [
{
id: 'test-plugin',
numTests: 1,
config: {
modifiers: {
tone: 'aggressive',
},
},
},
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Verify modifiers don't contain language
expect(mockPluginAction).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
modifiers: expect.objectContaining({
tone: 'aggressive',
}),
}),
}),
);
// Get the actual config that was passed
const actualConfig = mockPluginAction.mock.calls[0][0].config;
// Verify language is not in the config at all
expect(actualConfig).not.toHaveProperty('language');
// Verify modifiers don't have language key
expect(actualConfig.modifiers).not.toHaveProperty('language');
});
it('should pass testGenerationInstructions through modifiers to plugin action', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
await synthesize({
language: 'en',
numTests: 1,
plugins: [
{
id: 'test-plugin',
numTests: 1,
config: {
modifiers: {
tone: 'aggressive',
},
},
},
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
testGenerationInstructions: 'Focus on edge cases',
});
// Verify action was called with correct config containing merged modifiers
expect(mockPluginAction).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
modifiers: expect.objectContaining({
testGenerationInstructions: 'Focus on edge cases',
tone: 'aggressive',
}),
}),
}),
);
});
});
describe('Multilingual support for media strategies', () => {
it('should support multilingual test cases for audio strategy', async () => {
// Mock strategy action for audio
const mockAudioAction = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `audio: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'audio' },
}));
});
vi.spyOn(Strategies, 'find').mockImplementation(function (predicate) {
if (typeof predicate === 'function') {
const strategies = [{ id: 'audio', action: mockAudioAction }];
return strategies.find(predicate);
}
return undefined;
});
const result = await synthesize({
language: ['en', 'fr', 'de'], // 3 languages
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [{ id: 'audio' }],
targetIds: ['test-provider'],
});
// Audio strategy now supports multiple languages
// Mock plugin always returns 2 tests (ignores numTests)
// Base tests: 2 tests * 3 languages = 6
// Audio strategy tests: 6 tests (applies to all base tests)
// Total: 12 tests
expect(result.testCases.length).toBe(12);
// Check that audio strategy was applied to all language variants
const audioTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'audio');
expect(audioTests.length).toBe(6);
});
it('should support multilingual test cases for video strategy', async () => {
const mockVideoAction = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `video: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'video' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'video',
action: mockVideoAction,
});
const result = await synthesize({
language: ['en', 'es'], // 2 languages
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'video' }],
targetIds: ['test-provider'],
});
// Video strategy now supports multiple languages
// Mock plugin always returns 2 tests (ignores numTests: 1)
// Base tests: 2 tests * 2 languages = 4
// Strategy transforms: 4 tests → 4 video tests
// Total: 4 base + 4 video = 8 tests
const videoTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'video');
expect(videoTests.length).toBe(4);
expect(result.testCases.length).toBe(8);
});
it('should support multilingual test cases for image strategy', async () => {
const mockImageAction = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `image: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'image' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'image',
action: mockImageAction,
});
const result = await synthesize({
language: ['en', 'fr', 'de', 'es'], // 4 languages
numTests: 3,
plugins: [{ id: 'test-plugin', numTests: 3 }],
prompts: ['Test prompt'],
strategies: [{ id: 'image' }],
targetIds: ['test-provider'],
});
// Image strategy now supports multiple languages
// Mock plugin always returns 2 tests (ignores numTests: 3)
// Base tests: 2 tests * 4 languages = 8
// Strategy transforms: 8 tests → 8 image tests
// Total: 8 base + 8 image = 16 tests
const imageTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'image');
expect(imageTests.length).toBe(8);
expect(result.testCases.length).toBe(16);
});
it('should support multilingual test cases for jailbreak strategy', async () => {
const mockJailbreakAction = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `jailbreak: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'jailbreak' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'jailbreak',
action: mockJailbreakAction,
});
const result = await synthesize({
language: ['en', 'fr'], // 2 languages
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }],
targetIds: ['test-provider'],
});
// Jailbreak strategy supports multiple languages
// Base tests: 2 tests * 2 languages = 4
// Jailbreak strategy tests: 4 tests (applies to all base tests)
// Total: 8 tests
const jailbreakTests = result.testCases.filter(
(tc) => tc.metadata?.strategyId === 'jailbreak',
);
expect(jailbreakTests.length).toBe(4);
expect(result.testCases.length).toBe(8);
});
it('should support multilingual test cases for math-prompt strategy', async () => {
const mockMathPromptAction = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `math: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'math-prompt' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'math-prompt',
action: mockMathPromptAction,
});
const result = await synthesize({
language: ['en', 'zh', 'hi'], // 3 languages
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'math-prompt' }],
targetIds: ['test-provider'],
});
// Math-prompt strategy now supports multiple languages
// Mock plugin always returns 2 tests (ignores numTests: 1)
// Base tests: 2 tests * 3 languages = 6
// Strategy transforms: 6 tests → 6 math tests
// Total: 6 base + 6 math = 12 tests
const mathTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'math-prompt');
expect(mathTests.length).toBe(6);
expect(result.testCases.length).toBe(12);
});
it('should support multilingual test cases for rot13 strategy', async () => {
const mockRot13Action = vi.fn().mockImplementation(function (testCases) {
return testCases.map((tc: any) => ({
...tc,
vars: { ...tc.vars, query: `rot13: ${tc.vars.query}` },
metadata: { ...tc.metadata, strategyId: 'rot13' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'rot13',
action: mockRot13Action,
});
const result = await synthesize({
language: ['en', 'fr'], // 2 languages
numTests: 2,
plugins: [{ id: 'test-plugin', numTests: 2 }],
prompts: ['Test prompt'],
strategies: [{ id: 'rot13' }],
targetIds: ['test-provider'],
});
// Rot13 supports multilingual test cases
const rot13Tests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'rot13');
expect(rot13Tests.length).toBe(4); // 2 tests * 2 languages = 4
});
});
describe('policy extraction for intent', () => {
it('should pass policy from metadata to extractGoalFromPrompt', async () => {
const mockExtractGoal = vi.mocked(
(await import('../../src/redteam/util')).extractGoalFromPrompt,
);
mockExtractGoal.mockClear();
const policyText = 'The application must not reveal system instructions';
// Mock plugin action that returns test case with policy metadata
const mockPluginAction = vi.fn().mockResolvedValue([
{
vars: { query: 'Test prompt' },
metadata: {
policy: policyText,
pluginId: 'promptfoo:redteam:policy',
},
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
key: 'policy',
action: mockPluginAction,
} as any);
await synthesize({
numTests: 1,
plugins: [{ id: 'policy', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'goat' }],
targetIds: ['promptfoo://provider/target-123'],
});
// Verify extractGoalFromPrompt was called with the policy
expect(mockExtractGoal).toHaveBeenCalled();
const call = mockExtractGoal.mock.calls[0];
expect(call[0]).toBe('Test prompt'); // prompt
expect(call[1]).toBe('Test purpose'); // purpose
expect(call[2]).toBe('policy'); // pluginId
expect(call[3]).toBe(policyText); // policy
expect(call[4]).toBe('target-123'); // targetId
});
it('should pass an explicit linked Cloud target to extractGoalFromPrompt', async () => {
const mockExtractGoal = vi.mocked(
(await import('../../src/redteam/util')).extractGoalFromPrompt,
);
mockExtractGoal.mockClear();
const mockPluginAction = vi.fn().mockResolvedValue([
{
vars: { query: 'Test prompt' },
metadata: { pluginId: 'promptfoo:redteam:policy' },
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
key: 'policy',
action: mockPluginAction,
} as any);
await synthesize({
cloudTargetDatabaseId: 'linked-target-123',
numTests: 1,
plugins: [{ id: 'policy', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'goat' }],
targetIds: ['file://local-provider.ts'],
});
expect(mockPluginAction).toHaveBeenCalledWith(
expect.objectContaining({ targetId: 'linked-target-123' }),
);
expect(mockExtractGoal.mock.calls[0][4]).toBe('linked-target-123');
});
it('should not pass policy when metadata does not contain policy', async () => {
const mockExtractGoal = vi.mocked(
(await import('../../src/redteam/util')).extractGoalFromPrompt,
);
mockExtractGoal.mockClear();
// Mock plugin action that returns test case WITHOUT policy metadata
const mockPluginAction = vi.fn().mockResolvedValue([
{
vars: { query: 'Test prompt' },
metadata: {
pluginId: 'promptfoo:redteam:other',
},
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
key: 'other-plugin',
action: mockPluginAction,
} as any);
await synthesize({
numTests: 1,
plugins: [{ id: 'other-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak:hydra' }],
targetIds: ['test-provider'],
});
// Verify extractGoalFromPrompt was called WITHOUT policy (undefined)
expect(mockExtractGoal).toHaveBeenCalled();
const call = mockExtractGoal.mock.calls[0];
expect(call[0]).toBe('Test prompt'); // prompt
expect(call[1]).toBe('Test purpose'); // purpose
expect(call[2]).toBe('other-plugin'); // pluginId
expect(call[3]).toBeUndefined(); // policy should be undefined
});
it('should handle policy in metadata with safe type checking', async () => {
const mockExtractGoal = vi.mocked(
(await import('../../src/redteam/util')).extractGoalFromPrompt,
);
mockExtractGoal.mockClear();
const testCases = [
{
metadata: { policy: 'Valid policy' },
expectedPolicy: 'Valid policy',
},
{
metadata: { policy: { id: 'abc123', text: 'Policy from object' } },
expectedPolicy: 'Policy from object',
},
{
metadata: { policy: '' },
expectedPolicy: '', // Empty string is extracted but filtered later by util.ts
},
{
metadata: { someOtherField: 'value' },
expectedPolicy: undefined, // No policy field
},
{
metadata: null,
expectedPolicy: undefined, // Null metadata
},
];
for (const testCase of testCases) {
mockExtractGoal.mockClear();
const mockPluginAction = vi.fn().mockResolvedValue([
{
vars: { query: 'Test prompt' },
metadata: testCase.metadata,
},
]);
vi.spyOn(Plugins, 'find').mockReturnValue({
key: 'test-plugin',
action: mockPluginAction,
} as any);
await synthesize({
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak:meta' }],
targetIds: ['test-provider'],
});
// Verify the policy parameter matches expected
expect(mockExtractGoal).toHaveBeenCalled();
const call = mockExtractGoal.mock.calls[0];
expect(call[3]).toBe(testCase.expectedPolicy);
}
});
});
describe('strategy numTests capping integration', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
vi.mocked(logger.info).mockImplementation(() => logger as any);
vi.mocked(logger.warn).mockImplementation(() => logger as any);
vi.mocked(logger.debug).mockImplementation(() => logger as any);
vi.mocked(logger.error).mockImplementation(() => logger as any);
vi.mocked(extractSystemPurpose).mockResolvedValue('Test purpose');
vi.mocked(extractEntities).mockResolvedValue([]);
vi.mocked(shouldGenerateRemote).mockReturnValue(false);
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'OK',
message: 'Cloud API is healthy',
});
vi.mocked(getRemoteHealthUrl).mockReturnValue('http://test.com/health');
vi.mocked(extractVariablesFromTemplates).mockReturnValue(['query']);
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
start: vi.fn(),
update: vi.fn(),
stop: vi.fn(),
increment: vi.fn(),
} as any;
});
});
it('should cap strategy output when numTests is configured', async () => {
// Mock plugin to return 10 test cases
const mockPluginAction = vi.fn().mockResolvedValue(
Array(10)
.fill(null)
.map((_, i) => ({ vars: { query: `test${i}` } })),
);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
// Mock strategy that returns all input tests (1:1)
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'base64' },
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'base64',
action: mockStrategyAction,
});
const result = await synthesize({
numTests: 10,
plugins: [{ id: 'test-plugin', numTests: 10 }],
prompts: ['Test prompt'],
strategies: [{ id: 'base64', config: { numTests: 3 } }],
targetIds: ['test-provider'],
});
// Basic tests: 10, Strategy tests: 3 (capped from 10)
const strategyTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'base64');
expect(strategyTests.length).toBe(3);
});
it('should log warning when numTests is 0', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'base64' },
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'base64',
action: mockStrategyAction,
});
const result = await synthesize({
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'base64', config: { numTests: 0 } }],
targetIds: ['test-provider'],
});
// Should have 0 strategy tests
const strategyTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'base64');
expect(strategyTests.length).toBe(0);
// Should have logged warning
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('numTests=0 configured, skipping strategy'),
);
});
it('should log debug when capping occurs', async () => {
const mockPluginAction = vi.fn().mockResolvedValue(
Array(5)
.fill(null)
.map((_, i) => ({ vars: { query: `test${i}` } })),
);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'base64' },
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'base64',
action: mockStrategyAction,
});
await synthesize({
numTests: 5,
plugins: [{ id: 'test-plugin', numTests: 5 }],
prompts: ['Test prompt'],
strategies: [{ id: 'base64', config: { numTests: 2 } }],
targetIds: ['test-provider'],
});
// Should have logged debug about pre-limiting
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Pre-limiting 5 tests to numTests=2'),
);
});
it('should not cap when numTests is undefined', async () => {
const mockPluginAction = vi.fn().mockResolvedValue(
Array(5)
.fill(null)
.map((_, i) => ({ vars: { query: `test${i}` } })),
);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'base64' },
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'base64',
action: mockStrategyAction,
});
const result = await synthesize({
numTests: 5,
plugins: [{ id: 'test-plugin', numTests: 5 }],
prompts: ['Test prompt'],
strategies: [{ id: 'base64' }], // No numTests config
targetIds: ['test-provider'],
});
// All 5 strategy tests should be present (no capping)
const strategyTests = result.testCases.filter((tc) => tc.metadata?.strategyId === 'base64');
expect(strategyTests.length).toBe(5);
});
it('should pass pre-limited tests to strategy (not all tests)', async () => {
// This is the key test: verify strategy receives limited input, not all tests
const mockPluginAction = vi.fn().mockResolvedValue(
Array(10)
.fill(null)
.map((_, i) => ({ vars: { query: `test${i}` } })),
);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'base64' },
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'base64',
action: mockStrategyAction,
});
await synthesize({
numTests: 10,
plugins: [{ id: 'test-plugin', numTests: 10 }],
prompts: ['Test prompt'],
strategies: [{ id: 'base64', config: { numTests: 3 } }],
targetIds: ['test-provider'],
});
// KEY ASSERTION: Strategy should receive only 3 tests, not all 10
// This verifies pre-limiting works (avoids wasted computation)
expect(mockStrategyAction).toHaveBeenCalled();
const receivedTests = mockStrategyAction.mock.calls[0][0];
expect(receivedTests.length).toBe(3);
});
it('should not call strategy when numTests is 0', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'base64' },
})),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'base64',
action: mockStrategyAction,
});
await synthesize({
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'base64', config: { numTests: 0 } }],
targetIds: ['test-provider'],
});
// Strategy should NOT be called when numTests=0 (early exit)
expect(mockStrategyAction).not.toHaveBeenCalled();
});
it('should apply post-cap safety net for 1:N fan-out strategies', async () => {
const mockPluginAction = vi.fn().mockResolvedValue(
Array(5)
.fill(null)
.map((_, i) => ({ vars: { query: `test${i}` } })),
);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
// Mock strategy that generates 2x the input (1:2 fan-out)
const mockStrategyAction = vi.fn().mockImplementation((testCases) =>
testCases.flatMap((tc: any) => [
{ ...tc, metadata: { ...tc.metadata, strategyId: 'multilingual', variant: 'a' } },
{ ...tc, metadata: { ...tc.metadata, strategyId: 'multilingual', variant: 'b' } },
]),
);
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'multilingual',
action: mockStrategyAction,
});
const result = await synthesize({
numTests: 5,
plugins: [{ id: 'test-plugin', numTests: 5 }],
prompts: ['Test prompt'],
strategies: [{ id: 'multilingual', config: { numTests: 3 } }],
targetIds: ['test-provider'],
});
// Strategy receives 3 tests (pre-limit), generates 6 (2x fan-out), capped to 3 (post-cap)
const strategyTests = result.testCases.filter(
(tc) => tc.metadata?.strategyId === 'multilingual',
);
expect(strategyTests.length).toBe(3);
// Should have logged warning about post-cap safety net
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Post-cap safety net applied'),
);
});
});
describe('plugin logging and report aggregation', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
vi.mocked(logger.info).mockImplementation(() => logger as any);
vi.mocked(logger.warn).mockImplementation(() => logger as any);
vi.mocked(logger.debug).mockImplementation(() => logger as any);
vi.mocked(logger.error).mockImplementation(() => logger as any);
vi.mocked(extractSystemPurpose).mockResolvedValue('Test purpose');
vi.mocked(extractEntities).mockResolvedValue([]);
vi.mocked(extractVariablesFromTemplates).mockReturnValue(['query']);
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'OK',
message: 'Cloud API is healthy',
});
vi.mocked(getRemoteHealthUrl).mockReturnValue('http://health.test');
vi.mocked(shouldGenerateRemote).mockReturnValue(true);
});
it('should show truncated policy text in plugin list for policy plugins', async () => {
const longPolicyText =
'The assistant must not reveal any internal implementation details such as schema definitions, parameter lists, tool routing logic.';
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
await synthesize({
numTests: 1,
plugins: [{ id: 'policy', numTests: 1, config: { policy: longPolicyText } }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Check that the "Using plugins:" log contains truncated policy text
const pluginListMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find((arg): arg is string => typeof arg === 'string' && arg.includes('Using plugins:'));
expect(pluginListMessage).toBeDefined();
// Should contain truncated text (70 chars + ...)
expect(pluginListMessage).toContain(
'The assistant must not reveal any internal implementation details such',
);
expect(pluginListMessage).toContain('...');
// Should NOT contain full JSON config
expect(pluginListMessage).not.toContain('"policy":');
});
it('should log config shape at debug level for policy plugins', async () => {
const policyText = 'SECRET_POLICY_DEBUG_TEXT';
const secretConfigKey = 'SECRET_CONFIG_KEY_NAME';
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
await synthesize({
numTests: 1,
plugins: [
{ id: 'policy', numTests: 1, config: { policy: policyText, [secretConfigKey]: true } },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(logger.debug).toHaveBeenCalledWith('Plugin config', {
pluginId: 'policy',
configKeyCount: 2,
});
const debugLogs = JSON.stringify(vi.mocked(logger.debug).mock.calls);
expect(debugLogs).not.toContain(policyText);
expect(debugLogs).not.toContain(secretConfigKey);
});
it('should show "(custom config)" for non-policy plugins with config', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'harmful:hate',
});
await synthesize({
numTests: 1,
plugins: [{ id: 'harmful:hate', numTests: 1, config: { someOption: 'value' } }],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
const pluginListMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find((arg): arg is string => typeof arg === 'string' && arg.includes('Using plugins:'));
expect(pluginListMessage).toBeDefined();
expect(pluginListMessage).toContain('(custom config)');
// Should NOT contain full JSON
expect(pluginListMessage).not.toContain('someOption');
});
it('should not log non-policy plugin config keys or values at debug level', async () => {
const secretConfigKey = 'SECRET_PLUGIN_CONFIG_KEY';
const secretConfigValue = 'SECRET_PLUGIN_CONFIG_VALUE';
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'harmful:hate',
});
await synthesize({
numTests: 1,
plugins: [
{
id: 'harmful:hate',
numTests: 1,
config: { [secretConfigKey]: secretConfigValue, severity: 'high' },
},
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
expect(logger.debug).toHaveBeenCalledWith('Plugin config', {
pluginId: 'harmful:hate',
configKeyCount: 2,
});
const debugLogs = JSON.stringify(vi.mocked(logger.debug).mock.calls);
expect(debugLogs).not.toContain(secretConfigKey);
expect(debugLogs).not.toContain(secretConfigValue);
});
it('should show separate rows for multiple policy plugins with unique IDs', async () => {
const mockPluginAction = vi
.fn()
.mockResolvedValue([{ vars: { query: 'test1' } }, { vars: { query: 'test2' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
await synthesize({
numTests: 2,
plugins: [
{ id: 'policy', numTests: 2, config: { policy: 'Policy 1' } },
{ id: 'policy', numTests: 2, config: { policy: 'Policy 2' } },
{ id: 'policy', numTests: 2, config: { policy: 'Policy 3' } },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
// Find the report in logger.info calls
const reportMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string => typeof arg === 'string' && arg.includes('Test Generation Report'),
);
expect(reportMessage).toBeDefined();
// Strip ANSI codes for easier assertion
const cleanReport = stripAnsi(reportMessage || '');
// Each policy plugin should have its own row with display format "policy [hash]: preview..."
// Inline policies show hash and preview
expect(cleanReport).toMatch(/policy \[[a-f0-9]{12}\]:/);
// Count unique policy rows (should be 3)
const policyMatches = cleanReport.match(/policy \[[a-f0-9]{12}\]:/g);
expect(policyMatches?.length).toBe(3);
// Each should show 2 requested, 2 generated
const twoMatches = cleanReport.match(/\b2\b/g);
expect(twoMatches?.length).toBeGreaterThanOrEqual(6); // At least 6 occurrences of "2"
});
it('should show separate rows for each policy and language combination', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
await synthesize({
numTests: 1,
language: ['Hmong', 'Zulu'],
plugins: [
{ id: 'policy', numTests: 1, config: { policy: 'Policy 1' } },
{ id: 'policy', numTests: 1, config: { policy: 'Policy 2' } },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
const reportMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string => typeof arg === 'string' && arg.includes('Test Generation Report'),
);
expect(reportMessage).toBeDefined();
const cleanReport = stripAnsi(reportMessage || '');
// Each policy should have separate rows for each language
// Display format: "(Lang) policy [hash]: preview..."
const hmongMatches = cleanReport.match(/\(Hmong\) policy \[[a-f0-9]{12}\]:/g);
const zuluMatches = cleanReport.match(/\(Zulu\) policy \[[a-f0-9]{12}\]:/g);
expect(hmongMatches?.length).toBe(2); // 2 policies in Hmong
expect(zuluMatches?.length).toBe(2); // 2 policies in Zulu
// Each should show 1 requested, 1 generated
const oneMatches = cleanReport.match(/\b1\b/g);
expect(oneMatches?.length).toBeGreaterThanOrEqual(8); // At least 8 occurrences of "1"
});
it('should use policy name when available instead of hash + truncated text', async () => {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'policy',
});
await synthesize({
numTests: 2,
plugins: [
// Policy with a name - should display the name
{
id: 'policy',
numTests: 2,
config: {
policy: {
id: 'abc123def456',
text: 'Some policy text',
name: 'Secret Protection Policy',
},
},
},
// Policy without a name - should display hash + truncated text
{ id: 'policy', numTests: 2, config: { policy: 'Another policy without a name' } },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
const reportMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string => typeof arg === 'string' && arg.includes('Test Generation Report'),
);
expect(reportMessage).toBeDefined();
const cleanReport = stripAnsi(reportMessage || '');
// Named policy should show just the name (no hash in display)
expect(cleanReport).toMatch(/Secret Protection Policy/);
expect(cleanReport).not.toMatch(/Secret Protection Policy \[[a-f0-9]/); // No hash after name
// Inline policy should show: "policy [hash]: preview..."
expect(cleanReport).toMatch(/policy \[[a-f0-9]{12}\]:/);
});
it('should work correctly with both built-in plugins and policy plugins', async () => {
// Mock different plugins
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockImplementation(function (predicate) {
const mockPlugins = [
{ key: 'policy', action: mockPluginAction },
{ key: 'hallucination', action: mockPluginAction },
{ key: 'contracts', action: mockPluginAction },
];
if (typeof predicate === 'function') {
return mockPlugins.find(predicate);
}
return undefined;
});
await synthesize({
numTests: 2,
plugins: [
// Built-in plugin - hallucination
{ id: 'hallucination', numTests: 2 },
// Built-in plugin - contracts
{ id: 'contracts', numTests: 2 },
// Policy plugin with name (cloud-style)
{
id: 'policy',
numTests: 2,
config: {
policy: {
id: 'abc123def456',
text: 'Never share confidential data',
name: 'Data Protection Policy',
},
},
},
// Policy plugin without name (inline)
{ id: 'policy', numTests: 2, config: { policy: 'Always be respectful to users' } },
],
prompts: ['Test prompt'],
strategies: [],
targetIds: ['test-provider'],
});
const reportMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string => typeof arg === 'string' && arg.includes('Test Generation Report'),
);
expect(reportMessage).toBeDefined();
const cleanReport = stripAnsi(reportMessage || '');
// Built-in plugins should show their ID directly
expect(cleanReport).toMatch(/hallucination/);
expect(cleanReport).toMatch(/contracts/);
// Named policy should show just the name
expect(cleanReport).toMatch(/Data Protection Policy/);
expect(cleanReport).not.toMatch(/Data Protection Policy \[/); // No ID after name
// Inline policy should show "policy [hash]: preview..."
expect(cleanReport).toMatch(/policy \[[a-f0-9]{12}\]:/);
// Should have 4 plugin rows (hallucination, contracts, named policy, inline policy)
const pluginRows = cleanReport.match(/│\s+\d+\s+│\s+Plugin/g);
expect(pluginRows?.length).toBe(4);
});
});
describe('redteamProvider config propagation to strategies', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(logger.info).mockImplementation(() => logger as any);
vi.mocked(logger.warn).mockImplementation(() => logger as any);
vi.mocked(logger.debug).mockImplementation(() => logger as any);
vi.mocked(logger.error).mockImplementation(() => logger as any);
vi.mocked(extractSystemPurpose).mockResolvedValue('Test purpose');
vi.mocked(extractEntities).mockResolvedValue([]);
vi.mocked(shouldGenerateRemote).mockReturnValue(false);
vi.mocked(checkRemoteHealth).mockResolvedValue({
status: 'OK',
message: 'Cloud API is healthy',
});
vi.mocked(getRemoteHealthUrl).mockReturnValue('http://test.com/health');
vi.mocked(extractVariablesFromTemplates).mockReturnValue(['query']);
vi.mocked(cliProgress.SingleBar).mockImplementation(function () {
return {
start: vi.fn(),
update: vi.fn(),
stop: vi.fn(),
increment: vi.fn(),
} as any;
});
});
it('should pass redteamProvider from cliState.config to strategy actions', async () => {
// Import cliState to set up the redteam provider config
const cliState = (await import('../../src/cliState')).default;
const originalConfig = cliState.config;
// Set up cliState with a mock redteam provider - this is the provider that should
// be passed to strategies for use by agentic providers (iterative, crescendo, etc.)
cliState.config = {
redteam: {
provider: 'vertex:gemini-2.5-flash',
},
} as any;
try {
// Mock plugin to return a test case
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
// Mock strategy that captures the config it receives
let capturedConfig: Record<string, any> | undefined;
const mockStrategyAction = vi.fn().mockImplementation((testCases, _injectVar, config) => {
capturedConfig = config;
return testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'jailbreak' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'jailbreak',
action: mockStrategyAction,
});
// Mock the provider loading to return a mock provider for test generation
// This avoids the loadApiProviders error while still testing strategy config
const mockProvider = { id: () => 'mock-provider', callApi: vi.fn() };
const providersShared = await import('../../src/redteam/providers/shared');
const getProviderSpy = vi
.spyOn(providersShared.redteamProviderManager, 'getProvider')
.mockResolvedValue(mockProvider as any);
const getGradingProviderSpy = vi
.spyOn(providersShared.redteamProviderManager, 'getGradingProvider')
.mockResolvedValue(mockProvider as any);
const getMultilingualProviderSpy = vi
.spyOn(providersShared.redteamProviderManager, 'getMultilingualProvider')
.mockResolvedValue(undefined);
try {
await synthesize({
cloudTargetDatabaseId: 'cloud-target-123',
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }],
targetIds: ['test-provider'],
});
// KEY ASSERTION: The strategy should receive redteamProvider from cliState.config
expect(mockStrategyAction).toHaveBeenCalled();
expect(capturedConfig).toBeDefined();
expect(capturedConfig?.redteamProvider).toBe('vertex:gemini-2.5-flash');
expect(capturedConfig?.targetId).toBe('cloud-target-123');
} finally {
getProviderSpy.mockRestore();
getGradingProviderSpy.mockRestore();
getMultilingualProviderSpy.mockRestore();
}
} finally {
// Restore original cliState
cliState.config = originalConfig;
}
});
it('should pass redteamProvider as undefined when not configured in cliState', async () => {
const cliState = (await import('../../src/cliState')).default;
const originalConfig = cliState.config;
// Set up cliState WITHOUT redteam provider
cliState.config = {
redteam: {},
} as any;
try {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
let capturedConfig: Record<string, any> | undefined;
const mockStrategyAction = vi.fn().mockImplementation((testCases, _injectVar, config) => {
capturedConfig = config;
return testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'jailbreak' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'jailbreak',
action: mockStrategyAction,
});
await synthesize({
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }],
targetIds: ['test-provider'],
});
expect(mockStrategyAction).toHaveBeenCalled();
expect(capturedConfig).toBeDefined();
// When not configured, redteamProvider should be undefined
expect(capturedConfig?.redteamProvider).toBeUndefined();
} finally {
cliState.config = originalConfig;
}
});
it('should pass redteamProvider as object when configured as provider options', async () => {
const cliState = (await import('../../src/cliState')).default;
const originalConfig = cliState.config;
// Set up cliState with provider options object
const providerOptions = {
id: 'vertex:gemini-2.5-flash',
config: { temperature: 0.7 },
};
cliState.config = {
redteam: {
provider: providerOptions,
},
} as any;
try {
const mockPluginAction = vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]);
vi.spyOn(Plugins, 'find').mockReturnValue({
action: mockPluginAction,
key: 'test-plugin',
});
let capturedConfig: Record<string, any> | undefined;
const mockStrategyAction = vi.fn().mockImplementation((testCases, _injectVar, config) => {
capturedConfig = config;
return testCases.map((tc: any) => ({
...tc,
metadata: { ...tc.metadata, strategyId: 'jailbreak' },
}));
});
vi.spyOn(Strategies, 'find').mockReturnValue({
id: 'jailbreak',
action: mockStrategyAction,
});
// Mock the provider loading
const mockProvider = { id: () => 'mock-provider', callApi: vi.fn() };
const providersShared = await import('../../src/redteam/providers/shared');
const getProviderSpy = vi
.spyOn(providersShared.redteamProviderManager, 'getProvider')
.mockResolvedValue(mockProvider as any);
const getGradingProviderSpy = vi
.spyOn(providersShared.redteamProviderManager, 'getGradingProvider')
.mockResolvedValue(mockProvider as any);
const getMultilingualProviderSpy = vi
.spyOn(providersShared.redteamProviderManager, 'getMultilingualProvider')
.mockResolvedValue(undefined);
try {
await synthesize({
numTests: 1,
plugins: [{ id: 'test-plugin', numTests: 1 }],
prompts: ['Test prompt'],
strategies: [{ id: 'jailbreak' }],
targetIds: ['test-provider'],
});
expect(mockStrategyAction).toHaveBeenCalled();
expect(capturedConfig).toBeDefined();
// Should pass the full provider options object
expect(capturedConfig?.redteamProvider).toEqual(providerOptions);
} finally {
getProviderSpy.mockRestore();
getGradingProviderSpy.mockRestore();
getMultilingualProviderSpy.mockRestore();
}
} finally {
cliState.config = originalConfig;
}
});
});
describe('Multi-input mode plugin exclusion', () => {
it('should exclude dataset-exempt plugins in multi-input mode', async () => {
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'aegis', numTests: 1 }, // DATASET_EXEMPT_PLUGIN
{ id: 'contracts', numTests: 1 }, // Regular plugin
],
prompts: ['Test {{query}}'],
strategies: [],
targetIds: ['test-provider'],
inputs: { query: 'user query', context: 'additional context' }, // Multi-input mode
});
// aegis should be excluded, contracts should remain
// Result should only contain test cases from contracts
expect(result.testCases.length).toBeGreaterThanOrEqual(0);
// Check that logger.info was called with skipping message
const skipMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find((arg): arg is string => typeof arg === 'string' && arg.includes('Skipping'));
expect(skipMessage).toBeDefined();
expect(skipMessage).toContain('aegis');
});
it('should exclude multi-input excluded plugins in multi-input mode', async () => {
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'cca', numTests: 1 }, // MULTI_INPUT_EXCLUDED_PLUGIN
{ id: 'cross-session-leak', numTests: 1 }, // MULTI_INPUT_EXCLUDED_PLUGIN
{ id: 'contracts', numTests: 1 }, // Regular plugin
],
prompts: ['Test {{query}}'],
strategies: [],
targetIds: ['test-provider'],
inputs: { query: 'user query', context: 'additional context' }, // Multi-input mode
});
// cca and cross-session-leak should be excluded
expect(result.testCases.length).toBeGreaterThanOrEqual(0);
// Check that logger.info was called with skipping message
const skipMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find((arg): arg is string => typeof arg === 'string' && arg.includes('Skipping'));
expect(skipMessage).toBeDefined();
expect(skipMessage).toContain('cca');
expect(skipMessage).toContain('cross-session-leak');
});
it('should NOT exclude plugins when inputs is empty object', async () => {
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'cca', numTests: 1 }, // Would be excluded in multi-input mode
{ id: 'contracts', numTests: 1 },
],
prompts: ['Test {{query}}'],
strategies: [],
targetIds: ['test-provider'],
inputs: {}, // Empty inputs - not multi-input mode
});
// No plugins should be excluded
expect(result.testCases.length).toBeGreaterThanOrEqual(0);
// Should NOT have skipping message for cca
const skipMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string =>
typeof arg === 'string' && arg.includes('Skipping') && arg.includes('cca'),
);
expect(skipMessage).toBeUndefined();
});
it('should NOT exclude plugins when inputs is undefined', async () => {
const result = await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'cca', numTests: 1 }, // Would be excluded in multi-input mode
{ id: 'contracts', numTests: 1 },
],
prompts: ['Test {{query}}'],
strategies: [],
targetIds: ['test-provider'],
// No inputs - not multi-input mode
});
// No plugins should be excluded
expect(result.testCases.length).toBeGreaterThanOrEqual(0);
// Should NOT have skipping message for cca
const skipMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find(
(arg): arg is string =>
typeof arg === 'string' && arg.includes('Skipping') && arg.includes('cca'),
);
expect(skipMessage).toBeUndefined();
});
it('should log info about using multi-input mode', async () => {
await synthesize({
language: 'en',
numTests: 1,
plugins: [{ id: 'contracts', numTests: 1 }],
prompts: ['Test {{query}}'],
strategies: [],
targetIds: ['test-provider'],
inputs: { query: 'user query', context: 'additional context' },
});
// Check that logger.info was called with multi-input mode message
const multiInputMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find((arg): arg is string => typeof arg === 'string' && arg.includes('multi-input mode'));
expect(multiInputMessage).toBeDefined();
expect(multiInputMessage).toContain('2 variables');
expect(multiInputMessage).toContain('query');
expect(multiInputMessage).toContain('context');
});
it('should exclude only plugins that still do not support multi-input mode', async () => {
await synthesize({
language: 'en',
numTests: 1,
plugins: [
{ id: 'cca', numTests: 1 },
{ id: 'cross-session-leak', numTests: 1 },
{ id: 'special-token-injection', numTests: 1 },
{ id: 'system-prompt-override', numTests: 1 },
{ id: 'ascii-smuggling', numTests: 1 },
{ id: 'contracts', numTests: 1 }, // Regular plugin - should NOT be excluded
],
prompts: ['Test {{query}}'],
strategies: [],
targetIds: ['test-provider'],
inputs: { query: 'user query', context: 'additional context' },
});
// Check that only the explicit non-dataset exclusions are in the skip message.
const skipMessage = vi
.mocked(logger.info)
.mock.calls.map(([arg]) => arg)
.find((arg): arg is string => typeof arg === 'string' && arg.includes('Skipping 2 plugin'));
expect(skipMessage).toBeDefined();
expect(skipMessage).toContain('cca');
expect(skipMessage).toContain('cross-session-leak');
expect(skipMessage).not.toContain('special-token-injection');
expect(skipMessage).not.toContain('system-prompt-override');
expect(skipMessage).not.toContain('ascii-smuggling');
});
});
});