/** * Performance metrics collection via CDP + Playwright page.evaluate(). * * Collects Core Web Vitals and resource timing after page navigation. * Stored alongside screen data for test generation context and regression detection. * * Metrics: * - LCP (Largest Contentful Paint) — via PerformanceObserver in page * - CLS (Cumulative Layout Shift) — via PerformanceObserver in page * - TTFB (Time to First Byte) — from navigation timing * - DOMContentLoaded — from navigation timing * - Resource count and size by type */ import type { Page } from 'playwright'; export interface CoreWebVitals { lcp?: number; // ms cls?: number; // unitless score ttfb?: number; // ms fcp?: number; // First Contentful Paint ms domContentLoaded?: number; // ms loadEventEnd?: number; // ms } export interface ResourceSummary { scripts: { count: number; totalBytes: number }; styles: { count: number; totalBytes: number }; images: { count: number; totalBytes: number }; fonts: { count: number; totalBytes: number }; other: { count: number; totalBytes: number }; } export interface PagePerfMetrics { url: string; vitals: CoreWebVitals; resources: ResourceSummary; totalTransferBytes: number; requestCount: number; measuredAt: string; } export async function collectPerfMetrics(page: any, url: string): Promise { const measuredAt = new Date().toISOString(); // Inject PerformanceObserver for LCP + CLS, wait up to 3s let vitals: CoreWebVitals = {}; try { vitals = await page.evaluate(() => { return new Promise((resolve) => { const result: any = {}; // Navigation timing const nav = performance.getEntriesByType('navigation')[0] as any; if (nav) { result.ttfb = Math.round(nav.responseStart - nav.requestStart); result.domContentLoaded = Math.round(nav.domContentLoadedEventEnd); result.loadEventEnd = Math.round(nav.loadEventEnd); } let lcpDone = false; let clsDone = false; const tryResolve = () => { if (lcpDone && clsDone) resolve(result); }; // LCP try { new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries.length > 0) result.lcp = Math.round((entries[entries.length - 1] as any).startTime); lcpDone = true; tryResolve(); }).observe({ type: 'largest-contentful-paint', buffered: true }); } catch { lcpDone = true; } // CLS try { let cls = 0; new PerformanceObserver((list) => { for (const e of list.getEntries() as any[]) { if (!e.hadRecentInput) cls += e.value; } result.cls = Math.round(cls * 1000) / 1000; clsDone = true; tryResolve(); }).observe({ type: 'layout-shift', buffered: true }); } catch { clsDone = true; } // FCP try { const fcp = performance.getEntriesByName('first-contentful-paint')[0] as any; if (fcp) result.fcp = Math.round(fcp.startTime); } catch {} // Fallback resolve after 3s setTimeout(() => resolve(result), 3_000); }); }).catch(() => ({})); } catch { /* page closed or navigate already */ } // Resource timing let resources: ResourceSummary = { scripts: { count: 0, totalBytes: 0 }, styles: { count: 0, totalBytes: 0 }, images: { count: 0, totalBytes: 0 }, fonts: { count: 0, totalBytes: 0 }, other: { count: 0, totalBytes: 0 }, }; let totalTransferBytes = 0; let requestCount = 0; try { const entries = await page.evaluate(() => performance.getEntriesByType('resource').map((e: any) => ({ name: e.name, initiatorType: e.initiatorType, transferSize: e.transferSize ?? 0, })) ).catch(() => [] as any[]); for (const e of entries) { requestCount++; totalTransferBytes += e.transferSize; const cat = e.initiatorType === 'script' ? 'scripts' : e.initiatorType === 'css' || e.name?.endsWith('.css') ? 'styles' : e.initiatorType === 'img' ? 'images' : e.name?.match(/\.(woff|woff2|ttf|otf|eot)/) ? 'fonts' : 'other'; resources[cat as keyof ResourceSummary].count++; resources[cat as keyof ResourceSummary].totalBytes += e.transferSize; } } catch {} return { url, vitals, resources, totalTransferBytes, requestCount, measuredAt }; }