Files
Samraaj BathandClaude Fable 5 03743333c1 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>
2026-07-04 07:03:21 -07:00

189 lines
9.4 KiB
TypeScript
Raw Permalink 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 { describe, it } from "node:test";
import assert from "node:assert/strict";
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";
// FIX 5 — the link gate must not fail a javascript: source href against the clone's sanitized value.
// Generation emits an inert `#` for a `javascript:*` href (React blocks the literal), so normHref
// collapses every javascript: href — on either side — to `#`, letting the two sides match.
describe("normHref collapses javascript: hrefs to # (FIX 5)", () => {
const origin = "https://example.test";
it("normalizes a javascript: source href to #", () => {
assert.equal(normHref("Javascript:{}", origin), "#");
assert.equal(normHref("javascript:void(0)", origin), "#");
});
it("makes a javascript: source match the emitted # value", () => {
assert.equal(normHref("javascript:{}", origin), normHref("#", origin), "source and clone agree");
});
it("still distinguishes a real fragment from a full URL", () => {
assert.equal(normHref("#top", origin), "#top");
assert.equal(normHref("https://example.test/x/", origin), "https://example.test/x");
});
});
// Chromium serializes a computed `letter-spacing: 0` back as the keyword `normal`. The emitter, after
// snapping a sub-0.1px authored tracking to 0, ships `letter-spacing: 0px` — which the CLONE then
// reports as `normal` too, but a source that authored an explicit near-zero px can land on either
// spelling. Gate 4 must treat `normal` and `0px` as equal, or every such node fails on spelling alone.
describe("gate4 letterSpacing normal ↔ 0px normalization", () => {
it("treats `normal` and `0px` as equivalent", () => {
assert.ok(letterSpacingEquivalent("normal", "0px"));
assert.ok(letterSpacingEquivalent("0px", "normal"));
});
it("treats `normal` and `normal` as equivalent", () => {
assert.ok(letterSpacingEquivalent("normal", "normal"));
});
it("treats a sub-2px authored tracking vs `normal` as equivalent (within ±2px)", () => {
// source computed `-0.08px`, clone serialized `normal` — a 0.08px delta, well within tolerance.
assert.ok(letterSpacingEquivalent("-0.08px", "normal"));
assert.ok(letterSpacingEquivalent("normal", "-0.0375px"));
});
it("still equates two close real px values (0.24px vs 0.2px)", () => {
assert.ok(letterSpacingEquivalent("-0.24px", "-0.2px"));
});
it("still FAILS a genuine tracking difference beyond ±2px", () => {
assert.ok(!letterSpacingEquivalent("4px", "normal"));
assert.ok(!letterSpacingEquivalent("normal", "-3px"));
assert.ok(!letterSpacingEquivalent("6px", "2px"));
});
it("passes when the source did not constrain the property (undefined)", () => {
assert.ok(letterSpacingEquivalent(undefined, "0px"));
});
});
// A capture-visible text node that the clone renders hidden (off-screen-shifted / banded /
// width-frozen subtree) is the emil-banner / maxbo-feed regression class. The diagnostic counts
// DISTINCT source cids whose gen counterpart exists but reports visible:false. Non-blocking; it
// only has to be a faithful, deterministic count.
describe("countVisibleInCaptureHiddenInClone diagnostic", () => {
// Minimal IR text leaf: `text` at every listed vp, visible per `vis`.
const leaf = (id: string, text: string, vis: Record<number, boolean>): IRNode => {
const vps = Object.keys(vis).map(Number);
const rec = <T,>(v: T): Record<number, T> => Object.fromEntries(vps.map((vp) => [vp, v]));
return {
id, tag: "a", attrs: {},
visibleByVp: vis,
bboxByVp: rec({ x: 0, y: 0, width: 100, height: 16 }),
computedByVp: rec({} as Record<string, string>),
children: [{ text }],
} as unknown as IRNode;
};
const makeIR = (leaves: IRNode[]): IR => ({
doc: {} as IR["doc"],
root: { id: "n0", tag: "body", attrs: {}, visibleByVp: {}, bboxByVp: {}, computedByVp: {}, children: leaves } as unknown as IRNode,
});
// Minimal clone snapshot: one node per (cid, visible) with matching direct text.
const snap = (nodes: Array<{ cid: string; text: string; visible: boolean }>): PageSnapshot => ({
root: {
tag: "body", attrs: { "data-cid": "n0" }, computed: {}, bbox: { x: 0, y: 0, width: 100, height: 100 }, visible: true,
children: nodes.map((n) => ({
tag: "a", attrs: { "data-cid": n.cid }, computed: {}, bbox: { x: 0, y: 0, width: 100, height: 16 },
visible: n.visible, children: [{ text: n.text }],
})),
},
} as unknown as PageSnapshot);
it("counts a source-visible text node the clone renders hidden", () => {
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
const gen = { 375: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]) };
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 1);
});
it("does NOT count a node visible in both source and clone", () => {
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
const gen = { 375: snap([{ cid: "n4", text: "Enrollment open!", visible: true }]) };
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
});
it("does NOT count a node the SOURCE itself hid (faithful hide)", () => {
// maxbo n192 @375: source already off-screen (visible:false) — the clone hiding it is correct.
const ir = makeIR([leaf("n192", "Avatar: Fire and Ash", { 375: false, 768: true })]);
const gen = {
375: snap([{ cid: "n192", text: "Avatar: Fire and Ash", visible: false }]),
768: snap([{ cid: "n192", text: "Avatar: Fire and Ash", visible: true }]),
};
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375, 768]), 0);
});
it("counts a node once even when hidden at several viewports (distinct cids)", () => {
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true, 768: true })]);
const gen = {
375: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]),
768: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]),
};
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375, 768]), 1);
});
it("ignores whitespace-only text nodes", () => {
const ir = makeIR([leaf("n5", " ", { 375: true })]);
const gen = { 375: snap([{ cid: "n5", text: " ", visible: false }]) };
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
});
it("does not count when the clone has no node for the cid (a different miss)", () => {
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
const gen = { 375: snap([]) };
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(" | ")}`);
});
});