UNPKG

lightweight-browser-load-tester

Version:

A lightweight load testing tool using real browsers for streaming applications with DRM support

485 lines (376 loc) 15.1 kB
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { PrometheusExporter } from './prometheus-exporter'; import { PrometheusConfiguration, BrowserMetrics, DRMMetrics, NetworkMetrics, ErrorLog, TestSummary } from '../types/index'; // Mock fetch globally global.fetch = vi.fn(); describe('PrometheusExporter', () => { let exporter: PrometheusExporter; let mockConfig: PrometheusConfiguration; let mockBrowserMetrics: BrowserMetrics; let mockDRMMetrics: DRMMetrics; let mockNetworkMetrics: NetworkMetrics[]; let mockErrorLog: ErrorLog; let mockTestSummary: TestSummary; beforeEach(() => { mockConfig = { enabled: true, remoteWriteUrl: 'https://prometheus.example.com/api/v1/write', username: 'test-user', password: 'test-password', batchSize: 10, flushInterval: 5, timeout: 3000, retryAttempts: 2, retryDelay: 500, headers: { 'X-Custom-Header': 'test-value' } }; mockBrowserMetrics = { instanceId: 'browser-1', memoryUsage: 150.5, cpuUsage: 25.3, requestCount: 100, errorCount: 2, uptime: 300 }; mockDRMMetrics = { licenseRequestCount: 50, averageLicenseTime: 250.5, licenseSuccessRate: 0.96, drmType: 'widevine', errors: [] }; mockNetworkMetrics = [ { url: 'https://example.com/api/test', method: 'GET', responseTime: 150, statusCode: 200, timestamp: new Date('2024-01-01T10:00:00Z'), requestSize: 1024, responseSize: 2048, isStreamingRelated: true, streamingType: 'manifest' }, { url: 'https://example.com/api/fail', method: 'POST', responseTime: 500, statusCode: 500, timestamp: new Date('2024-01-01T10:01:00Z'), requestSize: 512, responseSize: 256, isStreamingRelated: false } ]; mockErrorLog = { timestamp: new Date('2024-01-01T10:00:30Z'), level: 'error', message: 'Test error message', stack: 'Error stack trace', context: { userId: 'test-user' } }; mockTestSummary = { totalRequests: 100, successfulRequests: 95, failedRequests: 5, averageResponseTime: 200.5, peakConcurrentUsers: 10, testDuration: 300 }; // Reset mocks vi.clearAllMocks(); vi.mocked(fetch).mockResolvedValue(new Response('', { status: 200 })); }); afterEach(async () => { if (exporter) { await exporter.shutdown(); } vi.restoreAllMocks(); }); describe('constructor', () => { it('should initialize with provided configuration', () => { exporter = new PrometheusExporter(mockConfig); const config = exporter.getConfig(); expect(config.enabled).toBe(true); expect(config.remoteWriteUrl).toBe(mockConfig.remoteWriteUrl); expect(config.batchSize).toBe(10); expect(config.flushInterval).toBe(5); }); it('should apply default values for optional configuration', () => { const minimalConfig: PrometheusConfiguration = { enabled: true, remoteWriteUrl: 'https://prometheus.example.com/api/v1/write' }; exporter = new PrometheusExporter(minimalConfig); const config = exporter.getConfig(); expect(config.batchSize).toBe(100); expect(config.flushInterval).toBe(10); expect(config.timeout).toBe(5000); expect(config.retryAttempts).toBe(3); expect(config.retryDelay).toBe(1000); }); it('should not start flush timer when disabled', () => { const disabledConfig = { ...mockConfig, enabled: false }; exporter = new PrometheusExporter(disabledConfig); expect(exporter.getBufferSize()).toBe(0); }); }); describe('exportBrowserMetrics', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should export browser metrics when enabled', async () => { await exporter.exportBrowserMetrics(mockBrowserMetrics); expect(exporter.getBufferSize()).toBeGreaterThan(0); }); it('should not export metrics when disabled', async () => { const disabledConfig = { ...mockConfig, enabled: false }; exporter = new PrometheusExporter(disabledConfig); await exporter.exportBrowserMetrics(mockBrowserMetrics); expect(exporter.getBufferSize()).toBe(0); }); it('should include correct metric names and labels', async () => { await exporter.exportBrowserMetrics(mockBrowserMetrics); // Check that metrics were added to buffer expect(exporter.getBufferSize()).toBeGreaterThan(0); }); }); describe('exportDRMMetrics', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should export DRM metrics when enabled', async () => { await exporter.exportDRMMetrics(mockDRMMetrics); expect(exporter.getBufferSize()).toBeGreaterThan(0); }); it('should not export metrics when disabled', async () => { const disabledConfig = { ...mockConfig, enabled: false }; exporter = new PrometheusExporter(disabledConfig); await exporter.exportDRMMetrics(mockDRMMetrics); expect(exporter.getBufferSize()).toBe(0); }); }); describe('exportNetworkMetrics', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should export network metrics when enabled', async () => { await exporter.exportNetworkMetrics(mockNetworkMetrics); expect(exporter.getBufferSize()).toBeGreaterThan(0); }); it('should handle empty network metrics array', async () => { await exporter.exportNetworkMetrics([]); expect(exporter.getBufferSize()).toBe(0); }); it('should not export metrics when disabled', async () => { const disabledConfig = { ...mockConfig, enabled: false }; exporter = new PrometheusExporter(disabledConfig); await exporter.exportNetworkMetrics(mockNetworkMetrics); expect(exporter.getBufferSize()).toBe(0); }); }); describe('exportTestSummary', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should export test summary metrics when enabled', async () => { await exporter.exportTestSummary(mockTestSummary); expect(exporter.getBufferSize()).toBeGreaterThan(0); }); it('should not export metrics when disabled', async () => { const disabledConfig = { ...mockConfig, enabled: false }; exporter = new PrometheusExporter(disabledConfig); await exporter.exportTestSummary(mockTestSummary); expect(exporter.getBufferSize()).toBe(0); }); }); describe('exportErrorMetrics', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should export error metrics when enabled', async () => { await exporter.exportErrorMetrics([mockErrorLog]); expect(exporter.getBufferSize()).toBeGreaterThan(0); }); it('should handle empty error array', async () => { await exporter.exportErrorMetrics([]); expect(exporter.getBufferSize()).toBe(0); }); it('should aggregate errors by level', async () => { const errors = [ { ...mockErrorLog, level: 'error' as const }, { ...mockErrorLog, level: 'error' as const }, { ...mockErrorLog, level: 'warning' as const } ]; await exporter.exportErrorMetrics(errors); expect(exporter.getBufferSize()).toBeGreaterThan(0); }); }); describe('flush', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should send metrics to Prometheus endpoint', async () => { await exporter.exportBrowserMetrics(mockBrowserMetrics); await exporter.flush(); expect(fetch).toHaveBeenCalledWith( mockConfig.remoteWriteUrl, expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json', 'Authorization': expect.stringContaining('Basic'), 'X-Custom-Header': 'test-value' }) }) ); }); it('should clear buffer after successful flush', async () => { await exporter.exportBrowserMetrics(mockBrowserMetrics); expect(exporter.getBufferSize()).toBeGreaterThan(0); await exporter.flush(); expect(exporter.getBufferSize()).toBe(0); }); it('should handle empty buffer', async () => { await exporter.flush(); expect(fetch).not.toHaveBeenCalled(); }); it('should not flush when disabled', async () => { const disabledConfig = { ...mockConfig, enabled: false }; exporter = new PrometheusExporter(disabledConfig); await exporter.flush(); expect(fetch).not.toHaveBeenCalled(); }); }); describe('HTTP request handling', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should include authentication headers when credentials provided', async () => { await exporter.exportBrowserMetrics(mockBrowserMetrics); await exporter.flush(); const expectedAuth = Buffer.from(`${mockConfig.username}:${mockConfig.password}`).toString('base64'); expect(fetch).toHaveBeenCalledWith( mockConfig.remoteWriteUrl, expect.objectContaining({ headers: expect.objectContaining({ 'Authorization': `Basic ${expectedAuth}` }) }) ); }); it('should not include authentication when credentials not provided', async () => { const configWithoutAuth = { ...mockConfig }; delete configWithoutAuth.username; delete configWithoutAuth.password; exporter = new PrometheusExporter(configWithoutAuth); await exporter.exportBrowserMetrics(mockBrowserMetrics); await exporter.flush(); const fetchCall = vi.mocked(fetch).mock.calls[0]; const headers = fetchCall[1]?.headers as Record<string, string>; expect(headers['Authorization']).toBeUndefined(); }); it('should handle HTTP errors with retry', async () => { vi.mocked(fetch) .mockRejectedValueOnce(new Error('Network error')) .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce(new Response('', { status: 200 })); await exporter.exportBrowserMetrics(mockBrowserMetrics); await exporter.flush(); expect(fetch).toHaveBeenCalledTimes(3); }); it('should throw error after max retries exceeded', async () => { vi.mocked(fetch).mockRejectedValue(new Error('Network error')); await exporter.exportBrowserMetrics(mockBrowserMetrics); await expect(exporter.flush()).rejects.toThrow('Failed to send metrics to Prometheus after 3 attempts'); expect(fetch).toHaveBeenCalledTimes(3); // Initial + 2 retries }); it('should handle HTTP error responses', async () => { vi.mocked(fetch).mockResolvedValue( new Response('Bad Request', { status: 400, statusText: 'Bad Request' }) ); await exporter.exportBrowserMetrics(mockBrowserMetrics); await expect(exporter.flush()).rejects.toThrow(); }); it('should handle timeout errors', async () => { vi.mocked(fetch).mockRejectedValue(Object.assign(new Error('Timeout'), { name: 'AbortError' })); await exporter.exportBrowserMetrics(mockBrowserMetrics); await expect(exporter.flush()).rejects.toThrow('Request timeout after 3000ms'); }); }); describe('batching and automatic flush', () => { it('should automatically flush when batch size is reached', async () => { const smallBatchConfig = { ...mockConfig, batchSize: 2 }; exporter = new PrometheusExporter(smallBatchConfig); const flushSpy = vi.spyOn(exporter, 'flush').mockResolvedValue(); // Add metrics to exceed batch size await exporter.exportBrowserMetrics(mockBrowserMetrics); await exporter.exportDRMMetrics(mockDRMMetrics); // Wait a bit for async flush to complete await new Promise(resolve => setTimeout(resolve, 10)); expect(flushSpy).toHaveBeenCalled(); }); it('should flush periodically based on flush interval', async () => { const quickFlushConfig = { ...mockConfig, flushInterval: 0.1 }; // 100ms exporter = new PrometheusExporter(quickFlushConfig); const flushSpy = vi.spyOn(exporter, 'flush').mockResolvedValue(); await exporter.exportBrowserMetrics(mockBrowserMetrics); // Wait for flush interval await new Promise(resolve => setTimeout(resolve, 150)); expect(flushSpy).toHaveBeenCalled(); }); }); describe('shutdown', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should flush remaining metrics on shutdown', async () => { await exporter.exportBrowserMetrics(mockBrowserMetrics); expect(exporter.getBufferSize()).toBeGreaterThan(0); await exporter.shutdown(); expect(fetch).toHaveBeenCalled(); expect(exporter.getBufferSize()).toBe(0); }); it('should stop periodic flush timer on shutdown', async () => { const flushSpy = vi.spyOn(exporter, 'flush').mockResolvedValue(); await exporter.shutdown(); // Wait longer than flush interval to ensure timer is stopped await new Promise(resolve => setTimeout(resolve, 100)); // Should only be called once during shutdown, not periodically expect(flushSpy).toHaveBeenCalledTimes(1); }); }); describe('metric format conversion', () => { beforeEach(() => { exporter = new PrometheusExporter(mockConfig); }); it('should convert metrics to correct Prometheus format', async () => { let capturedPayload: any = null; vi.mocked(fetch).mockImplementation(async (url, options) => { if (options?.body) { capturedPayload = JSON.parse(options.body.toString()); } return new Response('', { status: 200 }); }); await exporter.exportBrowserMetrics(mockBrowserMetrics); await exporter.flush(); expect(capturedPayload).toHaveProperty('timeseries'); expect(Array.isArray(capturedPayload.timeseries)).toBe(true); const firstTimeseries = capturedPayload.timeseries[0]; expect(firstTimeseries).toHaveProperty('labels'); expect(firstTimeseries).toHaveProperty('samples'); const nameLabel = firstTimeseries.labels.find((l: any) => l.name === '__name__'); expect(nameLabel).toBeDefined(); expect(nameLabel.value).toMatch(/^browser_/); }); }); });