/** * 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; responseHeaders?: Record; } export class NetworkInterceptor { private requests = new Map(); private redirectChains = new Map(); 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); 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 { const distribution: Record = {}; 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 { const patterns: Record = {}; 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 = {}; 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 { 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): Record { 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; }