diff --git a/e2e/utils/settle.ts b/e2e/utils/settle.ts index de8afc088..dac403129 100644 --- a/e2e/utils/settle.ts +++ b/e2e/utils/settle.ts @@ -1,5 +1,23 @@ import { expect, type Locator, type Page } from "@playwright/test"; +/** + * How long either settle wait in `settleBeforeMeasure` may take before the + * spec moves on. + * + * Both of those waits are written to end on their own terms; neither was + * bounded, and neither inherits a bound from anywhere, because the config + * leaves `actionTimeout` and `navigationTimeout` unset and Playwright reads + * that as "no limit". One request that never completes therefore ends the + * whole test instead of just the wait, and it ends it in the least useful way + * available: the run reports whichever call was still pending when the fixture + * tore the page down, so the message names a `page.evaluate` against a closed + * target rather than the surface that stalled. Six seconds is an order of + * magnitude above the slowest settle measured anywhere in this suite. Past + * that it is a stall, and a stall belongs in front of the spec's own + * assertions, which can say what is actually wrong. + */ +const SETTLE_BUDGET_MS = 6_000; + /** * Gate a one-shot DOM read on the element it is about to measure. * @@ -45,6 +63,30 @@ export async function settleBeforeMeasure( .catch(() => {}); // Fonts and late CSS change geometry after the element is visible, and // every measurement in this suite is geometric. - await page.waitForLoadState("networkidle").catch(() => {}); - await page.evaluate(() => document.fonts?.ready); + await page + .waitForLoadState("networkidle", { timeout: SETTLE_BUDGET_MS }) + .catch(() => {}); + await waitForFonts(page); +} + +/** + * Wait for webfonts to settle, bounded inside the page. + * + * `document.fonts.ready` belongs to the document, so awaiting it from a + * `page.evaluate` hands the test an unbounded wait on a promise no timeout + * reaches. Race it against a timer in the page instead, so a font that never + * arrives costs this wait and nothing else. + */ +export async function waitForFonts( + page: Page, + timeout = SETTLE_BUDGET_MS, +): Promise { + await page.evaluate(async (budget: number) => { + const ready = document.fonts?.ready; + if (!ready) return; + await Promise.race([ + ready, + new Promise((resolve) => window.setTimeout(resolve, budget)), + ]); + }, timeout); } diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index 6448d3472..bbfccb930 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -11,7 +11,7 @@ import { mockDashboardSnapshot, } from "./utils/mock-dashboard-snapshot"; import { mockPopulatedInsights } from "./utils/mock-populated-insights"; -import { settleBeforeMeasure } from "./utils/settle"; +import { settleBeforeMeasure, waitForFonts } from "./utils/settle"; const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341"); @@ -46,7 +46,9 @@ async function settleAndCapture( content: "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}", }); - await page.evaluate(() => document.fonts?.ready); + // The injected stylesheet can pull a font the page had not needed yet, so + // the wait repeats after it. Bounded, for the reason `waitForFonts` gives. + await waitForFonts(page); const path = join(EVIDENCE_DIR, filename); await page.screenshot({ path, animations: "disabled" }); await testInfo.attach(filename, { path, contentType: "image/png" });