lightweight-browser-load-tester
Version:
A lightweight load testing tool using real browsers for streaming applications with DRM support
711 lines • 29 kB
JavaScript
"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