feat(e2e): 添加完整的E2E测试框架和测试用例

添加Playwright测试框架配置和基础页面对象
实现冒烟测试用例覆盖首页和联系页面核心功能
更新导航组件以支持滚动高亮功能
添加BackButton组件统一返回按钮行为
配置Woodpecker CI集成和测试报告生成
This commit is contained in:
张翔
2026-02-27 10:30:33 +08:00
parent 4a616fe96e
commit 5d5b7feb0a
50 changed files with 6765 additions and 46 deletions
+313
View File
@@ -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;
}
}
+254
View File
@@ -0,0 +1,254 @@
import { ContactFormData, TestData } from '../types';
export class TestDataGenerator {
private static readonly FIRST_NAMES = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄', '周', '吴'];
private static readonly LAST_NAMES = ['伟', '芳', '娜', '秀英', '敏', '静', '丽', '强', '磊', '军'];
private static readonly COMPANIES = ['科技有限公司', '信息技术有限公司', '网络技术有限公司', '数据科技有限公司', '智能科技有限公司'];
private static readonly SUBJECTS = ['产品咨询', '技术支持', '商务合作', '其他', '意见反馈'];
static generateName(): string {
const first = this.FIRST_NAMES[Math.floor(Math.random() * this.FIRST_NAMES.length)];
const last = this.LAST_NAMES[Math.floor(Math.random() * this.LAST_NAMES.length)];
return `${first}${last}`;
}
static generateEmail(name?: string): string {
const username = name || this.generateName();
const domains = ['example.com', 'test.com', 'demo.com'];
const domain = domains[Math.floor(Math.random() * domains.length)];
return `${username}@${domain}`;
}
static generatePhone(): string {
const prefix = ['138', '139', '136', '137', '158', '159'][Math.floor(Math.random() * 6)];
const middle = Math.floor(Math.random() * 9000 + 1000);
const suffix = Math.floor(Math.random() * 9000 + 1000);
return `${prefix}${middle}${suffix}`;
}
static generateCompany(): string {
const prefix = ['创新', '未来', '智慧', '科技', '数字'][Math.floor(Math.random() * 5)];
const suffix = this.COMPANIES[Math.floor(Math.random() * this.COMPANIES.length)];
return `${prefix}${suffix}`;
}
static generateMessage(minLength: number = 10, maxLength: number = 100): string {
const messages = [
'您好,我对贵公司的产品很感兴趣,希望能了解更多信息。',
'请问贵公司是否有相关的技术支持服务?',
'我们正在寻找合作伙伴,希望能与贵公司建立联系。',
'贵公司的产品功能很强大,希望能安排一次演示。',
'我对贵公司的服务有些建议和想法,希望能与您交流。',
'请问贵公司的产品价格如何?是否有优惠政策?',
'我们公司正在评估相关技术方案,希望能了解贵公司的解决方案。',
'您好,我想咨询一下贵公司的产品定制服务。',
];
return messages[Math.floor(Math.random() * messages.length)];
}
static generateSubject(): string {
return this.SUBJECTS[Math.floor(Math.random() * this.SUBJECTS.length)];
}
static generateContactFormData(): ContactFormData {
return {
name: this.generateName(),
email: this.generateEmail(),
phone: this.generatePhone(),
company: this.generateCompany(),
message: this.generateMessage(),
subject: this.generateSubject(),
};
}
static generateTestData(): TestData {
return {
name: this.generateName(),
email: this.generateEmail(),
phone: this.generatePhone(),
company: this.generateCompany(),
message: this.generateMessage(),
};
}
static generateInvalidEmail(): string {
const invalidEmails = [
'invalid-email',
'@example.com',
'user@',
'user@domain',
'user domain.com',
];
return invalidEmails[Math.floor(Math.random() * invalidEmails.length)];
}
static generateInvalidPhone(): string {
const invalidPhones = [
'123',
'1234567890123',
'abcdefghijk',
'123-456-7890',
];
return invalidPhones[Math.floor(Math.random() * invalidPhones.length)];
}
static generateShortMessage(): string {
return '测试';
}
static generateLongMessage(): string {
return 'A'.repeat(1001);
}
static generateSpecialCharacters(): string {
return '!@#$%^&*()_+-=[]{}|;:,.<>?/~`';
}
static generateChineseCharacters(): string {
return '这是一段中文测试文本,包含了一些特殊字符:!@#¥%……&*()——+';
}
static generateMixedContent(): string {
return 'Hello 世界!This is a mixed content test 测试。';
}
static generateNumberString(length: number): string {
return Math.random().toString().slice(2, 2 + length);
}
static generateAlphanumeric(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
static generateWhitespace(): string {
return ' \t\n\r ';
}
static generateUrl(): string {
const urls = [
'https://example.com',
'http://test.com',
'https://demo.com/path',
'http://example.com/page?param=value',
];
return urls[Math.floor(Math.random() * urls.length)];
}
static generateDate(): string {
const date = new Date();
date.setDate(date.getDate() + Math.floor(Math.random() * 30));
return date.toISOString().split('T')[0];
}
static generateTime(): string {
const hours = Math.floor(Math.random() * 24);
const minutes = Math.floor(Math.random() * 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
static generateBoolean(): boolean {
return Math.random() < 0.5;
}
static generateNumber(min: number = 0, max: number = 100): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
static generateFloat(min: number = 0, max: number = 100, decimals: number = 2): number {
const num = Math.random() * (max - min) + min;
return parseFloat(num.toFixed(decimals));
}
static generateArray<T>(generator: () => T, length: number = 5): T[] {
return Array.from({ length }, generator);
}
static generateObject(): Record<string, any> {
return {
name: this.generateName(),
email: this.generateEmail(),
age: this.generateNumber(18, 65),
active: this.generateBoolean(),
score: this.generateFloat(0, 100),
};
}
static generateUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
static generateIPv4(): string {
return `${this.generateNumber(0, 255)}.${this.generateNumber(0, 255)}.${this.generateNumber(0, 255)}.${this.generateNumber(0, 255)}`;
}
static generateMacAddress(): string {
const hex = '0123456789ABCDEF';
let mac = '';
for (let i = 0; i < 6; i++) {
if (i > 0) mac += ':';
mac += hex[Math.floor(Math.random() * 16)];
mac += hex[Math.floor(Math.random() * 16)];
}
return mac;
}
static generateColor(): string {
return `#${this.generateAlphanumeric(6)}`;
}
static generateJson(): string {
const obj = this.generateObject();
return JSON.stringify(obj);
}
static generateXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<root>
<name>${this.generateName()}</name>
<email>${this.generateEmail()}</email>
<age>${this.generateNumber(18, 65)}</age>
</root>`;
}
static generateCsv(): string {
const headers = ['Name,Email,Phone,Company'];
const rows = this.generateArray(() =>
`${this.generateName()},${this.generateEmail()},${this.generatePhone()},${this.generateCompany()}`,
5
);
return [...headers, ...rows].join('\n');
}
static generateHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>${this.generateName()}</h1>
<p>${this.generateMessage()}</p>
</body>
</html>`;
}
static generateMarkdown(): string {
return `# ${this.generateName()}
## Contact Information
- Email: ${this.generateEmail()}
- Phone: ${this.generatePhone()}
## Message
${this.generateMessage()}`;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { DeviceConfig } from '../types';
export const devices: Record<string, DeviceConfig> = {
'desktop-1920x1080': {
name: 'Desktop 1920x1080',
viewport: { width: 1920, height: 1080 },
isMobile: false,
},
'desktop-1366x768': {
name: 'Desktop 1366x768',
viewport: { width: 1366, height: 768 },
isMobile: false,
},
'desktop-1280x720': {
name: 'Desktop 1280x720',
viewport: { width: 1280, height: 720 },
isMobile: false,
},
'laptop-1440x900': {
name: 'Laptop 1440x900',
viewport: { width: 1440, height: 900 },
isMobile: false,
},
'laptop-1024x768': {
name: 'Laptop 1024x768',
viewport: { width: 1024, height: 768 },
isMobile: false,
},
'tablet-768x1024': {
name: 'Tablet 768x1024',
viewport: { width: 768, height: 1024 },
isMobile: true,
},
'tablet-834x1194': {
name: 'Tablet 834x1194 (iPad Pro)',
viewport: { width: 834, height: 1194 },
isMobile: true,
},
'mobile-375x667': {
name: 'Mobile 375x667 (iPhone SE)',
viewport: { width: 375, height: 667 },
isMobile: true,
},
'mobile-390x844': {
name: 'Mobile 390x844 (iPhone 12)',
viewport: { width: 390, height: 844 },
isMobile: true,
},
'mobile-414x896': {
name: 'Mobile 414x896 (iPhone 11)',
viewport: { width: 414, height: 896 },
isMobile: true,
},
'mobile-360x640': {
name: 'Mobile 360x640 (Android)',
viewport: { width: 360, height: 640 },
isMobile: true,
},
'mobile-412x915': {
name: 'Mobile 412x915 (Pixel 5)',
viewport: { width: 412, height: 915 },
isMobile: true,
},
};
export const desktopDevices = Object.entries(devices)
.filter(([_, config]) => !config.isMobile)
.map(([key, config]) => ({ key, ...config }));
export const mobileDevices = Object.entries(devices)
.filter(([_, config]) => config.isMobile)
.map(([key, config]) => ({ key, ...config }));
export const tabletDevices = Object.entries(devices)
.filter(([_, config]) => config.isMobile && config.viewport.width >= 768)
.map(([key, config]) => ({ key, ...config }));
export const getDevice = (key: string): DeviceConfig => {
return devices[key] || devices['desktop-1280x720'];
};
export const getAllDevices = (): DeviceConfig[] => {
return Object.values(devices);
};
export const getDesktopDevices = (): DeviceConfig[] => {
return desktopDevices.map(d => devices[d.key]);
};
export const getMobileDevices = (): DeviceConfig[] => {
return mobileDevices.map(d => devices[d.key]);
};
export const getTabletDevices = (): DeviceConfig[] => {
return tabletDevices.map(d => devices[d.key]);
};
export const getBreakpoints = () => {
return {
xs: 0,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536,
};
};
export const isMobile = (width: number): boolean => {
const breakpoints = getBreakpoints();
return width < breakpoints.lg;
};
export const isTablet = (width: number): boolean => {
const breakpoints = getBreakpoints();
return width >= breakpoints.md && width < breakpoints.lg;
};
export const isDesktop = (width: number): boolean => {
const breakpoints = getBreakpoints();
return width >= breakpoints.lg;
};