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