diff --git a/packages/host/tests/helpers/percy-snapshot.ts b/packages/host/tests/helpers/percy-snapshot.ts index 12c88af146..552f534a0d 100644 --- a/packages/host/tests/helpers/percy-snapshot.ts +++ b/packages/host/tests/helpers/percy-snapshot.ts @@ -20,12 +20,59 @@ export default async function percySnapshot( // Load every @font-face the page has declared, not just a hard-coded list. // This covers IBM Plex Sans, IBM Plex Mono (used by Monaco), IBM Plex Serif // and any future additions, without needing to keep the helper in sync. - await Promise.all(Array.from(document.fonts, (f) => f.load())); + // + // `allSettled` (not `all`) because Chrome rejects FontFace.load() with a + // generic `DOMException: A network error occurred.` when the font fetch + // fails — typically a transient hiccup pulling a non-critical font over the + // wire in CI. Letting that bubble out turns the *whole* test red with no URL + // attached. The hard-coded IBM Plex Sans `document.fonts.check` below stays + // the load-bearing assertion: if the font that actually moves Percy pixels + // is missing, fail there with a clear message. + let faces = Array.from(document.fonts); + let fontResults = await Promise.allSettled( + faces.map((f) => f.load().then(() => f)), + ); await document.fonts.ready; - // The hard-coded Sans check remains as a load-bearing assertion: this font - // is the default body font, and a capture without it shifts every text - // element by a fraction of a pixel and turns Percy red across the board. + let failedFonts = fontResults.flatMap((result, idx) => { + if (result.status !== 'rejected') { + return []; + } + let face = faces[idx]; + let descriptor = face + ? `${face.weight} ${face.style} "${face.family}"` + : ``; + return [{ face, descriptor, reason: describeFontLoadError(result.reason) }]; + }); + + // If IBM Plex Sans itself failed to load, fail loud here — `document.fonts + // .check(..., '')` below cannot be relied on to catch this: per the WHATWG + // spec, `FontFaceSet.check` treats faces in `error` status as settled, and + // with empty text it can still return `true` while the required face is + // actually unrenderable. Without this explicit guard, Percy would capture + // the page with a fallback font silently substituted. + let failedRequired = failedFonts.find( + ({ face }) => face?.family === 'IBM Plex Sans', + ); + if (failedRequired) { + throw new Error( + `Required font failed to load: ${failedRequired.descriptor}: ${failedRequired.reason}`, + ); + } + + if (failedFonts.length) { + let lines = failedFonts.map( + ({ descriptor, reason }) => `${descriptor}: ${reason}`, + ); + console.warn( + `[percy-snapshot] ${failedFonts.length} @font-face load(s) failed; continuing snapshot. Failures:\n ${lines.join('\n ')}`, + ); + } + + // Belt-and-suspenders: even if no Sans face entered the `error` status, the + // page may still be missing a Sans weight (e.g. never declared, or evicted + // after a teardown). A capture without it shifts every text element by a + // fraction of a pixel and turns Percy red across the board. for (const weight of ['400', '500', '600', '700']) { const descriptor = `${weight} 1em IBM Plex Sans`; if (!document.fonts.check(descriptor, '')) { @@ -70,3 +117,16 @@ export default async function percySnapshot( await originalPercySnapshot(...args); } + +function describeFontLoadError(reason: unknown): string { + if (reason instanceof Error) { + let header = reason.name + ? `${reason.name}: ${reason.message}` + : reason.message; + return header || 'Unknown error'; + } + if (typeof reason === 'string') { + return reason; + } + return String(reason); +}