diff --git a/test-framework/shared/utils/performance/CoreWebVitals.ts b/test-framework/shared/utils/performance/CoreWebVitals.ts new file mode 100644 index 0000000..cb1a85d --- /dev/null +++ b/test-framework/shared/utils/performance/CoreWebVitals.ts @@ -0,0 +1,83 @@ +import { Page } from '@playwright/test'; +import { CoreWebVitals } from '../../types'; + +export class CoreWebVitals { + constructor(private page: Page) {} + + async measureLCP(): Promise { + 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 { + 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 { + 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 { + const [lcp, fid, cls] = await Promise.all([ + this.measureLCP(), + this.measureFID(), + this.measureCLS() + ]); + + return { + largestContentfulPaint: lcp, + firstInputDelay: fid, + cumulativeLayoutShift: cls + }; + } + + async measureTTFB(): Promise { + return await this.page.evaluate(() => { + const timing = performance.timing; + return timing.responseStart - timing.navigationStart; + }); + } + + async measureFCP(): Promise { + 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 }); + }); + }); + } +} diff --git a/test-framework/shared/utils/performance/LighthouseRunner.ts b/test-framework/shared/utils/performance/LighthouseRunner.ts new file mode 100644 index 0000000..1ea25aa --- /dev/null +++ b/test-framework/shared/utils/performance/LighthouseRunner.ts @@ -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 { + 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; + } +} diff --git a/test-framework/shared/utils/performance/PerformanceMonitor.ts b/test-framework/shared/utils/performance/PerformanceMonitor.ts new file mode 100644 index 0000000..0b98151 --- /dev/null +++ b/test-framework/shared/utils/performance/PerformanceMonitor.ts @@ -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 { + 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 { + 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 { + 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 + }; + }); + } +}