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
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;
|
|
}
|