import { expect, type Page } from '@playwright/test'; /** * Core Web Vitals budget gates for the public viewer gallery. * Generous for shared CI + DEV LXC — catches order-of-magnitude regressions. */ export const CWV_BUDGET = { /** Largest Contentful Paint (ms) after navigation settles. */ lcp: process.env.CI ? 12_000 : 8_000, /** Cumulative Layout Shift (unitless, 0–1+). */ cls: 0.2, } as const; export type WebVitalsSample = { lcp: number; cls: number; /** True when LCP came from a real LCP entry (not navigation-timing fallback). */ lcpFromPaint: boolean; }; /** Install observers before navigation (call once per test). */ export async function installWebVitalsCollector(page: Page): Promise { await page.addInitScript(() => { const w = window as Window & { __punimtagCwv?: { lcp: number; cls: number } }; w.__punimtagCwv = { lcp: 0, cls: 0 }; try { new PerformanceObserver((list) => { const entries = list.getEntries(); const last = entries[entries.length - 1] as PerformanceEntry & { startTime: number }; if (last) w.__punimtagCwv!.lcp = last.startTime; }).observe({ type: 'largest-contentful-paint', buffered: true as boolean }); } catch { /* unsupported */ } try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const shift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean }; if (shift.hadRecentInput) continue; w.__punimtagCwv!.cls += shift.value ?? 0; } }).observe({ type: 'layout-shift', buffered: true as boolean }); } catch { /* unsupported */ } }); } export async function readWebVitals(page: Page, settleMs = 2_000): Promise { await page.waitForLoadState('domcontentloaded'); const deadline = Date.now() + 10_000; let sample: WebVitalsSample = { lcp: 0, cls: 0, lcpFromPaint: false }; while (Date.now() < deadline) { await page.waitForTimeout(settleMs); sample = await page.evaluate(() => { const w = window as Window & { __punimtagCwv?: { lcp: number; cls: number } }; let lcp = w.__punimtagCwv?.lcp ?? 0; let lcpFromPaint = lcp > 0; if (lcp === 0) { const lcpEntries = performance.getEntriesByType( 'largest-contentful-paint', ) as PerformanceEntry[]; const last = lcpEntries[lcpEntries.length - 1]; if (last?.startTime) { lcp = last.startTime; lcpFromPaint = true; } } if (lcp === 0) { const nav = performance.getEntriesByType('navigation')[0] as | PerformanceNavigationTiming | undefined; lcp = nav?.domContentLoadedEventEnd ?? 0; lcpFromPaint = false; } return { lcp, cls: w.__punimtagCwv?.cls ?? 0, lcpFromPaint }; }); if (sample.lcpFromPaint) break; } return sample; } export function expectWithinCwvBudget(sample: WebVitalsSample): void { expect(sample.lcp, 'LCP / navigation timing was not recorded').toBeGreaterThan(0); expect(sample.lcp, `LCP ${Math.round(sample.lcp)}ms exceeds budget ${CWV_BUDGET.lcp}ms`).toBeLessThanOrEqual( CWV_BUDGET.lcp, ); expect(sample.cls, `CLS ${sample.cls.toFixed(3)} exceeds budget ${CWV_BUDGET.cls}`).toBeLessThanOrEqual( CWV_BUDGET.cls, ); }