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
text/typescript
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_/);
});
});
});