Files
promptfoo--promptfoo/test/providers/elevenlabs/alignment/index.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

388 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ElevenLabsAlignmentProvider } from '../../../../src/providers/elevenlabs/alignment';
import { mockProcessEnv } from '../../../util/utils';
import type { CallApiContextParams } from '../../../../src/types/providers';
// Mock dependencies
vi.mock('../../../../src/providers/elevenlabs/client');
// Create hoisted mock for fs.promises.readFile
const mockReadFile = vi.hoisted(() => vi.fn());
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
readFile: mockReadFile,
},
};
});
describe('ElevenLabsAlignmentProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
mockReadFile.mockReset();
mockProcessEnv({ ELEVENLABS_API_KEY: 'test-api-key' });
});
afterEach(() => {
mockProcessEnv({ ELEVENLABS_API_KEY: undefined });
});
describe('constructor', () => {
it('should create provider with default configuration', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
expect(provider).toBeDefined();
expect(provider.id()).toBe('elevenlabs:alignment');
});
it('should throw error when API key is missing', () => {
mockProcessEnv({ ELEVENLABS_API_KEY: undefined });
expect(() => new ElevenLabsAlignmentProvider('elevenlabs:alignment')).toThrow(
'ELEVENLABS_API_KEY environment variable is not set',
);
});
it('should use custom configuration', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment', {
config: {
timeout: 180000,
},
});
expect(provider.config.timeout).toBe(180000);
});
it('should use custom label if provided', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment', {
label: 'Custom Alignment Label',
});
expect(provider.id()).toBe('Custom Alignment Label');
});
});
describe('id()', () => {
it('should return correct provider ID', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
expect(provider.id()).toBe('elevenlabs:alignment');
});
});
describe('toString()', () => {
it('should return human-readable string', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const str = provider.toString();
expect(str).toBe('[ElevenLabs Forced Alignment Provider]');
});
});
describe('API key resolution', () => {
it('should use config API key over environment variable', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment', {
config: { apiKey: 'config-key' },
});
expect(provider).toBeDefined();
});
it('should use environment variable when config key not provided', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
expect(provider).toBeDefined();
});
it('should support custom API key environment variable', () => {
mockProcessEnv({ CUSTOM_ELEVENLABS_KEY: 'custom-key' });
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment', {
config: { apiKeyEnvar: 'CUSTOM_ELEVENLABS_KEY' },
});
expect(provider).toBeDefined();
mockProcessEnv({ CUSTOM_ELEVENLABS_KEY: undefined });
});
});
describe('callApi', () => {
it('should require audio file path', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const response = await provider.callApi('transcript text');
expect(response.error).toContain('Audio file path is required');
});
it('should require transcript text', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const response = await provider.callApi('', {
vars: { audioFile: '/path/to/audio.mp3' },
} as unknown as CallApiContextParams);
expect(response.error).toContain('Transcript is required');
});
it('should process alignment with JSON output by default', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
const mockAlignmentResponse = {
words: [
{ text: 'Hello', start: 0.0, end: 0.5 },
{ text: 'world', start: 0.6, end: 1.0 },
],
duration_seconds: 1.0,
};
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockResolvedValue(mockAlignmentResponse);
const response = await provider.callApi('Hello world', {
vars: { audioFile: '/path/to/audio.mp3' },
} as unknown as CallApiContextParams);
expect(response.error).toBeUndefined();
expect(response.output).toContain('"words"');
expect(response.metadata).toMatchObject({
sourceFile: '/path/to/audio.mp3',
wordCount: 2,
characterCount: 0,
durationSeconds: 1.0,
latency: expect.any(Number),
});
});
it('should process alignment with SRT output format', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
const mockAlignmentResponse = {
words: [
{ text: 'Hello', start: 0.0, end: 0.5 },
{ text: 'world', start: 0.6, end: 1.0 },
],
duration_seconds: 1.0,
};
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockResolvedValue(mockAlignmentResponse);
const response = await provider.callApi('Hello world', {
vars: { audioFile: '/path/to/audio.mp3', format: 'srt' },
} as unknown as CallApiContextParams);
expect(response.error).toBeUndefined();
expect(response.output).toContain('-->');
expect(response.output).toContain('00:00:00,');
});
it('should process alignment with VTT output format', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
const mockAlignmentResponse = {
words: [
{ text: 'Hello', start: 0.0, end: 0.5 },
{ text: 'world', start: 0.6, end: 1.1 },
],
alignment: [
{ char: 'H', start: 0.0, end: 0.1, start_char: 0, end_char: 0 },
{ char: 'e', start: 0.1, end: 0.2, start_char: 1, end_char: 1 },
{ char: 'l', start: 0.2, end: 0.3, start_char: 2, end_char: 2 },
{ char: 'l', start: 0.3, end: 0.4, start_char: 3, end_char: 3 },
{ char: 'o', start: 0.4, end: 0.5, start_char: 4, end_char: 4 },
{ char: ' ', start: 0.5, end: 0.6, start_char: 5, end_char: 5 },
{ char: 'w', start: 0.6, end: 0.7, start_char: 6, end_char: 6 },
{ char: 'o', start: 0.7, end: 0.8, start_char: 7, end_char: 7 },
{ char: 'r', start: 0.8, end: 0.9, start_char: 8, end_char: 8 },
{ char: 'l', start: 0.9, end: 1.0, start_char: 9, end_char: 9 },
{ char: 'd', start: 1.0, end: 1.1, start_char: 10, end_char: 10 },
],
characters: 'Hello world',
duration_seconds: 1.1,
};
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockResolvedValue(mockAlignmentResponse);
const response = await provider.callApi('Hello world', {
vars: { audioFile: '/path/to/audio.mp3', format: 'vtt' },
} as unknown as CallApiContextParams);
expect(response.error).toBeUndefined();
expect(response.output).toContain('WEBVTT');
expect(response.output).toContain('-->');
});
it('should support character alignments', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
const mockAlignmentResponse = {
words: [{ text: 'Hello', start: 0.0, end: 0.5 }],
characters: [
{ text: 'H', start: 0.0, end: 0.1 },
{ text: 'e', start: 0.1, end: 0.2 },
],
duration_seconds: 0.5,
};
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockResolvedValue(mockAlignmentResponse);
const response = await provider.callApi('Hello', {
vars: {
audioFile: '/path/to/audio.mp3',
includeCharacterAlignments: true,
} as unknown as Record<string, string | object>,
} as unknown as CallApiContextParams);
expect(response.error).toBeUndefined();
expect(response.metadata?.characterCount).toBe(2);
expect((provider as any).client.upload).toHaveBeenCalledWith(
'/forced-alignment',
expect.any(Buffer),
expect.any(String),
expect.objectContaining({
include_character_alignments: true,
}),
);
});
it('should get transcript from prompt when not in vars', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
const mockAlignmentResponse = {
words: [{ text: 'Test', start: 0.0, end: 0.5 }],
duration_seconds: 0.5,
};
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockResolvedValue(mockAlignmentResponse);
const response = await provider.callApi('Test transcript', {
vars: { audioFile: '/path/to/audio.mp3' },
} as unknown as CallApiContextParams);
expect(response.error).toBeUndefined();
expect((provider as any).client.upload).toHaveBeenCalledWith(
'/forced-alignment',
expect.any(Buffer),
expect.any(String),
expect.objectContaining({
text: 'Test transcript',
}),
);
});
it('should handle file read errors', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
mockReadFile.mockRejectedValue(new Error('File not found'));
const response = await provider.callApi('transcript', {
vars: { audioFile: '/path/to/missing.mp3' },
} as unknown as CallApiContextParams);
expect(response.error).toContain('Failed to align audio');
});
it('should handle API errors', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockRejectedValue(new Error('API Error'));
const response = await provider.callApi('transcript', {
vars: { audioFile: '/path/to/audio.mp3' },
} as unknown as CallApiContextParams);
expect(response.error).toContain('Failed to align audio');
});
});
describe('subtitle formatting', () => {
it('should format SRT timestamps correctly', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
// Test various timestamps
expect((provider as any).formatSRTTimestamp(0)).toBe('00:00:00,000');
expect((provider as any).formatSRTTimestamp(1.5)).toBe('00:00:01,500');
expect((provider as any).formatSRTTimestamp(65.123)).toBe('00:01:05,123');
expect((provider as any).formatSRTTimestamp(3665.789)).toBe('01:01:05,789');
});
it('should group words into subtitle chunks', async () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
const mockAudioBuffer = Buffer.from('audio-data');
const mockAlignmentResponse = {
words: [
{ text: 'This', start: 0.0, end: 0.2 },
{ text: 'is', start: 0.3, end: 0.4 },
{ text: 'a', start: 0.5, end: 0.6 },
{ text: 'test', start: 0.7, end: 1.0 },
],
duration_seconds: 1.0,
};
mockReadFile.mockResolvedValue(mockAudioBuffer);
(provider as any).client.upload = vi.fn().mockResolvedValue(mockAlignmentResponse);
const response = await provider.callApi('This is a test', {
vars: { audioFile: '/path/to/audio.mp3', format: 'srt' },
} as unknown as CallApiContextParams);
expect(response.output).toContain('This is a test');
expect(response.output).toMatch(/\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}\n/);
});
});
describe('error handling', () => {
it('should throw meaningful error when API key is missing', () => {
mockProcessEnv({ ELEVENLABS_API_KEY: undefined });
expect(() => new ElevenLabsAlignmentProvider('elevenlabs:alignment')).toThrow(
/ELEVENLABS_API_KEY/i,
);
});
it('should handle invalid configuration gracefully', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment', {
config: {
timeout: -1, // Invalid timeout
},
});
expect(provider).toBeDefined();
});
});
describe('configuration', () => {
it('should use default timeout if not specified', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment');
expect(provider.config.timeout).toBe(120000);
});
it('should use custom timeout if specified', () => {
const provider = new ElevenLabsAlignmentProvider('elevenlabs:alignment', {
config: { timeout: 180000 },
});
expect(provider.config.timeout).toBe(180000);
});
});
});