UNPKG

lightweight-browser-load-tester

Version:

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

711 lines 29 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TestRunner = void 0; const events_1 = require("events"); const browser_pool_1 = require("../managers/browser-pool"); const request_interceptor_1 = require("../interceptors/request-interceptor"); /** * TestRunner orchestrates browser instances and executes load tests */ class TestRunner extends events_1.EventEmitter { constructor(config) { super(); this.sessions = new Map(); this.isRunning = false; this.testId = `test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; this.config = config; // Warn user if DRM is configured and headless mode is being overridden if (config.drmConfig && (config.browserOptions?.headless !== false)) { console.warn('🔐 DRM detected: Automatically disabling headless mode for Widevine compatibility'); console.warn(' Widevine DRM requires a display context and hardware security features'); console.warn(' To explicitly control this behavior, set browserOptions.headless in your config'); } // Create browser pool configuration const poolConfig = { maxInstances: config.resourceLimits.maxConcurrentInstances, minInstances: Math.min(2, config.concurrentUsers), resourceLimits: config.resourceLimits, localStorage: config.localStorage, drmConfig: config.drmConfig, // Pass DRM config to browser pool browserOptions: { // Automatically disable headless mode when DRM is configured headless: config.drmConfig ? false : (config.browserOptions?.headless ?? true), // Automatically use Chrome for DRM testing, Chromium for regular testing browserType: config.browserOptions?.browserType || (config.drmConfig ? 'chrome' : 'chromium'), args: [ // Default stability and performance 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', // Add DRM-specific args when DRM is configured ...(config.drmConfig ? [ '--enable-features=WidevineL1', '--autoplay-policy=no-user-gesture-required', '--disable-features=EncryptedMediaHdcpPolicyCheck' ] : []), // Add user-specified args ...(config.browserOptions?.args || []) ] } }; this.browserPool = new browser_pool_1.BrowserPool(poolConfig); this.setupBrowserPoolEvents(); } /** * Start the load test */ async startTest() { 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() { 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() { 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 */ getResourceUtilizationData() { 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 */ generateResourceAlerts(resourceStats) { const alerts = []; 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 = 300000) { return await this.browserPool.cleanupIdleInstances(maxIdleTime); } /** * Get detailed resource usage statistics */ getResourceUsageStats() { return this.browserPool.getResourceUsageStats(); } /** * Get test ID */ getTestId() { return this.testId; } /** * Check if test is currently running */ isTestRunning() { return this.isRunning; } /** * Set up browser pool event listeners */ setupBrowserPoolEvents() { 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 */ async startRampUp() { 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 */ async startSession() { 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 request_interceptor_1.RequestInterceptor(browserInstance.page, this.config.requestParameters, { sessionId }, this.config.streamingOnly || false, this.config.allowedUrls || [], this.config.blockedUrls || []); // Create session const session = { 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 */ scheduleSessionCompletion(session) { 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 */ async completeSession(sessionId) { 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 */ handleBrowserDisconnect(instanceId) { // 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 */ startMonitoring() { 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 */ setupTestTimeout() { this.testTimeout = setTimeout(async () => { if (this.isRunning) { await this.stopTest(); } }, this.config.testDuration * 1000); } /** * Perform shutdown cleanup */ async performShutdown() { 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 */ generateResults() { const allNetworkMetrics = this.getAllNetworkMetrics(); const allErrors = this.getAllErrors(); const browserMetrics = this.browserPool.getMetrics(); // Generate summary const summary = { 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 */ getAllNetworkMetrics() { const allMetrics = []; 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 */ getAllErrors() { const allErrors = []; 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 */ generateDRMMetrics(networkMetrics) { 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 */ logError(message, error, context) { const level = context?.level || 'error'; const 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); } } } exports.TestRunner = TestRunner; //# sourceMappingURL=test-runner.js.map