UNPKG

lightweight-browser-load-tester

Version:

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

891 lines (777 loc) 27.3 kB
import { EventEmitter } from 'events'; import { BrowserPool } from '../managers/browser-pool'; import { RequestInterceptor } from '../interceptors/request-interceptor'; import { TestConfiguration, TestResults, TestSummary, NetworkMetrics, ErrorLog, ManagedBrowserInstance, BrowserPoolConfig, DRMMetrics } from '../types'; /** * Test session represents a single user session */ export interface TestSession { id: string; browserInstance: ManagedBrowserInstance; interceptor: RequestInterceptor; startTime: Date; endTime?: Date; status: 'starting' | 'running' | 'stopping' | 'completed' | 'failed'; errors: ErrorLog[]; } /** * Real-time test monitoring data */ export interface TestMonitoringData { activeSessions: number; completedSessions: number; failedSessions: number; totalRequests: number; successfulRequests: number; failedRequests: number; averageResponseTime: number; currentRps: number; // requests per second elapsedTime: number; // seconds remainingTime: number; // seconds memoryUsage: number; // MB cpuUsage: number; // percentage resourceUtilization: ResourceUtilizationData; } /** * Resource utilization and performance data */ export interface ResourceUtilizationData { memoryUtilization: number; // percentage cpuUtilization: number; // percentage instancesNearMemoryLimit: number; instancesNearCpuLimit: number; totalInstances: number; activeInstances: number; resourceAlerts: ResourceAlert[]; } /** * Resource alert information */ export interface ResourceAlert { type: 'memory' | 'cpu' | 'instance_limit' | 'performance'; severity: 'warning' | 'critical'; message: string; timestamp: Date; instanceId?: string; value?: number; limit?: number; } /** * Test execution events */ export interface TestRunnerEvents { 'test-started': { testId: string; config: TestConfiguration }; 'test-completed': { testId: string; results: TestResults }; 'test-failed': { testId: string; error: Error }; 'session-started': { sessionId: string; testId: string }; 'session-completed': { sessionId: string; testId: string }; 'session-failed': { sessionId: string; testId: string; error: Error }; 'monitoring-update': { testId: string; data: TestMonitoringData }; 'ramp-up-completed': { testId: string }; } /** * TestRunner orchestrates browser instances and executes load tests */ export class TestRunner extends EventEmitter { private testId: string; private config: TestConfiguration; private browserPool: BrowserPool; private sessions: Map<string, TestSession> = new Map(); private isRunning = false; private startTime?: Date; private endTime?: Date; private monitoringInterval?: NodeJS.Timeout; private rampUpInterval?: NodeJS.Timeout; private testTimeout?: NodeJS.Timeout; private shutdownPromise?: Promise<void>; constructor(config: TestConfiguration) { super(); this.testId = `test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; this.config = config; // Create browser pool configuration const poolConfig: BrowserPoolConfig = { maxInstances: config.resourceLimits.maxConcurrentInstances, minInstances: Math.min(2, config.concurrentUsers), resourceLimits: config.resourceLimits, localStorage: config.localStorage, browserOptions: { headless: true, args: [ '--disable-web-security', '--disable-features=VizDisplayCompositor', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-client-side-phishing-detection', '--disable-default-apps', '--disable-hang-monitor', '--disable-popup-blocking', '--disable-prompt-on-repost', '--disable-sync', '--metrics-recording-only', '--no-first-run', '--safebrowsing-disable-auto-update', '--enable-automation', '--password-store=basic', '--use-mock-keychain' ] } }; this.browserPool = new BrowserPool(poolConfig); this.setupBrowserPoolEvents(); } /** * Start the load test */ async startTest(): Promise<void> { if (this.isRunning) { throw new Error('Test is already running'); } try { this.isRunning = true; this.startTime = new Date(); // Initialize browser pool await this.browserPool.initialize(); // Start monitoring this.startMonitoring(); // Set up test timeout this.setupTestTimeout(); // Start ramp-up process await this.startRampUp(); this.emit('test-started', { testId: this.testId, config: this.config }); } catch (error) { this.isRunning = false; const testError = error instanceof Error ? error : new Error(String(error)); this.emit('test-failed', { testId: this.testId, error: testError }); throw testError; } } /** * Stop the load test gracefully */ async stopTest(): Promise<TestResults> { if (!this.isRunning && !this.shutdownPromise) { throw new Error('No test is currently running'); } // If shutdown is already in progress, wait for it to complete if (this.shutdownPromise) { await this.shutdownPromise; return this.generateResults(); } // If test is still running, initiate shutdown if (this.isRunning) { this.shutdownPromise = this.performShutdown(); await this.shutdownPromise; // Clear the shutdown promise after completion so subsequent calls will throw this.shutdownPromise = undefined; } return this.generateResults(); } /** * Get current test monitoring data */ getMonitoringData(): TestMonitoringData { const now = Date.now(); const elapsedTime = this.startTime ? (now - this.startTime.getTime()) / 1000 : 0; const remainingTime = Math.max(0, this.config.testDuration - elapsedTime); const activeSessions = Array.from(this.sessions.values()).filter(s => s.status === 'running').length; const completedSessions = Array.from(this.sessions.values()).filter(s => s.status === 'completed').length; const failedSessions = Array.from(this.sessions.values()).filter(s => s.status === 'failed').length; // Aggregate network metrics const allNetworkMetrics = this.getAllNetworkMetrics(); const totalRequests = allNetworkMetrics.length; const successfulRequests = allNetworkMetrics.filter(m => m.statusCode >= 200 && m.statusCode < 400).length; const failedRequests = totalRequests - successfulRequests; const averageResponseTime = totalRequests > 0 ? allNetworkMetrics.reduce((sum, m) => sum + m.responseTime, 0) / totalRequests : 0; // Calculate current RPS (requests in last 10 seconds) const tenSecondsAgo = now - 10000; const recentRequests = allNetworkMetrics.filter(m => m.timestamp.getTime() > tenSecondsAgo); const currentRps = recentRequests.length / 10; // Get resource usage const browserMetrics = this.browserPool.getMetrics(); const memoryUsage = browserMetrics.reduce((sum, m) => sum + m.memoryUsage, 0); const cpuUsage = browserMetrics.length > 0 ? browserMetrics.reduce((sum, m) => sum + m.cpuUsage, 0) / browserMetrics.length : 0; // Get detailed resource utilization data const resourceUtilization = this.getResourceUtilizationData(); return { activeSessions, completedSessions, failedSessions, totalRequests, successfulRequests, failedRequests, averageResponseTime, currentRps, elapsedTime, remainingTime, memoryUsage, cpuUsage, resourceUtilization }; } /** * Get detailed resource utilization data with alerts */ private getResourceUtilizationData(): ResourceUtilizationData { const resourceStats = this.browserPool.getResourceUsageStats(); const alerts = this.generateResourceAlerts(resourceStats); return { memoryUtilization: resourceStats.memoryUtilization, cpuUtilization: resourceStats.cpuUtilization, instancesNearMemoryLimit: resourceStats.instancesNearMemoryLimit, instancesNearCpuLimit: resourceStats.instancesNearCpuLimit, totalInstances: resourceStats.totalInstances, activeInstances: resourceStats.activeInstances, resourceAlerts: alerts }; } /** * Generate resource alerts based on current usage */ private generateResourceAlerts(resourceStats: any): ResourceAlert[] { const alerts: ResourceAlert[] = []; const now = new Date(); // Memory utilization alerts if (resourceStats.memoryUtilization > 90) { alerts.push({ type: 'memory', severity: 'critical', message: `Critical memory utilization: ${resourceStats.memoryUtilization.toFixed(1)}%`, timestamp: now, value: resourceStats.memoryUtilization, limit: 90 }); } else if (resourceStats.memoryUtilization > 80) { alerts.push({ type: 'memory', severity: 'warning', message: `High memory utilization: ${resourceStats.memoryUtilization.toFixed(1)}%`, timestamp: now, value: resourceStats.memoryUtilization, limit: 80 }); } // CPU utilization alerts if (resourceStats.cpuUtilization > 90) { alerts.push({ type: 'cpu', severity: 'critical', message: `Critical CPU utilization: ${resourceStats.cpuUtilization.toFixed(1)}%`, timestamp: now, value: resourceStats.cpuUtilization, limit: 90 }); } else if (resourceStats.cpuUtilization > 80) { alerts.push({ type: 'cpu', severity: 'warning', message: `High CPU utilization: ${resourceStats.cpuUtilization.toFixed(1)}%`, timestamp: now, value: resourceStats.cpuUtilization, limit: 80 }); } // Instance limit alerts const instanceUtilization = (resourceStats.totalInstances / this.config.resourceLimits.maxConcurrentInstances) * 100; if (instanceUtilization > 90) { alerts.push({ type: 'instance_limit', severity: 'critical', message: `Near maximum instance limit: ${resourceStats.totalInstances}/${this.config.resourceLimits.maxConcurrentInstances}`, timestamp: now, value: resourceStats.totalInstances, limit: this.config.resourceLimits.maxConcurrentInstances }); } else if (instanceUtilization > 80) { alerts.push({ type: 'instance_limit', severity: 'warning', message: `High instance usage: ${resourceStats.totalInstances}/${this.config.resourceLimits.maxConcurrentInstances}`, timestamp: now, value: resourceStats.totalInstances, limit: this.config.resourceLimits.maxConcurrentInstances }); } // Performance alerts based on response times const recentMetrics = this.getAllNetworkMetrics().filter(m => (Date.now() - m.timestamp.getTime()) < 30000 // Last 30 seconds ); if (recentMetrics.length > 0) { const avgResponseTime = recentMetrics.reduce((sum, m) => sum + m.responseTime, 0) / recentMetrics.length; if (avgResponseTime > 5000) { alerts.push({ type: 'performance', severity: 'critical', message: `Very slow response times: ${avgResponseTime.toFixed(0)}ms average`, timestamp: now, value: avgResponseTime, limit: 5000 }); } else if (avgResponseTime > 2000) { alerts.push({ type: 'performance', severity: 'warning', message: `Slow response times: ${avgResponseTime.toFixed(0)}ms average`, timestamp: now, value: avgResponseTime, limit: 2000 }); } } return alerts; } /** * Force cleanup of idle browser instances to free resources */ async cleanupIdleInstances(maxIdleTime: number = 300000): Promise<number> { return await this.browserPool.cleanupIdleInstances(maxIdleTime); } /** * Get detailed resource usage statistics */ getResourceUsageStats() { return this.browserPool.getResourceUsageStats(); } /** * Get test ID */ getTestId(): string { return this.testId; } /** * Check if test is currently running */ isTestRunning(): boolean { return this.isRunning; } /** * Set up browser pool event listeners */ private setupBrowserPoolEvents(): void { this.browserPool.on('instanceCreationFailed', ({ error }) => { this.logError('Browser instance creation failed', error); }); this.browserPool.on('instanceDisconnected', ({ instanceId }) => { this.handleBrowserDisconnect(instanceId); }); this.browserPool.on('resourceLimitExceeded', ({ instanceId, type, usage, limit }) => { this.logError(`Resource limit exceeded for instance ${instanceId}`, null, { type, usage, limit }); }); // Error recovery events this.browserPool.on('circuitBreakerOpened', ({ instanceId }) => { this.logError(`Circuit breaker opened for instance ${instanceId}`, null, { instanceId, event: 'circuit-breaker-opened' }); }); this.browserPool.on('circuitBreakerClosed', ({ instanceId }) => { this.logError(`Circuit breaker closed for instance ${instanceId}`, null, { instanceId, event: 'circuit-breaker-closed', level: 'info' }); }); this.browserPool.on('instanceBlacklisted', ({ instanceId, reason }) => { this.logError(`Browser instance blacklisted: ${reason}`, null, { instanceId, reason, event: 'instance-blacklisted' }); }); this.browserPool.on('instanceRestarted', ({ originalInstanceId, newInstanceId }) => { this.logError(`Browser instance restarted successfully`, null, { originalInstanceId, newInstanceId, event: 'instance-restarted', level: 'info' }); }); this.browserPool.on('instanceRestartFailed', ({ instanceId, originalError, restartError }) => { this.logError(`Browser instance restart failed`, restartError, { instanceId, originalError: originalError.message, event: 'instance-restart-failed' }); }); this.browserPool.on('errorLogged', (errorLog) => { // Forward error logs from browser pool this.emit('error-logged', errorLog); }); // Resource management events this.browserPool.on('resourceLimitWarning', ({ instanceId, type, usage, limit, isActive }) => { this.logError(`Resource limit warning for ${isActive ? 'active' : 'idle'} instance ${instanceId}`, null, { instanceId, type, usage, limit, isActive, event: 'resource-limit-warning', level: 'warning' }); }); this.browserPool.on('instanceCleanedForResourceLimit', ({ instanceId }) => { this.logError(`Instance cleaned due to resource limits`, null, { instanceId, event: 'instance-cleaned-resource-limit', level: 'info' }); }); this.browserPool.on('instanceCleanedForIdle', ({ instanceId }) => { this.logError(`Idle instance cleaned up`, null, { instanceId, event: 'instance-cleaned-idle', level: 'info' }); }); } /** * Start the ramp-up process */ private async startRampUp(): Promise<void> { const rampUpIntervalMs = (this.config.rampUpTime * 1000) / this.config.concurrentUsers; let startedSessions = 0; return new Promise((resolve, _reject) => { const startNextSession = async () => { try { if (startedSessions >= this.config.concurrentUsers || !this.isRunning) { if (this.rampUpInterval) { clearInterval(this.rampUpInterval); } this.emit('ramp-up-completed', { testId: this.testId }); resolve(); return; } await this.startSession(); startedSessions++; } catch (error) { this.logError('Failed to start session during ramp-up', error); } }; // Start first session immediately startNextSession(); // Start remaining sessions with interval if (this.config.concurrentUsers > 1) { this.rampUpInterval = setInterval(startNextSession, rampUpIntervalMs); } }); } /** * Start a single test session */ private async startSession(): Promise<TestSession> { const sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; try { // Acquire browser instance const browserInstance = await this.browserPool.acquireInstance(); // Create request interceptor const interceptor = new RequestInterceptor( browserInstance.page, this.config.requestParameters, { sessionId }, this.config.streamingOnly || false, this.config.allowedUrls || [], this.config.blockedUrls || [] ); // Create session const session: TestSession = { id: sessionId, browserInstance, interceptor, startTime: new Date(), status: 'starting', errors: [] }; this.sessions.set(sessionId, session); // Start request interception await interceptor.startInterception(); // Navigate to streaming URL session.status = 'running'; interceptor.startStreamingMonitoring(); await browserInstance.page.goto(this.config.streamingUrl, { waitUntil: 'networkidle', timeout: 30000 }); this.emit('session-started', { sessionId, testId: this.testId }); // Set up session completion handling this.scheduleSessionCompletion(session); return session; } catch (error) { const session = this.sessions.get(sessionId); if (session) { session.status = 'failed'; session.endTime = new Date(); session.errors.push({ timestamp: new Date(), level: 'error', message: 'Session startup failed', stack: error instanceof Error ? error.stack : undefined, context: { sessionId } }); } this.emit('session-failed', { sessionId, testId: this.testId, error: error instanceof Error ? error : new Error(String(error)) }); throw error; } } /** * Schedule session completion based on test duration */ private scheduleSessionCompletion(session: TestSession): void { const remainingTime = this.config.testDuration * 1000 - (Date.now() - this.startTime!.getTime()); if (remainingTime > 0) { setTimeout(async () => { await this.completeSession(session.id); }, remainingTime); } } /** * Complete a test session */ private async completeSession(sessionId: string): Promise<void> { const session = this.sessions.get(sessionId); if (!session || session.status === 'completed' || session.status === 'failed') { return; } try { session.status = 'stopping'; // Stop request interception await session.interceptor.stopInterception(); // Release browser instance await this.browserPool.releaseInstance(session.browserInstance.id); session.status = 'completed'; session.endTime = new Date(); this.emit('session-completed', { sessionId, testId: this.testId }); } catch (error) { session.status = 'failed'; session.endTime = new Date(); session.errors.push({ timestamp: new Date(), level: 'error', message: 'Session completion failed', stack: error instanceof Error ? error.stack : undefined, context: { sessionId } }); this.emit('session-failed', { sessionId, testId: this.testId, error: error instanceof Error ? error : new Error(String(error)) }); } } /** * Handle browser disconnect events */ private handleBrowserDisconnect(instanceId: string): void { // Find sessions using this browser instance const affectedSessions = Array.from(this.sessions.values()) .filter(session => session.browserInstance.id === instanceId); for (const session of affectedSessions) { if (session.status === 'running') { session.status = 'failed'; session.endTime = new Date(); session.errors.push({ timestamp: new Date(), level: 'error', message: 'Browser instance disconnected', context: { sessionId: session.id, instanceId } }); this.emit('session-failed', { sessionId: session.id, testId: this.testId, error: new Error('Browser instance disconnected') }); } } } /** * Start monitoring and emit periodic updates */ private startMonitoring(): void { this.monitoringInterval = setInterval(() => { const monitoringData = this.getMonitoringData(); this.emit('monitoring-update', { testId: this.testId, data: monitoringData }); }, 2000); // Update every 2 seconds } /** * Set up test timeout */ private setupTestTimeout(): void { this.testTimeout = setTimeout(async () => { if (this.isRunning) { await this.stopTest(); } }, this.config.testDuration * 1000); } /** * Perform shutdown cleanup */ private async performShutdown(): Promise<void> { this.isRunning = false; this.endTime = new Date(); // Clear intervals and timeouts if (this.monitoringInterval) { clearInterval(this.monitoringInterval); } if (this.rampUpInterval) { clearInterval(this.rampUpInterval); } if (this.testTimeout) { clearTimeout(this.testTimeout); } // Complete all active sessions const activeSessions = Array.from(this.sessions.values()) .filter(session => session.status === 'running' || session.status === 'starting'); const completionPromises = activeSessions.map(session => this.completeSession(session.id).catch(error => { this.logError(`Failed to complete session ${session.id}`, error); }) ); await Promise.all(completionPromises); // Shutdown browser pool await this.browserPool.shutdown(); const results = this.generateResults(); this.emit('test-completed', { testId: this.testId, results }); } /** * Generate test results */ private generateResults(): TestResults { const allNetworkMetrics = this.getAllNetworkMetrics(); const allErrors = this.getAllErrors(); const browserMetrics = this.browserPool.getMetrics(); // Generate summary const summary: TestSummary = { totalRequests: allNetworkMetrics.length, successfulRequests: allNetworkMetrics.filter(m => m.statusCode >= 200 && m.statusCode < 400).length, failedRequests: allNetworkMetrics.filter(m => m.statusCode >= 400 || m.statusCode === 0).length, averageResponseTime: allNetworkMetrics.length > 0 ? allNetworkMetrics.reduce((sum, m) => sum + m.responseTime, 0) / allNetworkMetrics.length : 0, peakConcurrentUsers: this.config.concurrentUsers, testDuration: this.endTime && this.startTime ? (this.endTime.getTime() - this.startTime.getTime()) / 1000 : 0 }; // Generate DRM metrics const drmMetrics = this.generateDRMMetrics(allNetworkMetrics); return { summary, browserMetrics, drmMetrics, networkMetrics: allNetworkMetrics, errors: allErrors }; } /** * Get all network metrics from all sessions */ private getAllNetworkMetrics(): NetworkMetrics[] { const allMetrics: NetworkMetrics[] = []; for (const session of this.sessions.values()) { allMetrics.push(...session.interceptor.getNetworkMetrics()); } return allMetrics.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); } /** * Get all errors from all sessions */ private getAllErrors(): ErrorLog[] { const allErrors: ErrorLog[] = []; for (const session of this.sessions.values()) { allErrors.push(...session.errors); allErrors.push(...session.interceptor.getErrors()); } return allErrors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); } /** * Generate DRM-specific metrics */ private generateDRMMetrics(networkMetrics: NetworkMetrics[]): DRMMetrics[] { if (!this.config.drmConfig) { return []; } const licenseRequests = networkMetrics.filter(m => m.streamingType === 'license' || m.url.toLowerCase().includes('license') || m.url.toLowerCase().includes('drm') ); const successfulLicenseRequests = licenseRequests.filter(m => m.statusCode >= 200 && m.statusCode < 400 ); const averageLicenseTime = licenseRequests.length > 0 ? licenseRequests.reduce((sum, m) => sum + m.responseTime, 0) / licenseRequests.length : 0; const licenseSuccessRate = licenseRequests.length > 0 ? (successfulLicenseRequests.length / licenseRequests.length) * 100 : 0; // Collect DRM errors from all sessions const drmErrors = []; for (const session of this.sessions.values()) { const streamingErrors = session.interceptor.getStreamingErrors(); for (const error of streamingErrors) { if (error.errorType === 'license' || (error.url && error.url.toLowerCase().includes('license'))) { drmErrors.push({ timestamp: error.timestamp, errorCode: error.errorCode || 'unknown', errorMessage: error.errorMessage, licenseUrl: error.url || this.config.drmConfig.licenseUrl, drmType: this.config.drmConfig.type }); } } } return [{ licenseRequestCount: licenseRequests.length, averageLicenseTime, licenseSuccessRate, drmType: this.config.drmConfig.type, errors: drmErrors }]; } /** * Log error with context */ private logError(message: string, error: any, context?: Record<string, any>): void { const level = (context?.level as 'error' | 'warning' | 'info') || 'error'; const errorLog: ErrorLog = { timestamp: new Date(), level, message, stack: error?.stack, context: { testId: this.testId, component: 'TestRunner', ...context } }; // Emit error log for external systems this.emit('error-logged', errorLog); // Console logging for development if (level === 'error') { console.error(`[TestRunner] ${message}`, error, context); } else if (level === 'warning') { console.warn(`[TestRunner] ${message}`, error, context); } else { console.info(`[TestRunner] ${message}`, error, context); } } }