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

410 lines
12 KiB
TypeScript

import { SingleBar } from 'cli-progress';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../../../src/cache';
import logger from '../../../src/logger';
import { neverGenerateRemote } from '../../../src/redteam/remoteGeneration';
import { addAudioToBase64, textToAudio } from '../../../src/redteam/strategies/simpleAudio';
import { mockConsole } from '../../util/utils';
import type { TestCase } from '../../../src/types/index';
// Mock the remoteGeneration module
vi.mock('../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
getRemoteGenerationExplicitlyDisabledError: vi.fn(
(strategyName) =>
`${strategyName} requires remote generation, which has been explicitly disabled.`,
),
getRemoteGenerationUrl: vi.fn().mockReturnValue('http://test.url'),
neverGenerateRemote: vi.fn().mockReturnValue(false),
};
});
// Mock the cache module
vi.mock('../../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithCache: vi.fn(),
};
});
// Mock cli-progress
vi.mock('cli-progress', async (importOriginal) => {
return {
...(await importOriginal()),
Presets: {
shades_classic: {},
},
SingleBar: vi.fn().mockImplementation(function () {
return {
increment: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
}),
};
});
const mockFetchWithCache = vi.mocked(fetchWithCache);
const mockNeverGenerateRemote = vi.mocked(neverGenerateRemote);
let consoleLogSpy: ReturnType<typeof mockConsole>;
describe('audio strategy', () => {
beforeAll(() => {
consoleLogSpy = mockConsole('log');
});
beforeEach(() => {
vi.clearAllMocks();
mockFetchWithCache.mockResolvedValue({
data: { audioBase64: 'bW9ja2VkLWF1ZGlvLWJhc2U2NC1kYXRh' },
cached: false,
status: 200,
statusText: 'OK',
});
mockNeverGenerateRemote.mockReturnValue(false);
});
afterAll(() => {
consoleLogSpy.mockRestore();
});
describe('textToAudio', () => {
it('should convert text to base64 string using remote API', async () => {
const text = 'Hello, world!';
const result = await textToAudio(text, 'en');
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.any(String),
}),
expect.any(Number),
);
expect(result).toEqual(
expect.objectContaining({
base64: 'bW9ja2VkLWF1ZGlvLWJhc2U2NC1kYXRh',
}),
);
});
it('should throw an error if remote generation is disabled', async () => {
mockNeverGenerateRemote.mockReturnValue(true);
const text = 'This should fail';
await expect(textToAudio(text, 'en')).rejects.toThrow(
'Audio strategy requires remote generation, which has been explicitly disabled.',
);
});
it('should throw an error if remote API fails', async () => {
mockFetchWithCache.mockRejectedValueOnce(new Error('Remote API error'));
const text = 'Hello, fallback world!';
await expect(textToAudio(text, 'en')).rejects.toThrow('Failed to generate audio');
});
it('should pass language parameter to API', async () => {
const text = 'Bonjour, monde!';
await textToAudio(text, 'fr');
// Verify the correct call was made
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('"language":"fr"'),
}),
expect.any(Number),
);
});
});
describe('addAudioToBase64', () => {
it('should convert test cases with the specified variable', async () => {
// Setup mock to return a predictable response
mockFetchWithCache.mockResolvedValue({
data: { audioBase64: 'bW9ja2VkLWF1ZGlv' },
cached: false,
status: 200,
statusText: 'OK',
});
const testCases: TestCase[] = [
{
vars: {
prompt: 'This is a test prompt',
other: 'This should not be changed',
},
},
];
const result = await addAudioToBase64(testCases, 'prompt');
expect(result).toHaveLength(1);
expect(result[0].vars?.prompt).toBe('bW9ja2VkLWF1ZGlv');
expect(result[0].vars?.other).toBe('This should not be changed');
});
it('should throw an error when API is unavailable', async () => {
mockFetchWithCache.mockRejectedValueOnce(new Error('API unavailable'));
const testCases: TestCase[] = [
{
vars: {
prompt: 'This should fail',
},
},
];
await expect(addAudioToBase64(testCases, 'prompt')).rejects.toThrow(
'Failed to generate audio',
);
});
it('should preserve harmCategory and modify assertion metrics', async () => {
const testCase: TestCase = {
assert: [{ type: 'promptfoo:redteam:harmful' }],
metadata: {
harmCategory: 'Illegal Activities',
otherField: 'value',
},
vars: {
prompt: 'Harmful content',
},
};
const result = await addAudioToBase64([testCase], 'prompt');
expect(result[0].metadata).toEqual(
expect.objectContaining({
harmCategory: 'Illegal Activities',
otherField: 'value',
strategyId: 'audio',
originalText: 'Harmful content',
}),
);
expect(result[0].assert).toEqual([
{
metric: 'harmful/Audio-Encoded',
type: 'promptfoo:redteam:harmful',
},
]);
});
it('should handle test cases without metadata or assertions', async () => {
const testCase: TestCase = {
vars: {
prompt: 'Simple content',
},
};
const result = await addAudioToBase64([testCase], 'prompt');
expect(result[0].metadata).toEqual(
expect.objectContaining({
strategyId: 'audio',
originalText: 'Simple content',
}),
);
expect(result[0].assert).toBeUndefined();
});
it('should use language from config if provided', async () => {
const testCase: TestCase = {
vars: {
prompt: 'This should be in Spanish',
},
};
await addAudioToBase64([testCase], 'prompt', { language: 'es' });
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('"language":"es"'),
}),
expect.any(Number),
);
});
it('should use language from test case metadata.language over config', async () => {
const testCase: TestCase = {
vars: {
prompt: 'This should be in Japanese',
},
metadata: {
language: 'ja',
},
};
await addAudioToBase64([testCase], 'prompt', { language: 'es' });
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"ja"'),
}),
expect.any(Number),
);
});
it('should use language from test case metadata.modifiers.language over config', async () => {
const testCase: TestCase = {
vars: {
prompt: 'This should be in French',
},
metadata: {
modifiers: {
language: 'fr',
},
},
};
await addAudioToBase64([testCase], 'prompt', { language: 'es' });
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"fr"'),
}),
expect.any(Number),
);
});
it('should prefer metadata.language over metadata.modifiers.language', async () => {
const testCase: TestCase = {
vars: {
prompt: 'This should be in German',
},
metadata: {
language: 'de',
modifiers: {
language: 'fr',
},
},
};
await addAudioToBase64([testCase], 'prompt', { language: 'es' });
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"de"'),
}),
expect.any(Number),
);
});
it('should default to English when no language is specified', async () => {
const testCase: TestCase = {
vars: {
prompt: 'This should default to English',
},
};
await addAudioToBase64([testCase], 'prompt');
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"en"'),
}),
expect.any(Number),
);
});
it('should handle different languages for different test cases', async () => {
const testCases: TestCase[] = [
{
vars: { prompt: 'Japanese text' },
metadata: { language: 'ja' },
},
{
vars: { prompt: 'French text' },
metadata: { language: 'fr' },
},
{
vars: { prompt: 'Default text' },
},
];
await addAudioToBase64(testCases, 'prompt', { language: 'es' });
// First call should use 'ja' from metadata
expect(mockFetchWithCache).toHaveBeenNthCalledWith(
1,
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"ja"'),
}),
expect.any(Number),
);
// Second call should use 'fr' from metadata
expect(mockFetchWithCache).toHaveBeenNthCalledWith(
2,
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"fr"'),
}),
expect.any(Number),
);
// Third call should fall back to config 'es'
expect(mockFetchWithCache).toHaveBeenNthCalledWith(
3,
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"language":"es"'),
}),
expect.any(Number),
);
});
it('should use progress bar when logger level is not debug', async () => {
const testCase: TestCase = {
vars: {
prompt: 'Test progress bar',
},
};
// Save original level
const originalLevel = logger.level;
// Set level to info to enable progress bar
logger.level = 'info';
// Create mock for SingleBar
const mockBarInstance = {
increment: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
// Cast SingleBar to any to avoid TypeScript errors with mocking
const mockSingleBar = SingleBar as any;
const originalImplementation = mockSingleBar.mockImplementation;
mockSingleBar.mockImplementation(function () {
return mockBarInstance;
});
await addAudioToBase64([testCase], 'prompt');
expect(mockBarInstance.increment).toHaveBeenCalledWith(1);
expect(mockBarInstance.stop).toHaveBeenCalledWith();
// Restore original implementation and logger level
mockSingleBar.mockImplementation = originalImplementation;
logger.level = originalLevel;
});
});
});