lightweight-browser-load-tester
Version:
A lightweight load testing tool using real browsers for streaming applications with DRM support
583 lines (523 loc) • 22 kB
text/typescript
/**
* Configuration management system for the lightweight browser load tester
*/
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { Command } from 'commander';
import Joi from 'joi';
import YAML from 'yaml';
import { TestConfiguration } from '../types';
/**
* Configuration source types
*/
export type ConfigSource = 'file' | 'cli' | 'env' | 'default';
/**
* Configuration with metadata about sources
*/
export interface ConfigurationWithMeta {
config: TestConfiguration;
sources: Record<keyof TestConfiguration, ConfigSource>;
}
/**
* Configuration file formats
*/
export type ConfigFormat = 'json' | 'yaml' | 'yml';
/**
* Configuration parsing options
*/
export interface ConfigParseOptions {
configFile?: string;
cliArgs?: string[];
validateOnly?: boolean;
}
/**
* Configuration validation schema using Joi
*/
const drmConfigSchema = Joi.object({
type: Joi.string().valid('widevine', 'playready', 'fairplay').required(),
licenseUrl: Joi.string().uri().required(),
certificateUrl: Joi.string().uri().optional(),
customHeaders: Joi.object().pattern(Joi.string(), Joi.string()).optional()
});
const parameterTemplateSchema = Joi.object({
target: Joi.string().valid('header', 'query', 'body').required(),
name: Joi.string().required(),
valueTemplate: Joi.string().required(),
scope: Joi.string().valid('global', 'per-session').required()
});
const localStorageEntrySchema = Joi.object({
domain: Joi.string().required(),
data: Joi.object().pattern(Joi.string(), Joi.string()).required()
});
const resourceLimitsSchema = Joi.object({
maxMemoryPerInstance: Joi.number().integer().min(128).required(),
maxCpuPercentage: Joi.number().min(1).max(100).required(),
maxConcurrentInstances: Joi.number().integer().min(1).required()
});
const prometheusConfigSchema = Joi.object({
enabled: Joi.boolean().required(),
remoteWriteUrl: Joi.string().uri().required(),
username: Joi.string().optional(),
password: Joi.string().optional(),
headers: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
batchSize: Joi.number().integer().min(1).default(100),
flushInterval: Joi.number().integer().min(1).default(10),
timeout: Joi.number().integer().min(1000).default(5000),
retryAttempts: Joi.number().integer().min(0).default(3),
retryDelay: Joi.number().integer().min(100).default(1000)
});
const opentelemetryConfigSchema = Joi.object({
enabled: Joi.boolean().required(),
endpoint: Joi.string().uri().required(),
protocol: Joi.string().valid('http/protobuf', 'http/json', 'grpc').default('http/protobuf'),
headers: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
serviceName: Joi.string().default('lightweight-browser-load-tester'),
serviceVersion: Joi.string().default('1.0.0'),
timeout: Joi.number().integer().min(1000).default(5000),
compression: Joi.string().valid('gzip', 'none').default('gzip'),
batchTimeout: Joi.number().integer().min(1000).default(5000),
maxExportBatchSize: Joi.number().integer().min(1).default(512),
maxQueueSize: Joi.number().integer().min(1).default(2048),
exportTimeout: Joi.number().integer().min(1000).default(30000)
});
const testConfigurationSchema = Joi.object({
concurrentUsers: Joi.number().integer().min(1).required(),
testDuration: Joi.number().integer().min(1).required(),
rampUpTime: Joi.number().integer().min(0).required(),
streamingUrl: Joi.string().uri().required(),
streamingOnly: Joi.boolean().optional().default(false),
allowedUrls: Joi.array().items(Joi.string()).optional().default([]),
blockedUrls: Joi.array().items(Joi.string()).optional().default([]),
drmConfig: drmConfigSchema.optional(),
requestParameters: Joi.array().items(parameterTemplateSchema).default([]),
resourceLimits: resourceLimitsSchema.required(),
localStorage: Joi.array().items(localStorageEntrySchema).optional().default([]),
prometheus: prometheusConfigSchema.optional(),
opentelemetry: opentelemetryConfigSchema.optional()
});
/**
* Default configuration values
*/
const DEFAULT_CONFIG: TestConfiguration = {
concurrentUsers: 1,
testDuration: 60,
rampUpTime: 10,
streamingUrl: '',
streamingOnly: false,
allowedUrls: [],
blockedUrls: [],
requestParameters: [],
localStorage: [],
resourceLimits: {
maxMemoryPerInstance: 512,
maxCpuPercentage: 80,
maxConcurrentInstances: 10
}
};
/**
* Environment variable mappings
*/
const ENV_MAPPINGS: Record<string, string> = {
'LOAD_TEST_CONCURRENT_USERS': 'concurrentUsers',
'LOAD_TEST_DURATION': 'testDuration',
'LOAD_TEST_RAMP_UP': 'rampUpTime',
'LOAD_TEST_STREAMING_URL': 'streamingUrl',
'LOAD_TEST_STREAMING_ONLY': 'streamingOnly',
'LOAD_TEST_ALLOWED_URLS': 'allowedUrls',
'LOAD_TEST_BLOCKED_URLS': 'blockedUrls',
'LOAD_TEST_MAX_MEMORY': 'resourceLimits.maxMemoryPerInstance',
'LOAD_TEST_MAX_CPU': 'resourceLimits.maxCpuPercentage',
'LOAD_TEST_MAX_INSTANCES': 'resourceLimits.maxConcurrentInstances',
'LOAD_TEST_DRM_TYPE': 'drmConfig.type',
'LOAD_TEST_DRM_LICENSE_URL': 'drmConfig.licenseUrl',
'LOAD_TEST_DRM_CERT_URL': 'drmConfig.certificateUrl',
'LOAD_TEST_PROMETHEUS_ENABLED': 'prometheus.enabled',
'LOAD_TEST_PROMETHEUS_URL': 'prometheus.remoteWriteUrl',
'LOAD_TEST_PROMETHEUS_USERNAME': 'prometheus.username',
'LOAD_TEST_PROMETHEUS_PASSWORD': 'prometheus.password',
'LOAD_TEST_PROMETHEUS_BATCH_SIZE': 'prometheus.batchSize',
'LOAD_TEST_PROMETHEUS_FLUSH_INTERVAL': 'prometheus.flushInterval',
'LOAD_TEST_PROMETHEUS_TIMEOUT': 'prometheus.timeout',
'LOAD_TEST_PROMETHEUS_RETRY_ATTEMPTS': 'prometheus.retryAttempts',
'LOAD_TEST_PROMETHEUS_RETRY_DELAY': 'prometheus.retryDelay',
'LOAD_TEST_OTEL_ENABLED': 'opentelemetry.enabled',
'LOAD_TEST_OTEL_ENDPOINT': 'opentelemetry.endpoint',
'LOAD_TEST_OTEL_PROTOCOL': 'opentelemetry.protocol',
'LOAD_TEST_OTEL_SERVICE_NAME': 'opentelemetry.serviceName',
'LOAD_TEST_OTEL_SERVICE_VERSION': 'opentelemetry.serviceVersion',
'LOAD_TEST_OTEL_TIMEOUT': 'opentelemetry.timeout',
'LOAD_TEST_OTEL_COMPRESSION': 'opentelemetry.compression',
'LOAD_TEST_OTEL_BATCH_TIMEOUT': 'opentelemetry.batchTimeout',
'LOAD_TEST_OTEL_MAX_EXPORT_BATCH_SIZE': 'opentelemetry.maxExportBatchSize',
'LOAD_TEST_OTEL_MAX_QUEUE_SIZE': 'opentelemetry.maxQueueSize',
'LOAD_TEST_OTEL_EXPORT_TIMEOUT': 'opentelemetry.exportTimeout'
};
/**
* Configuration parsing and validation errors
*/
export class ConfigurationError extends Error {
constructor(message: string, public source?: ConfigSource, public field?: string) {
super(message);
this.name = 'ConfigurationError';
}
}
/**
* Main configuration manager class
*/
export class ConfigurationManager {
/**
* Parse configuration from multiple sources with priority order:
* 1. Command line arguments (highest priority)
* 2. Environment variables
* 3. Configuration file
* 4. Default values (lowest priority)
*/
static async parseConfiguration(options: ConfigParseOptions = {}): Promise<ConfigurationWithMeta> {
const sources: Record<string, ConfigSource> = {};
let config = { ...DEFAULT_CONFIG };
// Set default sources
Object.keys(config).forEach(key => {
sources[key] = 'default';
});
try {
// 1. Load from configuration file if provided
if (options.configFile) {
const fileConfig = this.loadConfigFile(options.configFile);
config = this.mergeConfigs(config, fileConfig, sources, 'file');
}
// 2. Load from environment variables
const envConfig = this.loadFromEnvironment();
config = this.mergeConfigs(config, envConfig, sources, 'env');
// 3. Load from command line arguments
if (options.cliArgs) {
const cliConfig = this.parseCommandLineArgs(options.cliArgs);
config = this.mergeConfigs(config, cliConfig, sources, 'cli');
}
// 4. Validate final configuration (unless validateOnly is true)
if (!options.validateOnly) {
this.validateConfiguration(config);
}
return {
config,
sources: sources as Record<keyof TestConfiguration, ConfigSource>
};
} catch (error) {
if (error instanceof ConfigurationError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new ConfigurationError(`Configuration parsing failed: ${message}`);
}
}
/**
* Load configuration from file (JSON or YAML)
*/
private static loadConfigFile(filePath: string): Partial<TestConfiguration> {
try {
const absolutePath = resolve(filePath);
const fileContent = readFileSync(absolutePath, 'utf8');
const format = this.detectFileFormat(filePath);
let parsed: any;
switch (format) {
case 'json':
parsed = JSON.parse(fileContent);
break;
case 'yaml':
case 'yml':
parsed = YAML.parse(fileContent);
break;
default:
throw new ConfigurationError(`Unsupported file format: ${format}`, 'file');
}
return parsed;
} catch (error) {
if (error instanceof ConfigurationError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new ConfigurationError(`Failed to load config file: ${message}`, 'file');
}
}
/**
* Detect configuration file format from extension
*/
private static detectFileFormat(filePath: string): ConfigFormat {
const extension = filePath.split('.').pop()?.toLowerCase();
switch (extension) {
case 'json':
return 'json';
case 'yaml':
case 'yml':
return 'yaml';
default:
throw new ConfigurationError(`Unknown file extension: ${extension}`, 'file');
}
}
/**
* Load configuration from environment variables
*/
private static loadFromEnvironment(): Partial<TestConfiguration> {
const config: any = {};
Object.entries(ENV_MAPPINGS).forEach(([envVar, configPath]) => {
const value = process.env[envVar];
if (value !== undefined) {
this.setNestedValue(config, configPath, this.parseEnvValue(value));
}
});
return config;
}
/**
* Parse command line arguments using Commander.js
*/
private static parseCommandLineArgs(args: string[]): Partial<TestConfiguration> {
const program = new Command();
program
.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('--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('--prometheus-batch-size <number>', 'Prometheus metrics batch size', parseInt)
.option('--prometheus-flush-interval <seconds>', 'Prometheus flush interval in seconds', parseInt)
.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('--otel-timeout <ms>', 'OpenTelemetry request timeout in milliseconds', parseInt)
.option('--otel-compression <type>', 'OpenTelemetry compression (gzip|none)')
.option('--otel-batch-timeout <ms>', 'OpenTelemetry batch timeout in milliseconds', parseInt)
.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)');
program.parse(args, { from: 'user' });
const options = program.opts();
const config: any = {};
if (options.concurrentUsers !== undefined) config.concurrentUsers = options.concurrentUsers;
if (options.testDuration !== undefined) config.testDuration = options.testDuration;
if (options.rampUpTime !== undefined) config.rampUpTime = options.rampUpTime;
if (options.streamingUrl !== undefined) config.streamingUrl = options.streamingUrl;
if (options.maxMemory !== undefined || options.maxCpu !== undefined || options.maxInstances !== undefined) {
config.resourceLimits = {};
if (options.maxMemory !== undefined) config.resourceLimits.maxMemoryPerInstance = options.maxMemory;
if (options.maxCpu !== undefined) config.resourceLimits.maxCpuPercentage = options.maxCpu;
if (options.maxInstances !== undefined) config.resourceLimits.maxConcurrentInstances = options.maxInstances;
}
if (options.drmType !== undefined || options.drmLicenseUrl !== undefined || options.drmCertUrl !== undefined) {
config.drmConfig = {};
if (options.drmType !== undefined) config.drmConfig.type = options.drmType;
if (options.drmLicenseUrl !== undefined) config.drmConfig.licenseUrl = options.drmLicenseUrl;
if (options.drmCertUrl !== undefined) config.drmConfig.certificateUrl = options.drmCertUrl;
}
if (options.prometheusEnabled !== undefined || options.prometheusUrl !== undefined ||
options.prometheusUsername !== undefined || options.prometheusPassword !== undefined ||
options.prometheusBatchSize !== undefined || options.prometheusFlushInterval !== undefined) {
config.prometheus = {};
if (options.prometheusEnabled !== undefined) config.prometheus.enabled = options.prometheusEnabled;
if (options.prometheusUrl !== undefined) config.prometheus.remoteWriteUrl = options.prometheusUrl;
if (options.prometheusUsername !== undefined) config.prometheus.username = options.prometheusUsername;
if (options.prometheusPassword !== undefined) config.prometheus.password = options.prometheusPassword;
if (options.prometheusBatchSize !== undefined) config.prometheus.batchSize = options.prometheusBatchSize;
if (options.prometheusFlushInterval !== undefined) config.prometheus.flushInterval = options.prometheusFlushInterval;
}
if (options.otelEnabled !== undefined || options.otelEndpoint !== undefined ||
options.otelProtocol !== undefined || options.otelServiceName !== undefined ||
options.otelServiceVersion !== undefined || options.otelTimeout !== undefined ||
options.otelCompression !== undefined || options.otelBatchTimeout !== undefined) {
config.opentelemetry = {};
if (options.otelEnabled !== undefined) config.opentelemetry.enabled = options.otelEnabled;
if (options.otelEndpoint !== undefined) config.opentelemetry.endpoint = options.otelEndpoint;
if (options.otelProtocol !== undefined) config.opentelemetry.protocol = options.otelProtocol;
if (options.otelServiceName !== undefined) config.opentelemetry.serviceName = options.otelServiceName;
if (options.otelServiceVersion !== undefined) config.opentelemetry.serviceVersion = options.otelServiceVersion;
if (options.otelTimeout !== undefined) config.opentelemetry.timeout = options.otelTimeout;
if (options.otelCompression !== undefined) config.opentelemetry.compression = options.otelCompression;
if (options.otelBatchTimeout !== undefined) config.opentelemetry.batchTimeout = options.otelBatchTimeout;
}
if (options.streamingOnly !== undefined) config.streamingOnly = options.streamingOnly;
// Parse URL patterns (comma-separated strings to arrays)
if (options.allowedUrls !== undefined) {
config.allowedUrls = options.allowedUrls.split(',').map((url: string) => url.trim()).filter((url: string) => url.length > 0);
}
if (options.blockedUrls !== undefined) {
config.blockedUrls = options.blockedUrls.split(',').map((url: string) => url.trim()).filter((url: string) => url.length > 0);
}
return config;
}
/**
* Merge configurations with source tracking
*/
private static mergeConfigs(
base: any,
override: any,
sources: Record<string, ConfigSource>,
source: ConfigSource
): any {
const result = { ...base };
Object.keys(override).forEach(key => {
if (override[key] !== undefined) {
if (typeof override[key] === 'object' && override[key] !== null && !Array.isArray(override[key])) {
result[key] = this.mergeConfigs(result[key] || {}, override[key], sources, source);
} else {
result[key] = override[key];
sources[key] = source;
}
}
});
return result;
}
/**
* Set nested object value using dot notation
*/
private static setNestedValue(obj: any, path: string, value: any): void {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
/**
* Parse environment variable value to appropriate type
*/
private static parseEnvValue(value: string): any {
// Try to parse as number
if (/^\d+$/.test(value)) {
return parseInt(value, 10);
}
// Try to parse as float
if (/^\d+\.\d+$/.test(value)) {
return parseFloat(value);
}
// Try to parse as boolean
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Try to parse as comma-separated array
if (value.includes(',')) {
return value.split(',').map(item => item.trim()).filter(item => item.length > 0);
}
// Return as string
return value;
}
/**
* Validate configuration using Joi schema
*/
private static validateConfiguration(config: TestConfiguration): void {
const { error } = testConfigurationSchema.validate(config, {
abortEarly: false,
allowUnknown: false
});
if (error) {
const details = error.details.map(detail => `${detail.path.join('.')}: ${detail.message}`).join('; ');
throw new ConfigurationError(`Configuration validation failed: ${details}`);
}
// Additional custom validations
if (config.rampUpTime >= config.testDuration) {
throw new ConfigurationError('Ramp up time must be less than test duration');
}
if (!config.streamingUrl) {
throw new ConfigurationError('Streaming URL is required');
}
}
/**
* Get configuration schema for external validation
*/
static getValidationSchema(): Joi.ObjectSchema {
return testConfigurationSchema;
}
/**
* Create example configuration file
*/
static generateExampleConfig(format: ConfigFormat = 'yaml'): string {
const exampleConfig: TestConfiguration = {
concurrentUsers: 5,
testDuration: 300,
rampUpTime: 30,
streamingUrl: 'https://example.com/stream',
streamingOnly: false,
allowedUrls: ['*manifest*', '*playlist*'],
blockedUrls: ['*analytics*', '*tracking*'],
drmConfig: {
type: 'widevine',
licenseUrl: 'https://example.com/license',
certificateUrl: 'https://example.com/cert'
},
requestParameters: [
{
target: 'header',
name: 'Authorization',
valueTemplate: 'Bearer {{token}}',
scope: 'per-session'
}
],
localStorage: [
{
domain: 'example.com',
data: {
'auth_token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
'user_id': '12345',
'session_id': 'sess_abc123'
}
},
{
domain: 'cdn.example.com',
data: {
'preferences': '{"theme":"dark","language":"en"}',
'cache_version': '1.2.3'
}
}
],
resourceLimits: {
maxMemoryPerInstance: 512,
maxCpuPercentage: 80,
maxConcurrentInstances: 10
},
prometheus: {
enabled: false,
remoteWriteUrl: 'https://prometheus.example.com/api/v1/write',
username: 'prometheus_user',
password: 'prometheus_password',
batchSize: 100,
flushInterval: 10,
timeout: 5000,
retryAttempts: 3,
retryDelay: 1000
},
opentelemetry: {
enabled: false,
endpoint: 'https://otel-collector.example.com:4318/v1/metrics',
protocol: 'http/protobuf',
serviceName: 'lightweight-browser-load-tester',
serviceVersion: '1.0.0',
timeout: 5000,
compression: 'gzip',
batchTimeout: 5000,
maxExportBatchSize: 512,
maxQueueSize: 2048,
exportTimeout: 30000
}
};
switch (format) {
case 'json':
return JSON.stringify(exampleConfig, null, 2);
case 'yaml':
case 'yml':
return YAML.stringify(exampleConfig);
default:
throw new ConfigurationError(`Unsupported format: ${format}`);
}
}
}