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
Fall back to buffered LCP / navigation timing when observers are slow; 12s LCP budget on CI; admin login asserts Logout instead of page title.
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
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<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,
|
||
);
|
||
}
|