import { Page } from '@playwright/test'; import { PerformanceMetrics, PerformanceThresholds } from '../types'; export class PerformanceMonitor { private page: Page; private metrics: PerformanceMetrics; private startTime: number; constructor(page: Page) { this.page = page; this.metrics = { loadTime: 0, firstContentfulPaint: 0, largestContentfulPaint: 0, timeToInteractive: 0, cumulativeLayoutShift: 0, firstInputDelay: 0, }; this.startTime = 0; } async startMonitoring(): Promise { this.startTime = Date.now(); await this.page.evaluate(() => { window.performance.clearResourceTimings(); }); await this.page.evaluate(() => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach((entry) => { if (entry.entryType === 'layout-shift' && !(entry as any).hadRecentInput) { (window as any).cumulativeLayoutShift = ((window as any).cumulativeLayoutShift || 0) + (entry as any).value; } }); }); observer.observe({ entryTypes: ['layout-shift'] }); } }); } async collectMetrics(): Promise { const navigationTiming = await this.page.evaluate(() => { const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return { loadTime: timing.loadEventEnd - timing.fetchStart, domContentLoaded: timing.domContentLoadedEventEnd - timing.fetchStart, firstPaint: timing.responseStart - timing.fetchStart, }; }); const paintTiming = await this.page.evaluate(() => { const paints = performance.getEntriesByType('paint'); const fcp = paints.find((p) => p.name === 'first-contentful-paint'); return { firstContentfulPaint: fcp ? fcp.startTime : 0, }; }); const lcp = await this.page.evaluate(() => { return new Promise((resolve) => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; resolve(lastEntry ? lastEntry.startTime : 0); }); observer.observe({ entryTypes: ['largest-contentful-paint'] }); setTimeout(() => resolve(0), 5000); } else { resolve(0); } }); }); const cls = await this.page.evaluate(() => { return (window as any).cumulativeLayoutShift || 0; }); const tti = await this.page.evaluate(() => { return new Promise((resolve) => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); const longTasks = entries.filter((e) => e.duration > 50); if (longTasks.length > 0) { resolve(longTasks[0].startTime); } }); observer.observe({ entryTypes: ['longtask'] }); setTimeout(() => resolve(0), 10000); } else { resolve(0); } }); }); const fid = await this.page.evaluate(() => { return new Promise((resolve) => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries.length > 0) { resolve(entries[0].processingStart - entries[0].startTime); } }); observer.observe({ entryTypes: ['first-input'] }); setTimeout(() => resolve(0), 5000); } else { resolve(0); } }); }); this.metrics = { loadTime: navigationTiming.loadTime, firstContentfulPaint: paintTiming.firstContentfulPaint, largestContentfulPaint: lcp, timeToInteractive: tti, cumulativeLayoutShift: cls, firstInputDelay: fid, }; return this.metrics; } async measurePageLoad(): Promise { const startTime = Date.now(); await this.page.waitForLoadState('networkidle'); const endTime = Date.now(); return endTime - startTime; } async measureFirstContentfulPaint(): Promise { const fcp = await this.page.evaluate(() => { const paints = performance.getEntriesByType('paint'); const fcpEntry = paints.find((p) => p.name === 'first-contentful-paint'); return fcpEntry ? fcpEntry.startTime : 0; }); return fcp; } async measureLargestContentfulPaint(): Promise { const lcp = await this.page.evaluate(() => { return new Promise((resolve) => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; resolve(lastEntry ? lastEntry.startTime : 0); }); observer.observe({ entryTypes: ['largest-contentful-paint'] }); setTimeout(() => resolve(0), 5000); } else { resolve(0); } }); }); return lcp; } async measureCumulativeLayoutShift(): Promise { const cls = await this.page.evaluate(() => { return (window as any).cumulativeLayoutShift || 0; }); return cls; } async measureTimeToInteractive(): Promise { const tti = await this.page.evaluate(() => { return new Promise((resolve) => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); const longTasks = entries.filter((e) => e.duration > 50); if (longTasks.length > 0) { resolve(longTasks[0].startTime); } }); observer.observe({ entryTypes: ['longtask'] }); setTimeout(() => resolve(0), 10000); } else { resolve(0); } }); }); return tti; } async measureFirstInputDelay(): Promise { const fid = await this.page.evaluate(() => { return new Promise((resolve) => { if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries.length > 0) { resolve(entries[0].processingStart - entries[0].startTime); } }); observer.observe({ entryTypes: ['first-input'] }); setTimeout(() => resolve(0), 5000); } else { resolve(0); } }); }); return fid; } async measureResourceTiming(): Promise { const resources = await this.page.evaluate(() => { return performance.getEntriesByType('resource').map((r) => ({ name: r.name, duration: r.duration, size: (r as any).transferSize, type: r.initiatorType, })); }); return resources; } async measureMemoryUsage(): Promise { const memory = await this.page.evaluate(() => { return (performance as any).memory?.usedJSHeapSize || 0; }); return memory; } async measureFrameRate(): Promise { const frameRate = await this.page.evaluate(() => { return new Promise((resolve) => { let frames = 0; const startTime = performance.now(); function countFrames() { frames++; if (performance.now() - startTime >= 1000) { resolve(frames); } else { requestAnimationFrame(countFrames); } } requestAnimationFrame(countFrames); }); }); return frameRate; } async measureDomContentLoaded(): Promise { const dcl = await this.page.evaluate(() => { const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return timing.domContentLoadedEventEnd - timing.fetchStart; }); return dcl; } validateMetrics(thresholds: PerformanceThresholds): { passed: boolean; violations: string[] } { const violations: string[] = []; if (this.metrics.loadTime > thresholds.loadTime) { violations.push(`页面加载时间 ${this.metrics.loadTime}ms 超过阈值 ${thresholds.loadTime}ms`); } if (this.metrics.firstContentfulPaint > thresholds.firstContentfulPaint) { violations.push(`首次内容绘制 ${this.metrics.firstContentfulPaint}ms 超过阈值 ${thresholds.firstContentfulPaint}ms`); } if (this.metrics.largestContentfulPaint > thresholds.largestContentfulPaint) { violations.push(`最大内容绘制 ${this.metrics.largestContentfulPaint}ms 超过阈值 ${thresholds.largestContentfulPaint}ms`); } if (this.metrics.timeToInteractive > thresholds.timeToInteractive) { violations.push(`可交互时间 ${this.metrics.timeToInteractive}ms 超过阈值 ${thresholds.timeToInteractive}ms`); } if (this.metrics.cumulativeLayoutShift > thresholds.cumulativeLayoutShift) { violations.push(`累积布局偏移 ${this.metrics.cumulativeLayoutShift} 超过阈值 ${thresholds.cumulativeLayoutShift}`); } if (this.metrics.firstInputDelay > thresholds.firstInputDelay) { violations.push(`首次输入延迟 ${this.metrics.firstInputDelay}ms 超过阈值 ${thresholds.firstInputDelay}ms`); } return { passed: violations.length === 0, violations, }; } getMetrics(): PerformanceMetrics { return this.metrics; } async generateReport(): Promise { const metrics = await this.collectMetrics(); const resources = await this.measureResourceTiming(); let report = '=== 性能测试报告 ===\n\n'; report += '核心指标:\n'; report += `- 页面加载时间: ${metrics.loadTime.toFixed(2)}ms\n`; report += `- 首次内容绘制: ${metrics.firstContentfulPaint.toFixed(2)}ms\n`; report += `- 最大内容绘制: ${metrics.largestContentfulPaint.toFixed(2)}ms\n`; report += `- 可交互时间: ${metrics.timeToInteractive.toFixed(2)}ms\n`; report += `- 累积布局偏移: ${metrics.cumulativeLayoutShift.toFixed(4)}\n`; report += `- 首次输入延迟: ${metrics.firstInputDelay.toFixed(2)}ms\n\n`; report += '资源加载:\n'; const totalResources = resources.length; const totalSize = resources.reduce((sum, r) => sum + (r.size || 0), 0); const avgDuration = resources.reduce((sum, r) => sum + r.duration, 0) / totalResources; report += `- 总资源数: ${totalResources}\n`; report += `- 总大小: ${(totalSize / 1024).toFixed(2)}KB\n`; report += `- 平均加载时间: ${avgDuration.toFixed(2)}ms\n`; return report; } }