feat(e2e): 添加完整的E2E测试框架和测试用例
添加Playwright测试框架配置和基础页面对象 实现冒烟测试用例覆盖首页和联系页面核心功能 更新导航组件以支持滚动高亮功能 添加BackButton组件统一返回按钮行为 配置Woodpecker CI集成和测试报告生成
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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<void> {
|
||||
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<PerformanceMetrics> {
|
||||
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<number>((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'] });
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const cls = await this.page.evaluate(() => {
|
||||
return (window as any).cumulativeLayoutShift || 0;
|
||||
});
|
||||
|
||||
const tti = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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'] });
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const fid = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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'] });
|
||||
} 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<number> {
|
||||
const startTime = Date.now();
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
async measureFirstContentfulPaint(): Promise<number> {
|
||||
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<number> {
|
||||
const lcp = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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<number> {
|
||||
const cls = await this.page.evaluate(() => {
|
||||
return (window as any).cumulativeLayoutShift || 0;
|
||||
});
|
||||
return cls;
|
||||
}
|
||||
|
||||
async measureTimeToInteractive(): Promise<number> {
|
||||
const tti = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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<number> {
|
||||
const fid = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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<any[]> {
|
||||
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<number> {
|
||||
const memory = await this.page.evaluate(() => {
|
||||
return (performance as any).memory?.usedJSHeapSize || 0;
|
||||
});
|
||||
return memory;
|
||||
}
|
||||
|
||||
async measureFrameRate(): Promise<number> {
|
||||
const frameRate = await this.page.evaluate(() => {
|
||||
return new Promise<number>((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<number> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user