Font-wait counts only actively-loading faces; excuse aborted fetches whose file exists

- Lazy-loading semantics: an @font-face never matched by rendered text
  stays "unloaded" forever - the readiness poll now waits only on faces
  in "loading" state, ending the instant fetches settle (removes ~3s of
  dead deadline per viewport on affected pages); unreferenced faces log
  per-face informationally instead of warning
- Asset gate counts a failure only for real HTTP >= 400 or a failed
  fetch whose target file is genuinely absent from the export - aborted
  srcset-candidate image fetches with valid files on disk are excused,
  matching the existing streaming-media treatment

458 tests pass (11 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 07:03:21 -07:00
co-authored by Claude Fable 5
parent fae44aab93
commit 03743333c1
5 changed files with 188 additions and 48 deletions
+55 -1
View File
@@ -1,6 +1,12 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { letterSpacingEquivalent, normHref, countVisibleInCaptureHiddenInClone } from "../src/validate/gates.js";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { letterSpacingEquivalent, normHref, countVisibleInCaptureHiddenInClone, gate2Assets } from "../src/validate/gates.js";
import { servedAssetExists } from "../src/validate/validate.js";
import type { AssetGraph } from "../src/infer/assets.js";
import type { FontGraph } from "../src/infer/fonts.js";
import type { IR, IRNode } from "../src/normalize/ir.js";
import type { PageSnapshot } from "../src/capture/walker.js";
@@ -132,3 +138,51 @@ describe("countVisibleInCaptureHiddenInClone diagnostic", () => {
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
});
});
// FIX 3 — an aborted srcset-candidate image fetch (requestfailed, file present on disk) must not be
// counted as a "404" asset failure. servedAssetExists maps a failed URL to a file under the served
// export exactly as serveStatic does; the assetFailed filter in validate.ts uses it to excuse
// aborted fetches whose file exists, while genuine misses (>= 400, or file absent) still fail.
describe("servedAssetExists (FIX 3 aborted-image excuse)", () => {
const root = mkdtempSync(join(tmpdir(), "served-out-"));
mkdirSync(join(root, "assets", "cloned", "images"), { recursive: true });
writeFileSync(join(root, "assets", "cloned", "images", "present.webp"), "RIFFxxxxWEBP");
it("resolves a URL to a present file under the served root", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/present.webp"), true);
});
it("returns false when the file is absent from the served root (a genuine miss)", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/absent.webp"), false);
});
it("ignores query strings when resolving to disk", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/present.webp?206w"), true);
});
it("does not escape the served root via .. traversal", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/../../../etc/passwd"), false);
});
it("an unparseable URL is treated as a genuine miss", () => {
assert.equal(servedAssetExists(root, "not-a-url"), false);
});
});
describe("gate2Assets failed-asset message (FIX 3)", () => {
const emptyAssets: AssetGraph = { entries: [], byUrl: new Map() };
const emptyFonts: FontGraph = { entries: [], css: "" };
it("passes when no generated asset refs are missing", () => {
const r = gate2Assets(emptyAssets, emptyFonts, { remoteRefs: [], failed404: [] });
assert.equal(r.pass, true);
assert.equal(r.metrics.failed404, 0);
});
it("fails and reports missing refs (not '404') when a genuine miss is passed", () => {
const r = gate2Assets(emptyAssets, emptyFonts, { remoteRefs: [], failed404: ["failed http://127.0.0.1:5000/assets/cloned/images/absent.webp"] });
assert.equal(r.pass, false);
assert.equal(r.metrics.failed404, 1);
assert.ok(r.issues.some((i) => i.includes("generated asset refs missing")), `expected 'missing' wording, got: ${r.issues.join(" | ")}`);
});
});
+48 -9
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serveStatic, parseRangeHeader, pendingFontFamilies, type FontFaceStatus } from "../src/validate/render.js";
import { serveStatic, parseRangeHeader, pendingFontFamilies, unreferencedFontFaces, type FontFaceStatus } from "../src/validate/render.js";
// ---- Pure helper: parseRangeHeader ----
describe("parseRangeHeader", () => {
@@ -55,7 +55,10 @@ describe("parseRangeHeader", () => {
// ---- Pure helper: pendingFontFamilies (the font-load wait decision) ----
// The render walk must run after webfonts are APPLIED, else text boxes measure the fallback face
// (a systematic width delta wrongly attributed to the clone). This helper is the state-based (not
// time-based) predicate the in-page poll ends on: it returns the DECLARED families not yet terminal.
// time-based) predicate the in-page poll ends on: it returns the DECLARED families still actively
// fetching. Browsers lazy-load faces, so after document.fonts.ready a face the rendered text needs
// is already "loading"; a face left "unloaded" is unreferenced and will never load on its own —
// waiting on it only burns the cap, so ONLY "loading" is pending.
describe("pendingFontFamilies", () => {
const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status });
@@ -67,8 +70,8 @@ describe("pendingFontFamilies", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "loaded"), face("Inter", "loaded")]), []);
});
it("an unloaded face makes its family pending", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "unloaded")]), ["Avenir"]);
it("an unloaded face is unreferenced (lazy-load) → NOT pending", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "unloaded")]), []);
});
it("a loading face makes its family pending", () => {
@@ -79,27 +82,63 @@ describe("pendingFontFamilies", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "error")]), []);
});
it("a family with mixed weights is pending if ANY weight is not terminal", () => {
it("a family is pending if ANY weight is still loading", () => {
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "loading", "700")];
assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]);
});
it("a partially-used family (used face loaded, sibling faces unloaded) is NOT pending", () => {
// The real-world false positive this fix removes: a matched face loaded, unreferenced
// weight/style siblings stay "unloaded" — the walk may proceed without waiting on them.
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "unloaded", "700"), face("Avenir", "unloaded", "400", "italic")];
assert.deepEqual(pendingFontFamilies(faces), []);
});
it("a family whose faces are all terminal (loaded or errored) is not pending", () => {
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "error", "700")];
assert.deepEqual(pendingFontFamilies(faces), []);
});
it("strips surrounding quotes from the reported family name and dedupes+sorts", () => {
const faces = [face('"Source Serif"', "unloaded"), face("Avenir", "loading"), face("Avenir", "unloaded")];
it("strips surrounding quotes from the reported family name and dedupes+sorts (loading only)", () => {
const faces = [face('"Source Serif"', "loading"), face("Avenir", "loading"), face("Avenir", "unloaded")];
assert.deepEqual(pendingFontFamilies(faces), ["Avenir", "Source Serif"]);
});
it("only the unloaded families are returned when some are loaded", () => {
const faces = [face("Inter", "loaded"), face("Avenir", "unloaded"), face("JetBrains", "loaded")];
it("only the still-loading families are returned when some are loaded/unloaded", () => {
const faces = [face("Inter", "loaded"), face("Avenir", "loading"), face("JetBrains", "unloaded")];
assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]);
});
});
// ---- Pure helper: unreferencedFontFaces (informational-only per-face report) ----
// Faces left "unloaded" after the wait bound: declared but no rendered text resolves to them.
// Reported per-face (family+weight+style) for an informational log; benign, never a fidelity issue.
describe("unreferencedFontFaces", () => {
const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status });
it("returns only the unloaded faces (not loaded/loading/error)", () => {
const faces = [face("Avenir", "loaded"), face("Merriweather", "unloaded", "700"), face("Inter", "loading"), face("Gotham", "error")];
assert.deepEqual(unreferencedFontFaces(faces), [{ family: "Merriweather", weight: "700", style: "normal", status: "unloaded" }]);
});
it("reports per-face (family+weight+style), not collapsed per-family, and strips quotes + sorts", () => {
const faces = [
face('"Merriweather"', "unloaded", "700", "normal"),
face("Merriweather", "unloaded", "300", "italic"),
face("Avenir", "unloaded", "400", "normal"),
];
assert.deepEqual(unreferencedFontFaces(faces), [
{ family: "Avenir", weight: "400", style: "normal", status: "unloaded" },
{ family: "Merriweather", weight: "300", style: "italic", status: "unloaded" },
{ family: "Merriweather", weight: "700", style: "normal", status: "unloaded" },
]);
});
it("no unloaded faces → empty", () => {
assert.deepEqual(unreferencedFontFaces([face("Avenir", "loaded"), face("Inter", "loading")]), []);
});
});
// ---- Integration: serveStatic answers real Range requests with 206/416 ----
describe("serveStatic Range support (integration)", () => {
let rootDir = "";