/** * Common Test Utilities * General-purpose helper functions for testing */ import { expect, vi } from 'vitest' import type { ProviderId } from '../mocks/providers' import { createMockImageModel, createMockLanguageModel, mockProviderConfigs } from '../mocks/providers' /** * Creates a test provider with streaming support */ export function createTestStreamingProvider(chunks: any[]) { return createMockLanguageModel({ doStream: vi.fn().mockReturnValue({ stream: (async function* () { for (const chunk of chunks) { yield chunk } })(), rawCall: { rawPrompt: null, rawSettings: {} }, rawResponse: { headers: {} }, warnings: [] }) }) } /** * Creates a test provider that throws errors */ export function createErrorProvider(error: Error) { return createMockLanguageModel({ doGenerate: vi.fn().mockRejectedValue(error), doStream: vi.fn().mockImplementation(() => { throw error }) }) } /** * Collects all chunks from a stream */ export async function collectStreamChunks(stream: AsyncIterable): Promise { const chunks: T[] = [] for await (const chunk of stream) { chunks.push(chunk) } return chunks } /** * Waits for a specific number of milliseconds */ export function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Creates a mock abort controller that aborts after a delay */ export function createDelayedAbortController(delayMs: number): AbortController { const controller = new AbortController() setTimeout(() => controller.abort(), delayMs) return controller } /** * Asserts that a function throws an error with a specific message */ export async function expectError(fn: () => Promise, expectedMessage?: string | RegExp): Promise { try { await fn() throw new Error('Expected function to throw an error, but it did not') } catch (error) { if (expectedMessage) { const message = (error as Error).message if (typeof expectedMessage === 'string') { if (!message.includes(expectedMessage)) { throw new Error(`Expected error message to include "${expectedMessage}", but got "${message}"`) } } else { if (!expectedMessage.test(message)) { throw new Error(`Expected error message to match ${expectedMessage}, but got "${message}"`) } } } return error as Error } } /** * Creates a spy function that tracks calls and arguments */ export function createSpy any>() { const calls: Array<{ args: Parameters; result?: ReturnType; error?: Error }> = [] const spy = vi.fn((...args: Parameters) => { try { const result = undefined as ReturnType calls.push({ args, result }) return result } catch (error) { calls.push({ args, error: error as Error }) throw error } }) return { fn: spy, calls, getCalls: () => calls, getCallCount: () => calls.length, getLastCall: () => calls[calls.length - 1], reset: () => { calls.length = 0 spy.mockClear() } } } /** * Validates provider configuration */ export function validateProviderConfig(providerId: ProviderId) { const config = mockProviderConfigs[providerId] if (!config) { throw new Error(`No mock configuration found for provider: ${providerId}`) } if (!config.apiKey) { throw new Error(`Provider ${providerId} is missing apiKey in mock config`) } return config } /** * Creates a test context with common setup */ export function createTestContext() { const mocks = { languageModel: createMockLanguageModel(), imageModel: createMockImageModel(), providers: new Map() } const cleanup = () => { mocks.providers.clear() vi.clearAllMocks() } return { mocks, cleanup } } /** * Measures execution time of an async function */ export async function measureTime(fn: () => Promise): Promise<{ result: T; duration: number }> { const start = Date.now() const result = await fn() const duration = Date.now() - start return { result, duration } } /** * Retries a function until it succeeds or max attempts reached */ export async function retryUntilSuccess(fn: () => Promise, maxAttempts = 3, delayMs = 100): Promise { let lastError: Error | undefined for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn() } catch (error) { lastError = error as Error if (attempt < maxAttempts) { await wait(delayMs) } } } throw lastError || new Error('All retry attempts failed') } /** * Creates a mock streaming response that emits chunks at intervals */ export function createTimedStream(chunks: T[], intervalMs = 10) { return { async *[Symbol.asyncIterator]() { for (const chunk of chunks) { await wait(intervalMs) yield chunk } } } } /** * Asserts that two objects are deeply equal, ignoring specified keys */ export function assertDeepEqualIgnoring>( actual: T, expected: T, ignoreKeys: string[] = [] ): void { const filterKeys = (obj: T): Partial => { const filtered = { ...obj } for (const key of ignoreKeys) { delete filtered[key] } return filtered } const filteredActual = filterKeys(actual) const filteredExpected = filterKeys(expected) expect(filteredActual).toEqual(filteredExpected) } /** * Creates a provider mock that simulates rate limiting */ export function createRateLimitedProvider(limitPerSecond: number) { const calls: number[] = [] return createMockLanguageModel({ doGenerate: vi.fn().mockImplementation(async () => { const now = Date.now() calls.push(now) // Remove calls older than 1 second const recentCalls = calls.filter((time) => now - time < 1000) if (recentCalls.length > limitPerSecond) { throw new Error('Rate limit exceeded') } return { text: 'Rate limited response', finishReason: 'stop' as const, usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, rawCall: { rawPrompt: null, rawSettings: {} }, rawResponse: { headers: {} }, warnings: [] } }) }) } /** * Validates streaming response structure */ export function validateStreamChunk(chunk: any): void { expect(chunk).toBeDefined() expect(chunk).toHaveProperty('type') if (chunk.type === 'text-delta') { expect(chunk).toHaveProperty('textDelta') expect(typeof chunk.textDelta).toBe('string') } else if (chunk.type === 'finish') { expect(chunk).toHaveProperty('finishReason') expect(chunk).toHaveProperty('usage') } else if (chunk.type === 'tool-call') { expect(chunk).toHaveProperty('toolCallId') expect(chunk).toHaveProperty('toolName') expect(chunk).toHaveProperty('args') } } /** * Creates a test logger that captures log messages */ export function createTestLogger() { const logs: Array<{ level: string; message: string; meta?: any }> = [] return { info: (message: string, meta?: any) => logs.push({ level: 'info', message, meta }), warn: (message: string, meta?: any) => logs.push({ level: 'warn', message, meta }), error: (message: string, meta?: any) => logs.push({ level: 'error', message, meta }), debug: (message: string, meta?: any) => logs.push({ level: 'debug', message, meta }), getLogs: () => logs, clear: () => { logs.length = 0 } } }