import { Page } from '@playwright/test'; import { PerformanceMetrics, PerformanceThresholds } from '../types'; export class PerformanceMonitor { private page: Page; private metrics: PerformanceMetrics; constructor(page: Page) { this.page = page; this.metrics = { loadTime: 0, firstContentfulPaint: 0, largestContentfulPaint: 0, timeToInteractive: 0, cumulativeLayoutShift: 0, firstInputDelay: 0, }; } async startMonitoring(): Promise { 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 || 0); } }); 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) { const entry = entries[0] as any; resolve((entry?.processingStart || 0) - (entry?.startTime || 0)); } }); 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 || 0); } }); 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) { const entry = entries[0] as any; resolve((entry?.processingStart || 0) - (entry?.startTime || 0)); } }); 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) => { const resource = r as any; return { name: resource.name, duration: resource.duration, size: resource.transferSize, type: resource.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; } async measureFirstMeaningfulPaint(): Promise { const fmp = 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].startTime); } }); observer.observe({ entryTypes: ['first-meaningful-paint'] }); setTimeout(() => resolve(0), 5000); } else { resolve(0); } }); }); return fmp; } async measureNetworkPerformance(): Promise<{ dnsLookup: number; tcpConnection: number; sslHandshake: number; requestTime: number; responseTime: number; totalTime: number; }> { const timing = await this.page.evaluate(() => { const perf = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return { dnsLookup: perf.domainLookupEnd - perf.domainLookupStart, tcpConnection: perf.connectEnd - perf.connectStart, sslHandshake: perf.secureConnectionStart > 0 ? perf.connectEnd - perf.secureConnectionStart : 0, requestTime: perf.responseStart - perf.requestStart, responseTime: perf.responseEnd - perf.responseStart, totalTime: perf.loadEventEnd - perf.fetchStart, }; }); return timing; } async measureBatteryImpact(): Promise<{ estimatedImpact: string; recommendations: string[]; }> { const metrics = await this.collectMetrics(); const recommendations: string[] = []; let impact = 'low'; if (metrics.loadTime > 3000) { recommendations.push('页面加载时间过长,建议优化资源加载'); impact = 'high'; } if (metrics.firstInputDelay > 100) { recommendations.push('首次输入延迟较高,建议优化JavaScript执行'); impact = impact === 'high' ? 'high' : 'medium'; } if (metrics.largestContentfulPaint > 2500) { recommendations.push('最大内容绘制时间过长,建议优化关键渲染路径'); impact = impact === 'high' ? 'high' : 'medium'; } return { estimatedImpact: impact, recommendations, }; } async validateLCP(value: number, threshold: number = 2500): boolean { return value <= threshold; } async validateFID(value: number, threshold: number = 100): boolean { return value <= threshold; } async validateCLS(value: number, threshold: number = 0.1): boolean { return value <= threshold; } async validateTTI(value: number, threshold: number = 3500): boolean { return value <= threshold; } async validateTTFB(value: number, threshold: number = 600): boolean { return value <= threshold; } async validateFCP(value: number, threshold: number = 1800): boolean { return value <= threshold; } async getCoreWebVitalsSummary(): Promise<{ lcp: { value: number; threshold: number; passed: boolean }; fid: { value: number; threshold: number; passed: boolean }; cls: { value: number; threshold: number; passed: boolean }; tti: { value: number; threshold: number; passed: boolean }; ttfb: { value: number; threshold: number; passed: boolean }; fcp: { value: number; threshold: number; passed: boolean }; }> { const metrics = await this.collectMetrics(); const ttfb = await this.measureFirstByteTime(); const fcp = await this.measureFirstContentfulPaint(); return { lcp: { value: metrics.largestContentfulPaint, threshold: 2500, passed: await this.validateLCP(metrics.largestContentfulPaint), }, fid: { value: metrics.firstInputDelay, threshold: 100, passed: await this.validateFID(metrics.firstInputDelay), }, cls: { value: metrics.cumulativeLayoutShift, threshold: 0.1, passed: await this.validateCLS(metrics.cumulativeLayoutShift), }, tti: { value: metrics.timeToInteractive, threshold: 3500, passed: await this.validateTTI(metrics.timeToInteractive), }, ttfb: { value: ttfb, threshold: 600, passed: await this.validateTTFB(ttfb), }, fcp: { value: fcp, threshold: 1800, passed: await this.validateFCP(fcp), }, }; } async measureFirstByteTime(): Promise { const ttfb = await this.page.evaluate(() => { const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return timing.responseStart - timing.fetchStart; }); return ttfb; } async measureMobileSpecificMetrics(): Promise<{ touchResponseTime: number; scrollPerformance: number; gestureLatency: number; }> { const touchResponseTime = await this.page.evaluate(() => { return new Promise((resolve) => { const startTime = performance.now(); document.addEventListener('touchstart', () => { resolve(performance.now() - startTime); }, { once: true }); setTimeout(() => resolve(0), 1000); }); }); const scrollPerformance = await this.page.evaluate(() => { return new Promise((resolve) => { const startTime = performance.now(); let frames = 0; function countFrames() { frames++; if (performance.now() - startTime >= 1000) { resolve(frames); } else { requestAnimationFrame(countFrames); } } requestAnimationFrame(countFrames); }); }); const gestureLatency = await this.page.evaluate(() => { return new Promise((resolve) => { const startTime = performance.now(); document.addEventListener('touchmove', () => { resolve(performance.now() - startTime); }, { once: true }); setTimeout(() => resolve(0), 500); }); }); return { touchResponseTime, scrollPerformance, gestureLatency, }; } }