You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

343 lines
12 KiB

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<void>) {
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<PerformanceCollector> {
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<string, any>
) {
// 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<T>(
page: Page,
testInfo: TestInfo,
testName: string,
testFn: (collector: PerformanceCollector) => Promise<T>
): Promise<T> {
const collector = await createPerformanceCollector(page);
const result = await testFn(collector);
await attachPerformanceData(testInfo, collector, { testName });
return result;
}