UNPKG

lightweight-browser-load-tester

Version:

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

468 lines 20.7 kB
#!/usr/bin/env node "use strict"; /** * Main entry point for the lightweight browser load tester */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LoadTesterApp = void 0; const commander_1 = require("commander"); const fs_1 = require("fs"); const path_1 = require("path"); const config_1 = require("./config"); const test_runner_1 = require("./controllers/test-runner"); const results_aggregator_1 = require("./aggregators/results-aggregator"); const prometheus_exporter_1 = require("./exporters/prometheus-exporter"); const opentelemetry_exporter_1 = require("./exporters/opentelemetry-exporter"); /** * Main application class that coordinates all components */ class LoadTesterApp { constructor(config) { this.isShuttingDown = false; this.config = config; this.setupGracefulShutdown(); } /** * Start the load test application */ async start() { if (this.isShuttingDown) { throw new Error('Application is shutting down'); } console.log('🚀 Starting lightweight browser load tester...'); console.log(`📊 Configuration: ${this.config.concurrentUsers} users, ${this.config.testDuration}s duration`); console.log(`🎯 Target URL: ${this.config.streamingUrl}`); if (this.config.drmConfig) { console.log(`🔐 DRM: ${this.config.drmConfig.type} (${this.config.drmConfig.licenseUrl})`); } try { // Initialize components this.testRunner = new test_runner_1.TestRunner(this.config); this.resultsAggregator = new results_aggregator_1.ResultsAggregator(); if (this.config.prometheus?.enabled) { this.prometheusExporter = new prometheus_exporter_1.PrometheusExporter(this.config.prometheus); console.log('📈 Prometheus metrics export enabled'); } if (this.config.opentelemetry?.enabled) { this.opentelemetryExporter = new opentelemetry_exporter_1.OpenTelemetryExporter(this.config.opentelemetry); await this.opentelemetryExporter.initialize(); console.log('📊 OpenTelemetry metrics export enabled'); } // Set up event handlers this.setupTestRunnerEvents(); // Start the test this.testRunner.startTest(); // Don't await - this is fire-and-forget // Wait for test completion or shutdown return await this.waitForCompletion(); } catch (error) { console.error('❌ Failed to start load test:', error); await this.cleanup(); throw error; } } /** * Stop the application gracefully */ async stop() { if (this.isShuttingDown) { if (this.shutdownPromise) { await this.shutdownPromise; } return null; } console.log('\n🛑 Graceful shutdown initiated...'); this.isShuttingDown = true; this.shutdownPromise = this.performShutdown(); return await this.shutdownPromise; } /** * Get current test status */ getStatus() { if (!this.testRunner) { return { status: 'not_started' }; } return { status: this.testRunner.isTestRunning() ? 'running' : 'completed', testId: this.testRunner.getTestId(), monitoring: this.testRunner.getMonitoringData() }; } /** * Set up graceful shutdown handlers */ setupGracefulShutdown() { const shutdownHandler = async (signal) => { console.log(`\n📡 Received ${signal}, initiating graceful shutdown...`); try { await this.stop(); process.exit(0); } catch (error) { console.error('❌ Error during shutdown:', error); process.exit(1); } }; process.on('SIGINT', () => shutdownHandler('SIGINT')); process.on('SIGTERM', () => shutdownHandler('SIGTERM')); // Handle uncaught exceptions process.on('uncaughtException', async (error) => { console.error('💥 Uncaught exception:', error); await this.stop(); process.exit(1); }); process.on('unhandledRejection', async (reason, promise) => { console.error('💥 Unhandled rejection at:', promise, 'reason:', reason); await this.stop(); process.exit(1); }); } /** * Set up test runner event handlers */ setupTestRunnerEvents() { if (!this.testRunner) return; this.testRunner.on('test-started', ({ testId }) => { console.log(`✅ Test started (ID: ${testId})`); }); this.testRunner.on('ramp-up-completed', () => { console.log('📈 Ramp-up phase completed, all users active'); }); this.testRunner.on('monitoring-update', ({ data }) => { const progress = this.config.testDuration > 0 ? ((data.elapsedTime / this.config.testDuration) * 100).toFixed(1) : '0.0'; process.stdout.write(`\r⏱️ Progress: ${progress}% | ` + `👥 Active: ${data.activeSessions} | ` + `📊 Requests: ${data.totalRequests} (${data.successfulRequests} success, ${data.failedRequests} failed) | ` + `⚡ RPS: ${data.currentRps.toFixed(1)} | ` + `💾 Memory: ${data.memoryUsage.toFixed(0)}MB | ` + `⏰ Remaining: ${Math.max(0, data.remainingTime).toFixed(0)}s`); // Export metrics to Prometheus if enabled if (this.prometheusExporter) { // Export test summary metrics this.prometheusExporter.exportTestSummary({ totalRequests: data.totalRequests, successfulRequests: data.successfulRequests, failedRequests: data.failedRequests, averageResponseTime: data.averageResponseTime, peakConcurrentUsers: data.activeSessions, testDuration: data.elapsedTime }); } // Export metrics to OpenTelemetry if enabled if (this.opentelemetryExporter) { // Export test summary metrics this.opentelemetryExporter.exportTestSummary({ totalRequests: data.totalRequests, successfulRequests: data.successfulRequests, failedRequests: data.failedRequests, averageResponseTime: data.averageResponseTime, peakConcurrentUsers: data.activeSessions, testDuration: data.elapsedTime }); } }); this.testRunner.on('test-completed', ({ results }) => { console.log('\n✅ Test completed successfully'); this.displayResults(results); }); this.testRunner.on('test-failed', ({ error }) => { console.log('\n❌ Test failed:', error.message); }); this.testRunner.on('session-failed', ({ sessionId, error }) => { console.log(`\n⚠️ Session ${sessionId} failed: ${error.message}`); }); } /** * Wait for test completion */ async waitForCompletion() { return new Promise((resolve, reject) => { if (!this.testRunner) { reject(new Error('Test runner not initialized')); return; } this.testRunner.once('test-completed', ({ results }) => { resolve(results); }); this.testRunner.once('test-failed', ({ error }) => { reject(error); }); }); } /** * Perform shutdown cleanup */ async performShutdown() { let results = null; try { // Stop test runner if running if (this.testRunner && this.testRunner.isTestRunning()) { console.log('🔄 Stopping test runner...'); results = await this.testRunner.stopTest(); console.log('✅ Test runner stopped'); } // Cleanup components await this.cleanup(); if (results) { console.log('\n📊 Final Results:'); this.displayResults(results); } console.log('✅ Graceful shutdown completed'); return results; } catch (error) { console.error('❌ Error during shutdown:', error); throw error; } } /** * Cleanup resources */ async cleanup() { const cleanupPromises = []; if (this.prometheusExporter) { cleanupPromises.push(this.prometheusExporter.shutdown()); } if (this.opentelemetryExporter) { cleanupPromises.push(this.opentelemetryExporter.shutdown()); } await Promise.all(cleanupPromises); } /** * Display test results summary */ displayResults(results) { console.log('\n📊 Test Results Summary:'); console.log('═'.repeat(50)); console.log(`📈 Total Requests: ${results.summary.totalRequests}`); console.log(`✅ Successful: ${results.summary.successfulRequests} (${((results.summary.successfulRequests / results.summary.totalRequests) * 100).toFixed(1)}%)`); console.log(`❌ Failed: ${results.summary.failedRequests} (${((results.summary.failedRequests / results.summary.totalRequests) * 100).toFixed(1)}%)`); console.log(`⏱️ Average Response Time: ${results.summary.averageResponseTime.toFixed(2)}ms`); console.log(`👥 Peak Concurrent Users: ${results.summary.peakConcurrentUsers}`); console.log(`⏰ Test Duration: ${results.summary.testDuration.toFixed(1)}s`); if (results.drmMetrics.length > 0) { console.log('\n🔐 DRM Metrics:'); results.drmMetrics.forEach(drm => { console.log(` ${drm.drmType}: ${drm.licenseRequestCount} requests, ${drm.averageLicenseTime.toFixed(2)}ms avg, ${drm.licenseSuccessRate.toFixed(1)}% success`); }); } if (results.errors.length > 0) { console.log(`\n⚠️ Errors: ${results.errors.length} total`); const errorCounts = results.errors.reduce((acc, error) => { acc[error.level] = (acc[error.level] || 0) + 1; return acc; }, {}); Object.entries(errorCounts).forEach(([level, count]) => { console.log(` ${level}: ${count}`); }); } } } exports.LoadTesterApp = LoadTesterApp; /** * CLI command definitions and main entry point */ async function main() { const program = new commander_1.Command(); // Read package.json for version const packagePath = (0, path_1.resolve)(__dirname, '../package.json'); const packageInfo = (0, fs_1.existsSync)(packagePath) ? JSON.parse((0, fs_1.readFileSync)(packagePath, 'utf8')) : { version: '1.0.0', description: 'Lightweight browser load tester' }; program .name('load-tester') .description(packageInfo.description) .version(packageInfo.version); // Main test command program .command('test') .description('Run a load test') .option('-c, --config <file>', 'Configuration file (JSON or YAML)') .option('-u, --concurrent-users <number>', 'Number of concurrent users', parseInt) .option('-d, --test-duration <seconds>', 'Test duration in seconds', parseInt) .option('-r, --ramp-up-time <seconds>', 'Ramp up time in seconds', parseInt) .option('-s, --streaming-url <url>', 'Streaming URL to test') .option('--max-memory <mb>', 'Maximum memory per instance in MB', parseInt) .option('--max-cpu <percentage>', 'Maximum CPU percentage', parseInt) .option('--max-instances <number>', 'Maximum concurrent instances', parseInt) .option('--drm-type <type>', 'DRM type (widevine|playready|fairplay)') .option('--drm-license-url <url>', 'DRM license URL') .option('--drm-cert-url <url>', 'DRM certificate URL') .option('--browser-type <type>', 'Browser type (chrome|chromium) - Chrome recommended for DRM') .option('--headless', 'Run browsers in headless mode (automatically disabled for DRM)') .option('--no-headless', 'Run browsers with GUI (required for DRM testing)') .option('--prometheus-enabled', 'Enable Prometheus metrics export') .option('--prometheus-url <url>', 'Prometheus RemoteWrite endpoint URL') .option('--prometheus-username <username>', 'Prometheus authentication username') .option('--prometheus-password <password>', 'Prometheus authentication password') .option('--otel-enabled', 'Enable OpenTelemetry metrics export') .option('--otel-endpoint <url>', 'OpenTelemetry OTLP endpoint URL') .option('--otel-protocol <protocol>', 'OpenTelemetry protocol (http/protobuf|http/json|grpc)') .option('--otel-service-name <name>', 'OpenTelemetry service name') .option('--otel-service-version <version>', 'OpenTelemetry service version') .option('--streaming-only', 'Block all non-streaming requests to save CPU/memory') .option('--allowed-urls <patterns>', 'Comma-separated URL patterns to always allow (even when streaming-only is enabled)') .option('--blocked-urls <patterns>', 'Comma-separated URL patterns to always block (even if streaming-related)') .option('--output <file>', 'Output file for results (JSON format)') .option('--verbose', 'Enable verbose logging') .action(async (options) => { try { // Parse configuration // Filter CLI args to only include test configuration options, not meta options const testConfigArgs = process.argv.slice(2).filter((arg, index, arr) => { // Skip the command name 'test' if (arg === 'test') return false; // Skip meta options and their values const metaOptions = ['-c', '--config', '--output', '--verbose']; if (metaOptions.includes(arg)) return false; if (index > 0 && metaOptions.includes(arr[index - 1])) return false; return true; }); const { config } = await config_1.ConfigurationManager.parseConfiguration({ configFile: options.config, cliArgs: testConfigArgs }); // Validate required fields if (!config.streamingUrl) { console.error('❌ Error: Streaming URL is required. Use --streaming-url or set it in config file.'); process.exit(1); } // Create and start application const app = new LoadTesterApp(config); const results = await app.start(); // Save results to file if specified if (options.output) { try { const fs = await Promise.resolve().then(() => __importStar(require('fs/promises'))); await fs.writeFile(options.output, JSON.stringify(results, null, 2)); console.log(`📄 Results saved to ${options.output}`); } catch (error) { console.error('❌ Error saving results to file:', error); } } process.exit(0); } catch (error) { if (error instanceof config_1.ConfigurationError) { console.error('❌ Configuration Error:', error.message); if (error.source) { console.error(` Source: ${error.source}`); } } else { console.error('❌ Error:', error instanceof Error ? error.message : String(error)); } process.exit(1); } }); // Configuration validation command program .command('validate') .description('Validate configuration file') .option('-c, --config <file>', 'Configuration file to validate') .action(async (options) => { try { if (!options.config) { console.error('❌ Error: Configuration file is required for validation'); process.exit(1); } await config_1.ConfigurationManager.parseConfiguration({ configFile: options.config, validateOnly: true }); console.log('✅ Configuration is valid'); process.exit(0); } catch (error) { if (error instanceof config_1.ConfigurationError) { console.error('❌ Configuration Error:', error.message); } else { console.error('❌ Validation Error:', error instanceof Error ? error.message : String(error)); } process.exit(1); } }); // Generate example configuration command program .command('init') .description('Generate example configuration file') .option('-f, --format <format>', 'Output format (json|yaml)', 'yaml') .option('-o, --output <file>', 'Output file name', 'load-test-config.yaml') .action(async (options) => { try { const format = options.format; const content = config_1.ConfigurationManager.generateExampleConfig(format); const fs = await Promise.resolve().then(() => __importStar(require('fs/promises'))); await fs.writeFile(options.output, content); console.log(`✅ Example configuration created: ${options.output}`); console.log('📝 Edit the file to match your testing requirements'); process.exit(0); } catch (error) { console.error('❌ Error creating configuration:', error instanceof Error ? error.message : String(error)); process.exit(1); } }); // Parse command line arguments await program.parseAsync(process.argv); } // Run CLI if this file is executed directly if (require.main === module) { main().catch(error => { console.error('💥 Fatal error:', error); process.exit(1); }); } // Export all types and classes for external use __exportStar(require("./types"), exports); __exportStar(require("./config"), exports); __exportStar(require("./controllers/test-runner"), exports); __exportStar(require("./aggregators/results-aggregator"), exports); __exportStar(require("./exporters/prometheus-exporter"), exports); __exportStar(require("./exporters/opentelemetry-exporter"), exports); //# sourceMappingURL=index.js.map