feat: add performance testing utilities

This commit is contained in:
张翔
2026-03-06 12:08:30 +08:00
parent 50ecd7a241
commit f8a080d0ca
3 changed files with 226 additions and 0 deletions
@@ -0,0 +1,83 @@
import { Page } from '@playwright/test';
import { CoreWebVitals } from '../../types';
export class CoreWebVitals {
constructor(private page: Page) {}
async measureLCP(): Promise<number> {
return await this.page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
resolve(lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
});
}
async measureFID(): Promise<number> {
return await this.page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const firstEntry = entries[0];
resolve(firstEntry.processingStart - firstEntry.startTime);
}).observe({ type: 'first-input', buffered: true });
});
});
}
async measureCLS(): Promise<number> {
return await this.page.evaluate(() => {
return new Promise((resolve) => {
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
const value = entry.value;
clsValue = Math.max(clsValue, value);
}
}
}).observe({ type: 'layout-shift', buffered: true });
setTimeout(() => resolve(clsValue), 5000);
});
});
}
async measureAll(): Promise<CoreWebVitals> {
const [lcp, fid, cls] = await Promise.all([
this.measureLCP(),
this.measureFID(),
this.measureCLS()
]);
return {
largestContentfulPaint: lcp,
firstInputDelay: fid,
cumulativeLayoutShift: cls
};
}
async measureTTFB(): Promise<number> {
return await this.page.evaluate(() => {
const timing = performance.timing;
return timing.responseStart - timing.navigationStart;
});
}
async measureFCP(): Promise<number> {
return await this.page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const fcpEntry = entries.find((entry: any) => entry.name === 'first-contentful-paint');
if (fcpEntry) {
resolve(fcpEntry.startTime);
}
}).observe({ type: 'paint', buffered: true });
});
});
}
}
@@ -0,0 +1,84 @@
import { Page } from '@playwright/test';
import { LighthouseResult } from '../../types';
export class LighthouseRunner {
constructor(private page: Page) {}
async runLighthouse(url: string): Promise<LighthouseResult> {
const results = await this.page.evaluate(async () => {
return new Promise((resolve) => {
if (!(window as any).lighthouse) {
resolve({
performance: 0,
accessibility: 0,
bestPractices: 0,
seo: 0,
pwa: 0
});
return;
}
(window as any).lighthouse(url, {
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo', 'pwa']
}).then((result: any) => {
resolve({
performance: Math.round(result.categories.performance.score * 100),
accessibility: Math.round(result.categories.accessibility.score * 100),
bestPractices: Math.round(result.categories['best-practices'].score * 100),
seo: Math.round(result.categories.seo.score * 100),
pwa: Math.round(result.categories.pwa.score * 100)
});
});
});
});
return results;
}
async runPerformanceAudit(): Promise<{
score: number;
metrics: {
firstContentfulPaint: number;
largestContentfulPaint: number;
cumulativeLayoutShift: number;
firstInputDelay: number;
speedIndex: number;
};
}> {
const results = await this.page.evaluate(async () => {
return new Promise((resolve) => {
if (!(window as any).lighthouse) {
resolve({
score: 0,
metrics: {
firstContentfulPaint: 0,
largestContentfulPaint: 0,
cumulativeLayoutShift: 0,
firstInputDelay: 0,
speedIndex: 0
}
});
return;
}
(window as any).lighthouse(location.href, {
onlyCategories: ['performance']
}).then((result: any) => {
const audits = result.audits;
resolve({
score: Math.round(result.categories.performance.score * 100),
metrics: {
firstContentfulPaint: audits['first-contentful-paint'].numericValue,
largestContentfulPaint: audits['largest-contentful-paint'].numericValue,
cumulativeLayoutShift: audits['cumulative-layout-shift'].numericValue,
firstInputDelay: audits['max-potential-fid'].numericValue,
speedIndex: audits['speed-index'].numericValue
}
});
});
});
});
return results;
}
}
@@ -0,0 +1,59 @@
import { Page } from '@playwright/test';
import { PerformanceMetrics, NetworkTiming, ResourceTiming } from '../../types';
export class PerformanceMonitor {
constructor(private page: Page) {}
async measurePageLoad(): Promise<PerformanceMetrics> {
const metrics = await this.page.evaluate(() => {
const performance = window.performance;
const timing = performance.timing;
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
loadTime: timing.loadEventEnd - timing.navigationStart,
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
firstPaint: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0,
firstContentfulPaint: navigation ? navigation.domContentLoadedEventEnd - navigation.fetchStart : 0,
};
});
return metrics;
}
async measureNetworkTiming(): Promise<NetworkTiming> {
return await this.page.evaluate(() => {
const timing = performance.timing;
return {
dns: timing.domainLookupEnd - timing.domainLookupStart,
tcp: timing.connectEnd - timing.connectStart,
ssl: timing.connectEnd - timing.secureConnectionStart,
request: timing.responseStart - timing.requestStart,
response: timing.responseEnd - timing.responseStart,
total: timing.loadEventEnd - timing.navigationStart,
};
});
}
async measureResourceTiming(): Promise<ResourceTiming[]> {
return await this.page.evaluate(() => {
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
return resources.map(r => ({
name: r.name,
duration: r.duration,
size: r.transferSize,
type: r.initiatorType
}));
});
}
async measureMemoryUsage(): Promise<{ usedJSHeapSize: number; totalJSHeapSize: number }> {
return await this.page.evaluate(() => {
const memory = (performance as any).memory;
return {
usedJSHeapSize: memory.usedJSHeapSize,
totalJSHeapSize: memory.totalJSHeapSize
};
});
}
}