Files
Charon/tests/fixtures/network.ts
2026-03-04 18:34:49 +00:00

326 lines
8.9 KiB
TypeScript

/**
* Network Interceptor Fixture
*
* Intercepts all HTTP requests and responses to capture network metrics,
* log network activity, and export data for analysis.
*
* Usage in fixtures:
* import { networkInterceptor } from '../fixtures/network';
*
* test.beforeEach(async ({ page }, testInfo) => {
* const interceptor = new NetworkInterceptor();
* interceptor.attach(page);
* });
*
* test.afterEach(async ({ }, testInfo) => {
* const csv = interceptor.exportCSV();
* // Save to artifacts
* });
*/
import { Page, Request, Response } from '@playwright/test';
import { DebugLogger, NetworkLogEntry } from '../utils/debug-logger';
import { WriteStream, createWriteStream } from 'fs';
import { join } from 'path';
interface NetworkMetrics {
url: string;
method: string;
startTime: number;
status?: number;
errorMessage?: string;
requestSize: number;
responseSize: number;
duration: number;
redirectChain: string[];
requestHeaders?: Record<string, string>;
responseHeaders?: Record<string, string>;
}
export class NetworkInterceptor {
private requests = new Map<string, NetworkMetrics>();
private redirectChains = new Map<string, string[]>();
private logger?: DebugLogger;
private csvStream?: WriteStream;
constructor(logger?: DebugLogger) {
this.logger = logger;
}
/**
* Attach interceptor to a page
*/
attach(page: Page): void {
// Track request start times
page.on('request', (request: Request) => {
const url = request.url();
const method = request.method();
const metrics: NetworkMetrics = {
url,
method,
startTime: Date.now(),
requestSize: this.estimateSize(request.postDataBuffer()),
responseSize: 0,
duration: 0,
redirectChain: [],
requestHeaders: this.sanitizeHeaders(request.headers()),
};
this.requests.set(url, metrics);
// Log request
if (this.logger) {
this.logger.network({
method,
url,
elapsedMs: 0,
});
}
});
// Track response metrics
page.on('response', (response: Response) => {
const url = response.url();
const metrics = this.requests.get(url);
if (metrics) {
metrics.status = response.status();
metrics.duration = Date.now() - metrics.startTime;
metrics.responseHeaders = this.sanitizeHeaders(response.headers() as Record<string, string>);
const contentType = response.headers()['content-type'] || 'unknown';
const contentLength = response.headers()['content-length'];
if (contentLength) {
metrics.responseSize = parseInt(contentLength, 10);
}
// Log response
if (this.logger) {
this.logger.network({
method: metrics.method,
url: metrics.url,
status: metrics.status,
elapsedMs: metrics.duration,
responseContentType: contentType,
responseBodySize: metrics.responseSize,
});
}
}
});
// Track request failures
page.on('requestfailed', (request: Request) => {
const url = request.url();
const metrics = this.requests.get(url);
if (metrics) {
metrics.duration = Date.now() - metrics.startTime;
metrics.errorMessage = request.failure()?.errorText;
if (this.logger) {
this.logger.network({
method: metrics.method,
url: metrics.url,
elapsedMs: metrics.duration,
error: metrics.errorMessage,
});
}
}
});
// Track redirects
page.on('requestfinished', (request: Request) => {
const redirectChain = request.redirectedFrom();
if (redirectChain) {
const chain = [];
let current: Request | null = redirectChain;
while (current) {
chain.push(current.url());
current = current.redirectedFrom();
}
this.redirectChains.set(request.url(), chain.reverse());
}
});
}
/**
* Export all network metrics as CSV
*/
exportCSV(): string {
const headers = [
'Timestamp',
'Method',
'URL',
'Status',
'Duration (ms)',
'Request Size (bytes)',
'Response Size (bytes)',
'Error',
];
const rows: string[][] = [];
this.requests.forEach((metrics) => {
const timestamp = new Date(metrics.startTime).toISOString();
const row = [
timestamp,
metrics.method,
metrics.url,
metrics.status?.toString() || 'N/A',
metrics.duration.toString(),
metrics.requestSize.toString(),
metrics.responseSize.toString(),
metrics.errorMessage || '',
];
rows.push(row);
});
// CSV format
return [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
}
/**
* Export metrics in JSON format
*/
exportJSON(): any {
const data: any = {
summary: {
totalRequests: this.requests.size,
timestamp: new Date().toISOString(),
},
requests: Array.from(this.requests.values()).map(metrics => ({
method: metrics.method,
url: metrics.url,
status: metrics.status,
duration: metrics.duration,
requestSize: metrics.requestSize,
responseSize: metrics.responseSize,
requestHeaders: metrics.requestHeaders,
responseHeaders: metrics.responseHeaders,
error: metrics.errorMessage,
timestamp: new Date(metrics.startTime).toISOString(),
})),
};
return data;
}
/**
* Get slow requests (above threshold)
*/
getSlowRequests(thresholdMs: number = 1000): NetworkMetrics[] {
return Array.from(this.requests.values())
.filter(m => m.duration > thresholdMs)
.sort((a, b) => b.duration - a.duration);
}
/**
* Get failed requests
*/
getFailedRequests(): NetworkMetrics[] {
return Array.from(this.requests.values())
.filter(m => m.status && (m.status >= 400 || m.errorMessage));
}
/**
* Get request count by status code
*/
getStatusCodeDistribution(): Record<string, number> {
const distribution: Record<string, number> = {};
this.requests.forEach((metrics) => {
const code = metrics.status?.toString() || 'error';
distribution[code] = (distribution[code] || 0) + 1;
});
return distribution;
}
/**
* Get average response time by URL pattern
*/
getAverageResponseTimeByPattern(): Record<string, number> {
const patterns: Record<string, { total: number; count: number }> = {};
this.requests.forEach((metrics) => {
const pattern = this.getURLPattern(metrics.url);
if (!patterns[pattern]) {
patterns[pattern] = { total: 0, count: 0 };
}
patterns[pattern].total += metrics.duration;
patterns[pattern].count += 1;
});
const averages: Record<string, number> = {};
Object.entries(patterns).forEach(([pattern, data]) => {
averages[pattern] = Math.round(data.total / data.count);
});
return averages;
}
/**
* Save metrics to file
*/
async saveMetrics(filepath: string, format: 'csv' | 'json' = 'csv'): Promise<void> {
const fs = await import('fs').then(m => m.promises);
let data: string;
if (format === 'csv') {
data = this.exportCSV();
} else {
data = JSON.stringify(this.exportJSON(), null, 2);
}
await fs.writeFile(filepath, data);
}
// ────────────────────────────────────────────────────────────────────
// Private helpers
// ────────────────────────────────────────────────────────────────────
private sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const sanitized = { ...headers };
const sensitiveHeaders = [
'authorization',
'cookie',
'x-api-key',
'x-emergency-token',
'x-auth-token',
'set-cookie',
];
Object.keys(sanitized).forEach(key => {
if (sensitiveHeaders.some(sh => key.toLowerCase().includes(sh))) {
sanitized[key] = '[REDACTED]';
}
});
return sanitized;
}
private estimateSize(buffer?: Buffer): number {
return buffer ? buffer.length : 0;
}
private getURLPattern(url: string): string {
try {
const parsed = new URL(url);
// Return path pattern (remove specific IDs)
const path = parsed.pathname.replace(/\/\d+/g, '/{id}');
return `${parsed.origin}${path}`;
} catch {
return url;
}
}
}
/**
* Create a network interceptor and attach to page
*/
export function createNetworkInterceptor(page: Page, logger?: DebugLogger): NetworkInterceptor {
const interceptor = new NetworkInterceptor(logger);
interceptor.attach(page);
return interceptor;
}