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