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
1090 lines
35 KiB
TypeScript
1090 lines
35 KiB
TypeScript
import * as fs from 'fs';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
GoogleVideoProvider,
|
|
generateVideoCacheKey,
|
|
validateAspectRatio,
|
|
validateDuration,
|
|
validateResolution,
|
|
} from '../../../src/providers/google/video';
|
|
import { mockProcessEnv } from '../../util/utils';
|
|
|
|
import type { CallApiContextParams } from '../../../src/types/providers';
|
|
|
|
// Mock the Google client
|
|
const mockRequest = vi.fn();
|
|
const mockFetchWithTimeout = vi.fn();
|
|
const mockGetGoogleClient = vi.fn().mockResolvedValue({
|
|
client: { request: mockRequest },
|
|
projectId: 'test-project',
|
|
});
|
|
|
|
// Mock blob storage
|
|
const mockStoreBlob = vi.fn();
|
|
vi.mock('../../../src/blobs', () => ({
|
|
storeBlob: (...args: unknown[]) => mockStoreBlob(...args),
|
|
}));
|
|
|
|
vi.mock('fs');
|
|
const mockResolveProjectId = vi.fn().mockResolvedValue('test-project');
|
|
const mockGetGoogleApiKey = vi.fn();
|
|
const mockDetermineGoogleVertexMode = vi.fn();
|
|
vi.mock('../../../src/providers/google/util', () => ({
|
|
getGoogleClient: () => mockGetGoogleClient(),
|
|
loadCredentials: vi.fn((creds) => creds),
|
|
resolveProjectId: (...args: unknown[]) => mockResolveProjectId(...args),
|
|
getGoogleApiKey: (...args: unknown[]) => mockGetGoogleApiKey(...args),
|
|
determineGoogleVertexMode: (...args: unknown[]) => mockDetermineGoogleVertexMode(...args),
|
|
}));
|
|
vi.mock('../../../src/util/fetch/index', () => ({
|
|
fetchWithTimeout: (...args: unknown[]) => mockFetchWithTimeout(...args),
|
|
}));
|
|
|
|
vi.mock('../../../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('GoogleVideoProvider', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockRequest.mockReset();
|
|
mockFetchWithTimeout.mockReset();
|
|
mockStoreBlob.mockReset();
|
|
mockResolveProjectId.mockReset();
|
|
mockGetGoogleApiKey.mockReset();
|
|
mockDetermineGoogleVertexMode.mockReset();
|
|
mockResolveProjectId.mockResolvedValue('test-project');
|
|
mockGetGoogleApiKey.mockImplementation((config: any, env?: any) => ({
|
|
apiKey:
|
|
config?.apiKey ||
|
|
env?.GOOGLE_API_KEY ||
|
|
process.env.GOOGLE_API_KEY ||
|
|
env?.GEMINI_API_KEY ||
|
|
process.env.GEMINI_API_KEY,
|
|
source: 'GOOGLE_API_KEY',
|
|
}));
|
|
mockDetermineGoogleVertexMode.mockImplementation((config: any, env?: any) => {
|
|
if (config?.vertexai !== undefined) {
|
|
return config.vertexai;
|
|
}
|
|
return Boolean(
|
|
config?.projectId ||
|
|
config?.credentials ||
|
|
env?.GOOGLE_CLOUD_PROJECT ||
|
|
env?.GOOGLE_PROJECT_ID ||
|
|
process.env.GOOGLE_CLOUD_PROJECT ||
|
|
process.env.GOOGLE_PROJECT_ID,
|
|
);
|
|
});
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: 'test-project' });
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
mockProcessEnv({ GOOGLE_API_KEY: undefined });
|
|
mockProcessEnv({ GEMINI_API_KEY: undefined });
|
|
mockProcessEnv({ VERTEX_PROJECT_ID: undefined });
|
|
|
|
// Default mock for blob storage
|
|
mockStoreBlob.mockResolvedValue({
|
|
ref: {
|
|
uri: 'promptfoo://blob/abc123def456',
|
|
hash: 'abc123def456',
|
|
mimeType: 'video/mp4',
|
|
sizeBytes: 1024,
|
|
provider: 'filesystem',
|
|
},
|
|
deduplicated: false,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockProcessEnv({ GOOGLE_API_KEY: undefined });
|
|
mockProcessEnv({ GEMINI_API_KEY: undefined });
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockProcessEnv({ VERTEX_PROJECT_ID: undefined });
|
|
// Reset fs mocks to prevent leakage between tests
|
|
vi.mocked(fs.existsSync).mockReset();
|
|
vi.mocked(fs.readFileSync).mockReset();
|
|
});
|
|
|
|
describe('constructor and id', () => {
|
|
it('should construct with model name', () => {
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview');
|
|
expect(provider.id()).toBe('google:video:veo-3.1-generate-preview');
|
|
expect(provider.toString()).toBe('[Google Video Provider veo-3.1-generate-preview]');
|
|
});
|
|
|
|
it('should support custom provider ID', () => {
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
id: 'my-custom-id',
|
|
});
|
|
expect(provider.id()).toBe('my-custom-id');
|
|
});
|
|
});
|
|
|
|
describe('validateAspectRatio', () => {
|
|
it('should accept 16:9', () => {
|
|
expect(validateAspectRatio('16:9')).toEqual({ valid: true });
|
|
});
|
|
|
|
it('should accept 9:16', () => {
|
|
expect(validateAspectRatio('9:16')).toEqual({ valid: true });
|
|
});
|
|
|
|
it('should reject invalid ratio', () => {
|
|
const result = validateAspectRatio('4:3');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('Invalid aspect ratio');
|
|
});
|
|
});
|
|
|
|
describe('validateDuration', () => {
|
|
it('should accept 4, 6, 8 for Veo 3.1', () => {
|
|
expect(validateDuration('veo-3.1-generate-preview', 4)).toEqual({ valid: true });
|
|
expect(validateDuration('veo-3.1-generate-preview', 6)).toEqual({ valid: true });
|
|
expect(validateDuration('veo-3.1-generate-preview', 8)).toEqual({ valid: true });
|
|
});
|
|
|
|
it('should accept 4, 6, 8 for Veo 3', () => {
|
|
expect(validateDuration('veo-3-generate', 4)).toEqual({ valid: true });
|
|
expect(validateDuration('veo-3-generate', 6)).toEqual({ valid: true });
|
|
expect(validateDuration('veo-3-generate', 8)).toEqual({ valid: true });
|
|
});
|
|
|
|
it('should accept 5, 6, 8 for Veo 2', () => {
|
|
expect(validateDuration('veo-2-generate', 5)).toEqual({ valid: true });
|
|
expect(validateDuration('veo-2-generate', 6)).toEqual({ valid: true });
|
|
expect(validateDuration('veo-2-generate', 8)).toEqual({ valid: true });
|
|
});
|
|
|
|
it('should reject 5 for Veo 3', () => {
|
|
const result = validateDuration('veo-3.1-generate-preview', 5);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('Invalid duration');
|
|
});
|
|
|
|
it('should reject 4 for Veo 2', () => {
|
|
const result = validateDuration('veo-2-generate', 4);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('Invalid duration');
|
|
});
|
|
});
|
|
|
|
describe('validateResolution', () => {
|
|
it('should accept 720p and 1080p for Veo 3.1 16:9', () => {
|
|
expect(validateResolution('veo-3.1-generate-preview', '16:9', '720p')).toEqual({
|
|
valid: true,
|
|
});
|
|
expect(validateResolution('veo-3.1-generate-preview', '16:9', '1080p')).toEqual({
|
|
valid: true,
|
|
});
|
|
});
|
|
|
|
it('should reject 1080p for Veo 3 with 9:16 aspect ratio', () => {
|
|
const result = validateResolution('veo-3-generate', '9:16', '1080p');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('Veo 3 only supports 1080p for 16:9');
|
|
});
|
|
|
|
it('should reject 1080p for Veo 2', () => {
|
|
const result = validateResolution('veo-2-generate', '16:9', '1080p');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.message).toContain('Veo 2 only supports 720p');
|
|
});
|
|
});
|
|
|
|
describe('generateVideoCacheKey', () => {
|
|
it('should generate deterministic key', () => {
|
|
const key1 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
const key2 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
expect(key1).toBe(key2);
|
|
});
|
|
|
|
it('should generate different keys for different prompts', () => {
|
|
const key1 = generateVideoCacheKey('prompt1', 'veo-3.1', '16:9', '720p', 8);
|
|
const key2 = generateVideoCacheKey('prompt2', 'veo-3.1', '16:9', '720p', 8);
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
|
|
it('should generate different keys for different models', () => {
|
|
const key1 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
const key2 = generateVideoCacheKey('prompt', 'veo-2', '16:9', '720p', 8);
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
|
|
it('should generate different keys for different durations', () => {
|
|
const key1 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 4);
|
|
const key2 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
|
|
it('should include image data in cache key', () => {
|
|
const key1 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8, 'imageBase64');
|
|
const key2 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
|
|
it('should include negative prompt in cache key', () => {
|
|
const key1 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8, undefined, 'blur');
|
|
const key2 = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
|
|
it('should return UUID-like format', () => {
|
|
const key = generateVideoCacheKey('prompt', 'veo-3.1', '16:9', '720p', 8);
|
|
expect(key).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/);
|
|
});
|
|
});
|
|
|
|
describe('callApi', () => {
|
|
it('should return error when Google AI Studio API key is missing', async () => {
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockResolveProjectId.mockRejectedValue(new Error('No project ID found'));
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview');
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Google AI Studio');
|
|
expect(result.error).toContain('GOOGLE_API_KEY');
|
|
});
|
|
|
|
it('should return error when Vertex project ID is missing and ADC fails', async () => {
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockResolveProjectId.mockRejectedValue(new Error('No project ID found'));
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
vertexai: true,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Vertex AI');
|
|
expect(result.error).toContain('GOOGLE_CLOUD_PROJECT');
|
|
});
|
|
|
|
it('should resolve project ID from ADC when not explicitly set', async () => {
|
|
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
// ADC can resolve the project ID
|
|
mockResolveProjectId.mockResolvedValue('adc-resolved-project');
|
|
|
|
const operationName =
|
|
'projects/adc-resolved-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: { name: operationName, done: false },
|
|
});
|
|
// Mock polling - done with video
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: { videos: [{ bytesBase64Encoded: base64Video }] },
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview');
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(mockResolveProjectId).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return error when prompt is empty', async () => {
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview');
|
|
|
|
const result = await provider.callApi('');
|
|
|
|
expect(result.error).toBe('Prompt is required for video generation');
|
|
});
|
|
|
|
it('should return error for invalid aspect ratio', async () => {
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
aspectRatio: '4:3' as any,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Invalid aspect ratio');
|
|
});
|
|
|
|
it('should return error for invalid duration', async () => {
|
|
const provider = new GoogleVideoProvider('veo-2-generate', {
|
|
config: {
|
|
durationSeconds: 4,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Invalid duration');
|
|
});
|
|
|
|
it('should create video job, poll for completion, and store to blob storage', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
// Base64 encoded MP4 header (simplified for test)
|
|
const base64Video = Buffer.from('fake mp4 video data').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - not done yet
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
metadata: { progress: 50 },
|
|
},
|
|
});
|
|
|
|
// Mock polling - done with base64 video
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
'@type': 'type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse',
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
pollIntervalMs: 10,
|
|
maxPollTimeMs: 5000,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('A cat playing piano', {
|
|
evaluationId: 'eval-google-video',
|
|
promptIdx: 4,
|
|
testIdx: 3,
|
|
} as unknown as CallApiContextParams);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.cached).toBe(false);
|
|
expect(result.output).toContain('[Video:');
|
|
expect(result.output).toContain('promptfoo://blob/');
|
|
expect(result.video).toBeDefined();
|
|
expect(result.video?.format).toBe('mp4');
|
|
expect(result.video?.model).toBe('veo-3.1-generate-preview');
|
|
expect(result.video?.blobRef).toBeDefined();
|
|
expect(result.video?.blobRef?.uri).toContain('promptfoo://blob/');
|
|
expect(result.video?.url).toBe(result.video?.blobRef?.uri); // url matches blobRef.uri
|
|
// 3 requests: job creation, 2 polls (one in progress, one done)
|
|
expect(mockRequest).toHaveBeenCalledTimes(3);
|
|
// Blob storage called once
|
|
expect(mockStoreBlob).toHaveBeenCalledTimes(1);
|
|
expect(mockStoreBlob).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
'video/mp4',
|
|
expect.objectContaining({
|
|
evalId: 'eval-google-video',
|
|
kind: 'video',
|
|
promptIdx: 4,
|
|
testIdx: 3,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should create and poll Veo jobs through Google AI Studio with an API key', async () => {
|
|
mockProcessEnv({ GOOGLE_PROJECT_ID: undefined });
|
|
mockProcessEnv({ GOOGLE_API_KEY: 'test-api-key' });
|
|
|
|
const operationName = 'models/veo-3.1-generate-preview/operations/test-op';
|
|
const videoUri = 'https://generativelanguage.googleapis.com/v1beta/files/test-video';
|
|
const videoBytes = Buffer.from('fake ai studio video data');
|
|
|
|
mockFetchWithTimeout
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ name: operationName, done: false }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
generateVideoResponse: {
|
|
generatedSamples: [{ video: { uri: videoUri } }],
|
|
},
|
|
},
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(videoBytes, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'video/mp4' },
|
|
}),
|
|
);
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
pollIntervalMs: 10,
|
|
maxPollTimeMs: 5000,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('A cinematic shot of a lighthouse in a storm', {
|
|
evaluationId: 'eval-google-video-download',
|
|
promptIdx: 9,
|
|
testIdx: 10,
|
|
} as unknown as CallApiContextParams);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.video?.model).toBe('veo-3.1-generate-preview');
|
|
expect(result.video?.blobRef?.uri).toContain('promptfoo://blob/');
|
|
expect(mockStoreBlob).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
'video/mp4',
|
|
expect.objectContaining({
|
|
evalId: 'eval-google-video-download',
|
|
kind: 'video',
|
|
promptIdx: 9,
|
|
testIdx: 10,
|
|
}),
|
|
);
|
|
expect(mockResolveProjectId).not.toHaveBeenCalled();
|
|
expect(mockFetchWithTimeout).toHaveBeenCalledTimes(3);
|
|
expect(mockFetchWithTimeout).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://generativelanguage.googleapis.com/v1beta/models/veo-3.1-generate-preview:predictLongRunning',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-goog-api-key': 'test-api-key',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
expect(mockFetchWithTimeout.mock.calls[0]?.[1]?.body).toContain(
|
|
'"prompt":"A cinematic shot of a lighthouse in a storm"',
|
|
);
|
|
expect(mockFetchWithTimeout.mock.calls[0]?.[1]?.body).toContain('"durationSeconds":8');
|
|
expect(mockFetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://generativelanguage.googleapis.com/v1beta/models/veo-3.1-generate-preview/operations/test-op',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
'x-goog-api-key': 'test-api-key',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
expect(mockFetchWithTimeout).toHaveBeenNthCalledWith(
|
|
3,
|
|
videoUri,
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
'x-goog-api-key': 'test-api-key',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('should handle API error on job creation', async () => {
|
|
mockRequest.mockRejectedValueOnce({
|
|
response: {
|
|
data: {
|
|
error: { message: 'Invalid prompt' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview');
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Failed to create video job');
|
|
expect(result.error).toContain('Invalid prompt');
|
|
});
|
|
|
|
it('should handle polling timeout', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - always not done
|
|
mockRequest.mockResolvedValue({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
metadata: { progress: 10 },
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
pollIntervalMs: 10,
|
|
maxPollTimeMs: 50, // Very short timeout
|
|
},
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const resultPromise = provider.callApi('Test prompt');
|
|
|
|
await vi.runAllTimersAsync();
|
|
const result = await resultPromise;
|
|
|
|
expect(result.error).toContain('timed out');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('should handle video generation error', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - error
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
error: {
|
|
code: 400,
|
|
message: 'Content policy violation',
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Video generation failed');
|
|
expect(result.error).toContain('Content policy violation');
|
|
});
|
|
|
|
it('should handle download error for legacy URI format', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const videoUri = 'https://storage.googleapis.com/video.mp4';
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done with legacy URI format
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
generateVideoResponse: {
|
|
generatedSamples: [{ video: { uri: videoUri } }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Mock video download - error
|
|
mockRequest.mockRejectedValueOnce({
|
|
message: 'Network error',
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toContain('Download error');
|
|
});
|
|
|
|
it('should include config options in API request', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
aspectRatio: '9:16',
|
|
resolution: '720p',
|
|
durationSeconds: 4,
|
|
negativePrompt: 'blur, noise',
|
|
personGeneration: 'dont_allow',
|
|
seed: 12345,
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Test prompt');
|
|
|
|
// First call is job creation
|
|
expect(mockRequest).toHaveBeenCalled();
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].aspectRatio).toBe('9:16');
|
|
expect(body.instances[0].resolution).toBe('720p');
|
|
expect(body.instances[0].durationSeconds).toBe('4');
|
|
expect(body.instances[0].negativePrompt).toBe('blur, noise');
|
|
expect(body.instances[0].personGeneration).toBe('dont_allow');
|
|
expect(body.instances[0].seed).toBe(12345);
|
|
});
|
|
|
|
it('should include video extension in request', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation (POST)
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling (POST) - returns done with video
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
extendVideoId: 'previous-operation-id',
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Continue the video');
|
|
|
|
expect(mockRequest).toHaveBeenCalled();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.cached).toBe(false);
|
|
|
|
// Should include video extension in request
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
expect(body.instances[0].video).toEqual({ operationName: 'previous-operation-id' });
|
|
});
|
|
|
|
it('should handle blob storage deduplication', async () => {
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock deduplicated blob storage response
|
|
mockStoreBlob.mockResolvedValueOnce({
|
|
ref: {
|
|
uri: 'promptfoo://blob/existinghash123',
|
|
hash: 'existinghash123',
|
|
mimeType: 'video/mp4',
|
|
sizeBytes: 1024,
|
|
provider: 'filesystem',
|
|
},
|
|
deduplicated: true,
|
|
});
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Test prompt');
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.video?.blobRef?.hash).toBe('existinghash123');
|
|
});
|
|
});
|
|
|
|
describe('image-to-video', () => {
|
|
it('should include image data in request', async () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
|
if (path === '/path/to/image.png') {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('fake-image-data'));
|
|
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
image: 'file:///path/to/image.png',
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Animate this image');
|
|
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].image).toEqual({
|
|
imageBytes: 'ZmFrZS1pbWFnZS1kYXRh', // base64 of 'fake-image-data'
|
|
mimeType: 'image/png',
|
|
});
|
|
});
|
|
|
|
it('should return error for missing image file', async () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
image: 'file:///path/to/missing.png',
|
|
},
|
|
});
|
|
|
|
const result = await provider.callApi('Animate this image');
|
|
|
|
expect(result.error).toContain('Image file not found');
|
|
});
|
|
});
|
|
|
|
describe('reference images (Veo 3.1)', () => {
|
|
it('should include reference images in request', async () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
|
if (path === '/path/to/ref1.png' || path === '/path/to/ref2.png') {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('ref-image-data'));
|
|
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done with base64 video
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
referenceImages: [
|
|
{ image: 'file:///path/to/ref1.png', referenceType: 'asset' },
|
|
{ image: 'file:///path/to/ref2.png', referenceType: 'asset' },
|
|
],
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Generate with references');
|
|
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].referenceImages).toHaveLength(2);
|
|
expect(body.instances[0].referenceImages[0].referenceType).toBe('asset');
|
|
});
|
|
|
|
it('should limit reference images to 3', async () => {
|
|
vi.mocked(fs.existsSync).mockReset();
|
|
// Return true only for the reference image file paths
|
|
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
|
const pathStr = path.toString();
|
|
if (pathStr.includes('/path/to/ref')) {
|
|
return true; // Reference image files exist
|
|
}
|
|
return false;
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('ref-image-data'));
|
|
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done with base64 video
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
referenceImages: [
|
|
{ image: 'file:///path/to/ref1.png', referenceType: 'asset' },
|
|
{ image: 'file:///path/to/ref2.png', referenceType: 'asset' },
|
|
{ image: 'file:///path/to/ref3.png', referenceType: 'asset' },
|
|
{ image: 'file:///path/to/ref4.png', referenceType: 'asset' }, // Should be ignored
|
|
],
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Generate with references');
|
|
|
|
expect(mockRequest).toHaveBeenCalled();
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].referenceImages).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('interpolation (Veo 3.1)', () => {
|
|
it('should include last frame in request', async () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
|
if (path === '/path/to/first.png' || path === '/path/to/last.png') {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('frame-data'));
|
|
|
|
const operationName =
|
|
'projects/test-project/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
// Mock job creation
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: false,
|
|
},
|
|
});
|
|
|
|
// Mock polling - done with base64 video
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: base64Video }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
image: 'file:///path/to/first.png',
|
|
lastFrame: 'file:///path/to/last.png',
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Interpolate between frames');
|
|
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].image).toBeDefined();
|
|
expect(body.instances[0].lastFrame).toBeDefined();
|
|
});
|
|
|
|
it('should support lastImage alias for interpolation', async () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
|
if (path === '/path/to/first.png' || path === '/path/to/last.png') {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('frame-data'));
|
|
|
|
const operationName = 'test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: { name: operationName, done: false },
|
|
});
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: { videos: [{ bytesBase64Encoded: base64Video }] },
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
image: 'file:///path/to/first.png',
|
|
lastImage: 'file:///path/to/last.png',
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Interpolate');
|
|
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].lastFrame).toBeDefined();
|
|
});
|
|
|
|
it('should support sourceVideo alias for extension', async () => {
|
|
const operationName = 'test-op';
|
|
const base64Video = Buffer.from('fake video').toString('base64');
|
|
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: { name: operationName, done: false },
|
|
});
|
|
mockRequest.mockResolvedValueOnce({
|
|
data: {
|
|
name: operationName,
|
|
done: true,
|
|
response: { videos: [{ bytesBase64Encoded: base64Video }] },
|
|
},
|
|
});
|
|
|
|
const provider = new GoogleVideoProvider('veo-3.1-generate-preview', {
|
|
config: {
|
|
sourceVideo: 'previous-op-id',
|
|
pollIntervalMs: 10,
|
|
},
|
|
});
|
|
|
|
await provider.callApi('Extend');
|
|
|
|
const firstCallOptions = mockRequest.mock.calls[0][0];
|
|
const body = JSON.parse(firstCallOptions.body);
|
|
|
|
expect(body.instances[0].video).toEqual({ operationName: 'previous-op-id' });
|
|
});
|
|
});
|
|
});
|