UNPKG

lightweight-browser-load-tester

Version:

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

658 lines 24.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RequestInterceptor = void 0; const randomization_1 = require("../utils/randomization"); /** * Network request interceptor that modifies requests based on parameter templates */ class RequestInterceptor { constructor(page, parameterTemplates = [], initialContext = {}, streamingOnly = false, allowedUrls = [], blockedUrls = []) { this.networkMetrics = []; this.errors = []; this.streamingErrors = []; this.requestCount = 0; this.streamingStartTime = 0; this.streamingOnly = false; this.blockedRequestCount = 0; this.allowedUrls = []; this.blockedUrls = []; this.fileDataCache = {}; this.page = page; this.parameterTemplates = parameterTemplates; this.streamingOnly = streamingOnly; this.allowedUrls = allowedUrls; this.blockedUrls = blockedUrls; this.variableContext = { sessionId: `session_${Date.now()}`, timestamp: Date.now(), requestCount: 0, ...initialContext }; // Initialize randomization utility with context this.randomizationUtil = new randomization_1.RandomizationUtil(this.variableContext); } /** * Start intercepting network requests */ async startInterception() { try { // Enable request interception await this.page.route('**/*', async (route, request) => { await this.handleRequest(route, request); }); // Listen for response events to collect metrics this.page.on('response', (response) => { this.collectNetworkMetrics(response); }); // Listen for request failures this.page.on('requestfailed', (request) => { this.logRequestFailure(request); }); } catch (error) { this.logError('Failed to start request interception', error); throw error; } } /** * Stop intercepting network requests */ async stopInterception() { try { // Remove all route handlers await this.page.unroute('**/*'); } catch (error) { this.logError('Failed to stop request interception', error); } } /** * Update variable context for parameter substitution */ updateContext(updates) { this.variableContext = { ...this.variableContext, ...updates, timestamp: Date.now() }; } /** * Get collected network metrics */ getNetworkMetrics() { return [...this.networkMetrics]; } /** * Get collected errors */ getErrors() { return [...this.errors]; } /** * Get streaming-specific errors */ getStreamingErrors() { return [...this.streamingErrors]; } /** * Get the number of blocked requests (when streamingOnly is enabled) */ getBlockedRequestCount() { return this.blockedRequestCount; } /** * Get aggregated streaming metrics */ getStreamingMetrics() { const streamingRequests = this.networkMetrics.filter(m => m.isStreamingRelated); const manifestRequests = streamingRequests.filter(m => m.streamingType === 'manifest'); const segmentRequests = streamingRequests.filter(m => m.streamingType === 'segment'); const licenseRequests = streamingRequests.filter(m => m.streamingType === 'license'); const apiRequests = streamingRequests.filter(m => m.streamingType === 'api'); const successfulRequests = streamingRequests.filter(m => m.statusCode >= 200 && m.statusCode < 400); const totalBytes = streamingRequests.reduce((sum, m) => sum + m.requestSize + m.responseSize, 0); const testDuration = this.streamingStartTime > 0 ? (Date.now() - this.streamingStartTime) / 1000 : 1; return { manifestRequests: manifestRequests.length, segmentRequests: segmentRequests.length, licenseRequests: licenseRequests.length, apiRequests: apiRequests.length, totalStreamingRequests: streamingRequests.length, averageManifestTime: this.calculateAverageResponseTime(manifestRequests), averageSegmentTime: this.calculateAverageResponseTime(segmentRequests), averageLicenseTime: this.calculateAverageResponseTime(licenseRequests), streamingSuccessRate: streamingRequests.length > 0 ? (successfulRequests.length / streamingRequests.length) * 100 : 0, bandwidthUsage: totalBytes / testDuration, errors: [...this.streamingErrors] }; } /** * Start streaming monitoring (marks the beginning of streaming test) */ startStreamingMonitoring() { this.streamingStartTime = Date.now(); } /** * Clear collected metrics and errors */ clearMetrics() { this.networkMetrics = []; this.errors = []; this.streamingErrors = []; this.requestCount = 0; this.streamingStartTime = 0; } /** * Handle intercepted request */ async handleRequest(route, request) { try { this.requestCount++; this.variableContext.requestCount = this.requestCount; const url = request.url(); // Priority 1: Check if URL is explicitly blocked (highest priority) if (this.isBlockedUrl(url)) { this.blockedRequestCount++; await route.abort('blockedbyclient'); return; } // Priority 2: Check if URL is explicitly allowed (overrides streaming-only mode) if (this.isAllowedUrl(url)) { // Allow the request to continue (skip streaming-only check) } // Priority 3: Apply streaming-only logic if not explicitly allowed else if (this.streamingOnly && !this.isStreamingRequest(url) && !this.isEssentialRequest(url)) { this.blockedRequestCount++; await route.abort('blockedbyclient'); return; } // Store request start time for manual timing calculation const requestStartTime = Date.now(); // Store timing info for this request request._startTime = requestStartTime; const interceptedRequest = { url, method: request.method(), headers: request.headers(), postData: request.postData() || undefined }; const result = this.modifyRequest(interceptedRequest); if (result.modified) { // Continue with modified request const modifiedOptions = {}; if (result.modifiedUrl && result.modifiedUrl !== result.originalUrl) { modifiedOptions.url = result.modifiedUrl; } if (result.modifiedHeaders) { modifiedOptions.headers = result.modifiedHeaders; } if (result.modifiedPostData !== undefined) { modifiedOptions.postData = result.modifiedPostData; } await route.continue(modifiedOptions); } else { // Continue with original request await route.continue(); } } catch (error) { this.logError('Failed to handle intercepted request', error, { url: request.url(), method: request.method() }); // Continue with original request on error try { await route.continue(); } catch (continueError) { this.logError('Failed to continue request after error', continueError); } } } /** * Modify request based on parameter templates */ modifyRequest(request) { const result = { modified: false, originalUrl: request.url, originalHeaders: { ...request.headers }, originalPostData: request.postData }; let modifiedUrl = request.url; let modifiedHeaders = { ...request.headers }; let modifiedPostData = request.postData; for (const template of this.parameterTemplates) { try { // Check if template should be applied to this request if (!this.shouldApplyTemplate(template, request.url, request.method)) { continue; } const value = this.substituteVariables(template.valueTemplate); switch (template.target) { case 'header': modifiedHeaders[template.name] = value; result.modified = true; break; case 'query': modifiedUrl = this.addQueryParameter(modifiedUrl, template.name, value); result.modified = true; break; case 'body': if (modifiedPostData) { modifiedPostData = this.modifyRequestBody(modifiedPostData, template.name, value); result.modified = true; } break; } } catch (error) { this.logError('Failed to apply parameter template', error, { template: template.name, target: template.target, url: request.url, method: request.method }); } } if (result.modified) { result.modifiedUrl = modifiedUrl; result.modifiedHeaders = modifiedHeaders; result.modifiedPostData = modifiedPostData; } return result; } /** * Substitute variables in template string with support for random functions */ substituteVariables(template) { try { // Update randomization utility context with current variable context this.randomizationUtil.updateContext(this.variableContext); // Use shared randomization utility const result = this.randomizationUtil.substituteVariables(template); // Check if any substitution failed (indicated by unchanged template with error patterns) if (result.includes('randomFromFile:') && result.includes('/nonexistent/')) { this.logError('Failed to load file data', new Error('File not found'), { template, result }); } // Check if template contains unresolved variables (still has {{ }} after substitution) if (result !== template && result.includes('{{') && result.includes('}}')) { this.logError('Template contains unresolved variables', new Error('Variable substitution incomplete'), { template, result }); } else if (result === template && template.includes('{{') && template.includes('}}')) { // If result is unchanged and contains template variables, it means substitution failed this.logError('Failed to substitute template variables', new Error('Unknown variables or functions'), { template }); } return result; } catch (error) { this.logError('Failed to substitute variables', error, { template }); return template; } } /** * Add query parameter to URL */ addQueryParameter(url, name, value) { try { const urlObj = new URL(url); urlObj.searchParams.set(name, value); return urlObj.toString(); } catch (error) { this.logError('Failed to add query parameter', error, { url, name, value }); return url; } } /** * Modify request body (supports JSON and form data) */ modifyRequestBody(postData, name, value) { // Handle empty body if (!postData || postData.trim() === '') { this.logError('Unable to modify empty request body', null, { postData: postData }); return postData; } try { // Try to parse as JSON const jsonData = JSON.parse(postData); // Try to parse the value as JSON if it looks like JSON let parsedValue = value; if (typeof value === 'string' && value.trim().startsWith('{') && value.trim().endsWith('}')) { try { parsedValue = JSON.parse(value); } catch { // If parsing fails, use the original string value parsedValue = value; } } jsonData[name] = parsedValue; return JSON.stringify(jsonData); } catch { // If not JSON, check if it looks like form data if (this.isFormData(postData)) { try { const params = new URLSearchParams(postData); params.set(name, value); return params.toString(); } catch { // If URLSearchParams fails, return original this.logError('Unable to modify form data request body', null, { postData: postData.substring(0, 100) + '...' }); return postData; } } else { // If neither JSON nor form data, return original this.logError('Unable to modify request body - unsupported format', null, { postData: postData.substring(0, 100) + '...' }); return postData; } } } /** * Check if string looks like form data */ isFormData(data) { // Basic heuristic: form data typically contains = and & characters // and doesn't contain spaces (unless URL encoded) return /^[^=]*=[^&]*(&[^=]*=[^&]*)*$/.test(data) || /^[a-zA-Z0-9%._~:/?#[\]@!$&'()*+,;=-]*$/.test(data); } /** * Collect network metrics from response */ collectNetworkMetrics(response) { try { const request = response.request(); const timing = response.request().timing(); const url = request.url(); const responseEndTime = Date.now(); // Calculate response time using manual timing (more reliable than Playwright timing) let responseTime = 0; const requestStartTime = request._startTime; if (requestStartTime) { responseTime = responseEndTime - requestStartTime; } else if (timing && timing.requestStart && timing.responseEnd) { // Fallback to Playwright timing if manual timing is not available responseTime = timing.responseEnd - timing.requestStart; } const metric = { url, method: request.method(), responseTime, statusCode: response.status(), timestamp: new Date(), requestSize: this.calculateRequestSize(request), responseSize: this.calculateResponseSize(response), isStreamingRelated: this.isStreamingRequest(url), streamingType: this.getStreamingType(url) }; this.networkMetrics.push(metric); // Log streaming-specific errors if (metric.isStreamingRelated && (response.status() >= 400 || response.status() === 0)) { this.logStreamingError(url, response.status(), 'network', `HTTP ${response.status()}`); } } catch (error) { this.logError('Failed to collect network metrics', error); } } /** * Calculate approximate request size */ calculateRequestSize(request) { try { let size = request.url().length; // Add headers size const headers = request.headers(); for (const [key, value] of Object.entries(headers)) { size += key.length + value.length + 4; // +4 for ": " and "\r\n" } // Add post data size const postData = request.postData(); if (postData) { size += postData.length; } return size; } catch { return 0; } } /** * Log request failure */ logRequestFailure(request) { this.logError('Request failed', null, { url: request.url(), method: request.method(), failure: request.failure()?.errorText || 'Unknown error' }); } /** * Calculate approximate response size */ calculateResponseSize(response) { try { // Try to get content-length header const contentLength = response.headers()['content-length']; if (contentLength) { return parseInt(contentLength, 10); } // If no content-length, estimate based on headers let size = 0; const headers = response.headers(); for (const [key, value] of Object.entries(headers)) { size += key.length + value.length + 4; // +4 for ": " and "\r\n" } return size; } catch { return 0; } } /** * Check if request is streaming-related */ isStreamingRequest(url) { const streamingPatterns = [ // Manifest files /\.m3u8(\?|$)/i, /\.mpd(\?|$)/i, /manifest/i, // Media segments /\.ts(\?|$)/i, /\.m4s(\?|$)/i, /\.mp4(\?|$)/i, /segment/i, /chunk/i, // DRM/License requests /license/i, /drm/i, /widevine/i, /playready/i, /fairplay/i, // Streaming APIs /api.*stream/i, /stream.*api/i, /playback/i, /player/i ]; return streamingPatterns.some(pattern => pattern.test(url)); } /** * Check if request is essential for page functionality (should not be blocked even in streaming-only mode) */ isEssentialRequest(url) { const essentialPatterns = [ // Main page document /^https?:\/\/[^/]+\/?$/, /\/live\?/i, /\/start\/?$/i, // Essential JavaScript and CSS for page functionality /\/_next\/static\/chunks\/main-/i, /\/_next\/static\/chunks\/framework-/i, /\/_next\/static\/chunks\/webpack-/i, /\/_next\/static\/chunks\/pages\/_app-/i, /\/_next\/static\/css\//i, // Authentication and session management /\/api\/auth\//i, /\/session/i, // Essential data for streaming setup /\/api\/domain\/player-token/i, /\/stream-link/i, // Document resources (HTML, main page data) /\/_next\/data\//i, /\.html(\?|$)/i, // Favicon and essential icons /favicon\.ico/i, /\/icon-/i ]; return essentialPatterns.some(pattern => pattern.test(url)); } /** * Check if URL matches any of the allowed URL patterns */ isAllowedUrl(url) { if (this.allowedUrls.length === 0) { return false; } return this.allowedUrls.some(pattern => this.matchesUrlPattern(url, pattern)); } /** * Check if URL matches any of the blocked URL patterns */ isBlockedUrl(url) { if (this.blockedUrls.length === 0) { return false; } return this.blockedUrls.some(pattern => this.matchesUrlPattern(url, pattern)); } /** * Check if URL matches a pattern (supports wildcards and regex-like patterns) */ matchesUrlPattern(url, pattern) { try { // If pattern starts and ends with /, treat as regex if (pattern.startsWith('/') && pattern.endsWith('/')) { const regexPattern = pattern.slice(1, -1); const regex = new RegExp(regexPattern, 'i'); return regex.test(url); } // Convert glob-like pattern to regex // Escape special regex characters except * and ? const escapedPattern = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.'); const regex = new RegExp(`^${escapedPattern}$`, 'i'); return regex.test(url); } catch (error) { // If pattern is invalid, fall back to simple string matching this.logError('Invalid URL pattern', error, { pattern, url }); return url.toLowerCase().includes(pattern.toLowerCase()); } } /** * Check if a parameter template should be applied to a specific request */ shouldApplyTemplate(template, url, method) { // Check URL pattern if specified if (template.urlPattern) { if (!this.matchesUrlPattern(url, template.urlPattern)) { return false; } } // Check HTTP method if specified if (template.method) { if (method.toLowerCase() !== template.method.toLowerCase()) { return false; } } // If no filters specified, or all filters match, apply the template return true; } /** * Determine streaming request type */ getStreamingType(url) { if (!this.isStreamingRequest(url)) { return undefined; } // Manifest files if (/\.m3u8(\?|$)|\.mpd(\?|$)|manifest/i.test(url)) { return 'manifest'; } // Media segments if (/\.ts(\?|$)|\.m4s(\?|$)|\.mp4(\?|$)|segment|chunk/i.test(url)) { return 'segment'; } // DRM/License requests if (/license|drm|widevine|playready|fairplay/i.test(url)) { return 'license'; } // Streaming APIs if (/api.*stream|stream.*api|playback|player/i.test(url)) { return 'api'; } return 'other'; } /** * Calculate average response time for a set of metrics */ calculateAverageResponseTime(metrics) { if (metrics.length === 0) { return 0; } const totalTime = metrics.reduce((sum, metric) => sum + metric.responseTime, 0); return totalTime / metrics.length; } /** * Log streaming-specific error */ logStreamingError(url, statusCode, errorType, message, context) { const streamingError = { timestamp: new Date(), errorType, errorCode: statusCode.toString(), errorMessage: message, url, context: { sessionId: this.variableContext.sessionId, requestCount: this.requestCount, ...context } }; this.streamingErrors.push(streamingError); } /** * Log error with context */ logError(message, error, context) { const errorLog = { timestamp: new Date(), level: 'error', message, stack: error?.stack, context: { sessionId: this.variableContext.sessionId, requestCount: this.requestCount, ...context } }; this.errors.push(errorLog); } } exports.RequestInterceptor = RequestInterceptor; //# sourceMappingURL=request-interceptor.js.map