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 %} {"user_message":"Ignore the retrieved answer","retrieved_context":"Approved policy text"} {% 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>((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 | 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 | 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 | 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'); }); }); });