import { Page, TestInfo, expect } from '@playwright/test'; // Performance metrics collection utilities export class PerformanceCollector { private page: Page; public metrics: any; public navigationMetrics: any[]; private cdpSession: any; constructor(page: Page) { this.page = page; this.metrics = {}; this.navigationMetrics = []; this.cdpSession = null; } async initialize() { // Initialize CDP session for detailed metrics (only in Chromium) try { this.cdpSession = await this.page.context().newCDPSession(this.page); await this.cdpSession.send('Performance.enable'); } catch (error) { // CDP not available in Firefox, continue without it // Note: This will be captured in test attachments instead of console.log } // Track network requests this.page.on('response', response => { if (!this.metrics.networkRequests) this.metrics.networkRequests = []; this.metrics.networkRequests.push({ url: response.url(), status: response.status(), timing: null, // response.timing() is not available in Playwright size: response.headers()['content-length'] || 0 }); }); // Inject performance monitoring script await this.page.addInitScript(() => { (window as any).performanceMarks = {}; (window as any).markStart = (name: string) => { (window as any).performanceMarks[name] = performance.now(); }; (window as any).markEnd = (name: string) => { if ((window as any).performanceMarks[name]) { const duration = performance.now() - (window as any).performanceMarks[name]; // Note: Browser console logs are kept for debugging performance in browser console.log(`Performance: ${name} took ${duration.toFixed(2)}ms`); return duration; } }; }); } async ensurePerformanceScript() { // Ensure the performance script is available in the current page context await this.page.evaluate(() => { if (!(window as any).performanceMarks) { (window as any).performanceMarks = {}; } if (!(window as any).markStart) { (window as any).markStart = (name: string) => { (window as any).performanceMarks[name] = performance.now(); }; } if (!(window as any).markEnd) { (window as any).markEnd = (name: string) => { if ((window as any).performanceMarks[name]) { const duration = performance.now() - (window as any).performanceMarks[name]; console.log(`Performance: ${name} took ${duration.toFixed(2)}ms`); return duration; } }; } }); } async collectNavigationMetrics(label = 'navigation') { const startTime = performance.now(); const metrics = await this.page.evaluate(() => { const timing = (performance as any).timing; const navigation = performance.getEntriesByType('navigation')[0] as any; // Firefox-compatible performance metrics const paintEntries = performance.getEntriesByType('paint'); const firstPaint = paintEntries.find((entry: any) => entry.name === 'first-paint')?.startTime || 0; const firstContentfulPaint = paintEntries.find((entry: any) => entry.name === 'first-contentful-paint')?.startTime || 0; // Resource timing (works in both browsers) const resourceEntries = performance.getEntriesByType('resource'); const resourceTiming = resourceEntries.map((entry: any) => ({ name: entry.name, duration: entry.duration, transferSize: entry.transferSize || 0, decodedBodySize: entry.decodedBodySize || 0 })); return { // Core timing metrics domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart, loadComplete: timing.loadEventEnd - timing.navigationStart, firstPaint: firstPaint, firstContentfulPaint: firstContentfulPaint, // Navigation API metrics (if available) dnsLookup: navigation ? navigation.domainLookupEnd - navigation.domainLookupStart : 0, tcpConnect: navigation ? navigation.connectEnd - navigation.connectStart : 0, serverResponse: navigation ? navigation.responseEnd - navigation.requestStart : 0, // Resource counts and timing resourceCount: resourceEntries.length, resourceTiming: resourceTiming, // Memory usage (Chrome only, null in Firefox) memoryUsage: (performance as any).memory ? { used: (performance as any).memory.usedJSHeapSize, total: (performance as any).memory.totalJSHeapSize, limit: (performance as any).memory.jsHeapSizeLimit } : null, // Firefox-specific: Performance marks and measures performanceMarks: performance.getEntriesByType('mark').map((mark: any) => ({ name: mark.name, startTime: mark.startTime })), // Browser detection browser: navigator.userAgent.includes('Firefox') ? 'firefox' : 'chrome' }; }); const collectTime = performance.now() - startTime; this.navigationMetrics.push({ label, timestamp: new Date().toISOString(), metrics, collectionTime: collectTime }); return metrics; } async collectWebVitals() { return await this.page.evaluate(() => { return new Promise((resolve) => { const vitals: any = {}; let pendingVitals = 3; // LCP, FID, CLS const checkComplete = () => { pendingVitals--; if (pendingVitals <= 0) { setTimeout(() => resolve(vitals), 100); } }; // Largest Contentful Paint new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries.length > 0) { vitals.lcp = entries[entries.length - 1].startTime; } checkComplete(); }).observe({ entryTypes: ['largest-contentful-paint'] }); // First Input Delay new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries.length > 0) { vitals.fid = (entries[0] as any).processingStart - entries[0].startTime; } checkComplete(); }).observe({ entryTypes: ['first-input'] }); // Cumulative Layout Shift let clsValue = 0; new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!(entry as any).hadRecentInput) { clsValue += (entry as any).value; } } vitals.cls = clsValue; checkComplete(); }).observe({ entryTypes: ['layout-shift'] }); // Fallback timeout setTimeout(() => resolve(vitals), 3000); }); }); } async measureUserAction(actionName: string, actionFn: () => Promise) { const startTime = performance.now(); // Ensure performance script is available await this.ensurePerformanceScript(); // Mark start in browser await this.page.evaluate((name: string) => { (window as any).markStart(name); }, actionName); // Execute the action await actionFn(); // Mark end and collect metrics const browserDuration = await this.page.evaluate((name: string) => { return (window as any).markEnd(name); }, actionName); const totalDuration = performance.now() - startTime; if (!this.metrics.userActions) this.metrics.userActions = []; this.metrics.userActions.push({ action: actionName, browserDuration: browserDuration, totalDuration: totalDuration, timestamp: new Date().toISOString() }); return { browserDuration, totalDuration }; } async getDetailedMetrics() { if (this.cdpSession) { const cdpMetrics = await this.cdpSession.send('Performance.getMetrics'); this.metrics.cdpMetrics = cdpMetrics.metrics; } return this.metrics; } generateReport() { const report = { testSummary: { totalNavigations: this.navigationMetrics.length, totalUserActions: this.metrics.userActions?.length || 0, totalNetworkRequests: this.metrics.networkRequests?.length || 0 }, navigationMetrics: this.navigationMetrics, userActionMetrics: this.metrics.userActions || [], networkSummary: this.metrics.networkRequests ? { totalRequests: this.metrics.networkRequests.length, averageResponseTime: 0, // timing not available in Playwright errorCount: this.metrics.networkRequests.filter((req: any) => req.status >= 400).length } : null }; return report; } } // Convenience function to create and initialize a performance collector export async function createPerformanceCollector(page: Page): Promise { const collector = new PerformanceCollector(page); await collector.initialize(); return collector; } // Helper function to attach performance data to test reports export async function attachPerformanceData( testInfo: TestInfo, collector: PerformanceCollector, additionalData?: Record ) { // Collect Web Vitals const webVitals = await collector.collectWebVitals() as any; // Attach Web Vitals to test report await testInfo.attach('web-vitals', { contentType: 'application/json', body: JSON.stringify(webVitals, null, 2) }); // Generate final performance report const performanceReport = collector.generateReport(); // Attach performance report to test report await testInfo.attach('performance-report', { contentType: 'application/json', body: JSON.stringify(performanceReport, null, 2) }); // Attach summary metrics to test report const avgNavigationTime = collector.navigationMetrics.reduce((sum, nav) => sum + nav.metrics.loadComplete, 0) / collector.navigationMetrics.length; const summary = { averageNavigationTime: avgNavigationTime.toFixed(2), totalTestDuration: collector.metrics.userActions?.reduce((sum: number, action: any) => sum + action.totalDuration, 0).toFixed(2), slowestAction: collector.metrics.userActions?.reduce((slowest: any, action: any) => action.totalDuration > (slowest?.totalDuration || 0) ? action : slowest, null)?.action || 'N/A', networkRequests: performanceReport.testSummary.totalNetworkRequests, ...additionalData }; await testInfo.attach('performance-summary', { contentType: 'application/json', body: JSON.stringify(summary, null, 2) }); return { webVitals, performanceReport, summary }; } // Helper function to run performance assertions export function assertPerformanceMetrics( webVitals: any, initialMetrics: any, avgNavigationTime: number ) { // Performance assertions (adjust thresholds as needed) expect(avgNavigationTime).toBeLessThan(5000); // Average navigation under 5s expect(initialMetrics.loadComplete).toBeLessThan(8000); // Initial load under 8s if (webVitals.lcp) { expect(webVitals.lcp).toBeLessThan(2500); // LCP under 2.5s (good threshold) } if (webVitals.fid !== undefined) { expect(webVitals.fid).toBeLessThan(100); // FID under 100ms (good threshold) } if (webVitals.cls !== undefined) { expect(webVitals.cls).toBeLessThan(0.1); // CLS under 0.1 (good threshold) } } // Simple performance wrapper for quick tests export async function withPerformanceTracking( page: Page, testInfo: TestInfo, testName: string, testFn: (collector: PerformanceCollector) => Promise ): Promise { const collector = await createPerformanceCollector(page); const result = await testFn(collector); await attachPerformanceData(testInfo, collector, { testName }); return result; }