Files
punimtag/e2e/web-vitals-budgets.ts
T
ilia 4a96e65d61
CI / skip-ci-check (pull_request) Successful in 29s
CI / python-lint (pull_request) Successful in 32s
CI / docker-ci (pull_request) Successful in 32s
CI / secret-scan (pull_request) Successful in 35s
CI / viewer-unit (pull_request) Successful in 2m50s
CI / admin-unit (pull_request) Successful in 3m5s
CI / e2e (pull_request) Failing after 3m5s
Harden CWV + admin e2e for CI headless runner.
Fall back to buffered LCP / navigation timing when observers are slow; 12s
LCP budget on CI; admin login asserts Logout instead of page title.
2026-08-04 23:02:56 -04:00

97 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 01+). */
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<void> {
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<WebVitalsSample> {
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,
);
}