/** * Network-action correlator — captures XHR/fetch requests that occur during * a named UI action (click, form submit, navigation) and maps them to API * endpoint contracts. * * Usage: * const correlator = createCorrelator(); * const detach = attachNetworkInterceptor(page, correlator); * correlator.startWindow('Submit login form'); * await page.click('#login-btn'); * const correlation = correlator.endWindow(); * detach(); * const result = correlator.getResult(); */ import type { Page, Request, Response } from 'playwright'; export interface NetworkRequestRecord { url: string; urlPattern: string; method: string; bodySchema: Record | null; status: number | null; responseSchema: Record | null; isGraphQL: boolean; graphQLOperation?: string; resourceType: string; correlatedActionLabel?: string; } export interface ActionCorrelation { actionLabel: string; requests: NetworkRequestRecord[]; } export interface NetworkCorrelationResult { correlations: ActionCorrelation[]; uniqueEndpoints: string[]; graphqlOperations: string[]; totalRequestsCaptured: number; coverage: number; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function extractUrlPattern(url: string): string { try { const u = new URL(url); const path = u.pathname .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':uuid') .replace(/\/[0-9a-f]{24}(\/|$)/g, '/:mongoId$1') .replace(/\/\d{6,}(\/|$)/g, '/:id$1') .replace(/\/\d{1,5}(\/|$)/g, '/:id$1'); return path; } catch { return url.slice(0, 80); } } function extractBodySchema(bodyText: string): Record | null { if (!bodyText || bodyText.length > 50_000) return null; let parsed: unknown; try { parsed = JSON.parse(bodyText); } catch { return null; } const schema: Record = {}; let keyCount = 0; function traverse(value: unknown, prefix: string, depth: number): void { if (keyCount >= 30 || depth > 3) return; if (value === null || value === undefined) return; if (Array.isArray(value)) { schema[prefix] = 'array'; keyCount++; return; } if (typeof value === 'object') { for (const [k, v] of Object.entries(value as Record)) { if (keyCount >= 30) break; const key = prefix ? `${prefix}.${k}` : k; if (v === null) { schema[key] = 'null'; keyCount++; } else if (Array.isArray(v)) { schema[key] = 'array'; keyCount++; } else if (typeof v === 'object') { traverse(v, key, depth + 1); } else { schema[key] = typeof v; keyCount++; } } return; } // Primitive at top level if (prefix) { schema[prefix] = typeof value; keyCount++; } } traverse(parsed, '', 0); return keyCount > 0 ? schema : null; } const ANALYTICS_DOMAINS = [ 'google-analytics', 'doubleclick', 'hotjar', 'mixpanel', 'segment', 'sentry', 'datadog', 'newrelic', 'amplitude', 'fullstory', ]; const API_PATH_PATTERNS = [ '/api/', '/graphql', '/v1/', '/v2/', '/rest/', '/query', '/rpc', '/trpc', ]; const EXCLUDED_RESOURCE_TYPES = new Set(['image', 'font', 'stylesheet', 'media', 'websocket']); function isApiRequest(url: string, resourceType: string): boolean { if (EXCLUDED_RESOURCE_TYPES.has(resourceType)) return false; try { const hostname = new URL(url).hostname; if (ANALYTICS_DOMAINS.some((d) => hostname.includes(d))) return false; } catch { return false; } if (resourceType === 'xhr' || resourceType === 'fetch') return true; const lower = url.toLowerCase(); return API_PATH_PATTERNS.some((p) => lower.includes(p)); } // --------------------------------------------------------------------------- // Correlator // --------------------------------------------------------------------------- interface NetworkCorrelator { startWindow(actionLabel: string): void; recordRequest(rec: NetworkRequestRecord): void; endWindow(): ActionCorrelation; getResult(): NetworkCorrelationResult; } export function createCorrelator(): NetworkCorrelator { let currentLabel: string | null = null; let currentRequests: NetworkRequestRecord[] = []; const correlations: ActionCorrelation[] = []; return { startWindow(actionLabel: string): void { currentLabel = actionLabel; currentRequests = []; }, recordRequest(rec: NetworkRequestRecord): void { if (currentLabel === null) return; currentRequests.push({ ...rec, correlatedActionLabel: currentLabel }); }, endWindow(): ActionCorrelation { const label = currentLabel ?? '(unknown)'; const correlation: ActionCorrelation = { actionLabel: label, requests: currentRequests, }; correlations.push(correlation); currentLabel = null; currentRequests = []; return correlation; }, getResult(): NetworkCorrelationResult { const uniqueEndpoints = [ ...new Set( correlations.flatMap((c) => c.requests.map((r) => r.urlPattern)), ), ]; const graphqlOperations = [ ...new Set( correlations .flatMap((c) => c.requests) .filter((r) => r.isGraphQL && r.graphQLOperation) .map((r) => r.graphQLOperation as string), ), ]; const totalRequestsCaptured = correlations.reduce( (sum, c) => sum + c.requests.length, 0, ); const coverage = correlations.length === 0 ? 0 : correlations.filter((c) => c.requests.length >= 1).length / correlations.length; return { correlations, uniqueEndpoints, graphqlOperations, totalRequestsCaptured, coverage, }; }, }; } // --------------------------------------------------------------------------- // Page interceptor // --------------------------------------------------------------------------- export function attachNetworkInterceptor( page: Page, correlator: ReturnType, ): () => void { const pendingRequests = new Map(); const onRequest = (req: Request): void => { const url = req.url(); const resourceType = req.resourceType(); if (!isApiRequest(url, resourceType)) return; let bodyText = ''; try { bodyText = req.postData() ?? ''; } catch { // postData() may throw on non-POST requests in some Playwright versions } pendingRequests.set(url, { bodyText, resourceType }); }; const onResponse = async (res: Response): Promise => { const url = res.url(); const pending = pendingRequests.get(url); if (!pending) return; pendingRequests.delete(url); const method = res.request().method(); let bodySchema: Record | null = null; try { bodySchema = extractBodySchema(pending.bodyText); } catch { // ignore schema extraction failures } let responseSchema: Record | null = null; let isGraphQL = false; let graphQLOperation: string | undefined; const contentType = res.headers()['content-type'] ?? ''; if (contentType.includes('application/json')) { try { const respText = await res.text(); if (respText.length < 20_000) { responseSchema = extractBodySchema(respText); } } catch { // response body may no longer be available } } // Detect GraphQL from request body try { const bodyParsed = JSON.parse(pending.bodyText) as Record; if (typeof bodyParsed?.operationName === 'string') { isGraphQL = true; graphQLOperation = bodyParsed.operationName; } } catch { // not JSON or no operationName } const rec: NetworkRequestRecord = { url, urlPattern: extractUrlPattern(url), method, bodySchema, status: res.status(), responseSchema, isGraphQL, graphQLOperation, resourceType: pending.resourceType, }; correlator.recordRequest(rec); }; page.on('request', onRequest); page.on('response', onResponse); return () => { page.off('request', onRequest); page.off('response', onResponse); }; }