Band coverage for never-visible occupying items, page-height trust guard, font materialization integrity
- Items never visible at the canonical viewport (carousel slides off-screen at every captured width) now fall through to per-viewport geometry bands when they occupy layout, instead of freezing canonical geometry - The explicit-height probe trust excludes body/html/main and any wrapper whose height matches the page scrollHeight - page-height nodes keep flowing, and the layout height gate stays meaningful - @font-face and style-rule url() harvesting resolves against the OWNING stylesheet's href (walking @import nesting), not document.baseURI; font bytes are magic-byte validated at the storage choke point (HTML bodies from catch-all 200 routes are rejected); font-face dedup prefers the variant whose source actually resolved, deterministically 415 tests pass (16 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1d68f16f96
commit
994d2bc21e
@@ -182,6 +182,28 @@ export function looksLikeVideoFile(bytes: Buffer): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Container-magic check for accepted font bytes, mirroring `looksLikeVideoFile`. A router that
|
||||||
|
* answers 200+HTML for an unknown `/media/*` path (common on SPA hosts) otherwise gets stored as
|
||||||
|
* a `.woff2`; the browser rejects it as a font and the whole page falls through to a system
|
||||||
|
* fallback. Accept only the real font-container signatures — woff2 (`wOF2`), woff (`wOFF`),
|
||||||
|
* OpenType/CFF (`OTTO`), TrueType (`\x00\x01\x00\x00` or `true`/`ttcf`), and EOT (the version
|
||||||
|
* header at bytes 8..11 is `\x01\x00\x01\x00`/`\x02\x00\x01\x00`, so key EOT off its unique
|
||||||
|
* 0x504C signature at offset 34) — and explicitly reject an HTML/text body. */
|
||||||
|
export function looksLikeFontFile(bytes: Buffer): boolean {
|
||||||
|
if (bytes.length < 4) return false;
|
||||||
|
const b0 = bytes[0]!, b1 = bytes[1]!, b2 = bytes[2]!, b3 = bytes[3]!;
|
||||||
|
// A leading `<` (`<!DOCTYPE`, `<html`, `<?xml`, `<svg`) is never a binary font container.
|
||||||
|
if (b0 === 0x3c) return false;
|
||||||
|
const tag = bytes.subarray(0, 4).toString("latin1");
|
||||||
|
if (tag === "wOF2" || tag === "wOFF") return true; // woff2 / woff
|
||||||
|
if (tag === "OTTO" || tag === "true" || tag === "ttcf") return true; // CFF OpenType / TrueType / TrueType collection
|
||||||
|
if (b0 === 0x00 && b1 === 0x01 && b2 === 0x00 && b3 === 0x00) return true; // TrueType sfnt (\x00\x01\x00\x00)
|
||||||
|
// EOT: a little-endian byte count precedes a version/flags header; its stable marker is the
|
||||||
|
// 0x504C ("LP") magic at byte offset 34.
|
||||||
|
if (bytes.length >= 36 && bytes[34] === 0x4c && bytes[35] === 0x50) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `<source>` candidate for the HTML media resource-selection algorithm. `media`/`type` are the
|
* A `<source>` candidate for the HTML media resource-selection algorithm. `media`/`type` are the
|
||||||
* raw attribute strings (null/undefined when absent, matching a missing attribute).
|
* raw attribute strings (null/undefined when absent, matching a missing attribute).
|
||||||
@@ -1047,6 +1069,10 @@ export async function captureSite(opts: {
|
|||||||
// page stored under first-stored-wins ships a corrupt file). Left unstored, the
|
// page stored under first-stored-wins ships a corrupt file). Left unstored, the
|
||||||
// asset surfaces in visual_assets_missing instead.
|
// asset surfaces in visual_assets_missing instead.
|
||||||
if (type === "video" && !looksLikeVideoFile(bytes)) return;
|
if (type === "video" && !looksLikeVideoFile(bytes)) return;
|
||||||
|
// Same guard for fonts: a 200+HTML body (SPA router answering an unknown /media/* path) must not
|
||||||
|
// be stored as a .woff2. Left unstored, the face surfaces as failed, so the font graph can fall
|
||||||
|
// back to a correctly-resolved duplicate url instead of shipping an impostor the browser rejects.
|
||||||
|
if (type === "font" && !looksLikeFontFile(bytes)) return;
|
||||||
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
||||||
if (a.storedAs) return;
|
if (a.storedAs) return;
|
||||||
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
|
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export type FontFace = {
|
|||||||
display?: string;
|
display?: string;
|
||||||
unicodeRange?: string;
|
unicodeRange?: string;
|
||||||
stretch?: string;
|
stretch?: string;
|
||||||
|
// Url of the stylesheet this face was declared in (undefined for an inline <style>, or for faces
|
||||||
|
// parsed out-of-band where the base is already baked into `src`). The src descriptor's relative
|
||||||
|
// url()s resolve against this, not the document — see readRules and parseSrcUrls.
|
||||||
|
baseHref?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PageSnapshot = {
|
export type PageSnapshot = {
|
||||||
@@ -600,17 +604,34 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
// dropped, collapsing the box in the clone.
|
// dropped, collapsing the box in the clone.
|
||||||
const explicitWidthSelectors: string[] = [];
|
const explicitWidthSelectors: string[] = [];
|
||||||
|
|
||||||
const absUrl = (u: string): string => {
|
const absUrl = (u: string, base?: string): string => {
|
||||||
try { return new URL(u, document.baseURI).href; } catch { return u; }
|
try { return new URL(u, base || document.baseURI).href; } catch { return u; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const harvestUrlsFromText = (text: string): void => {
|
// A relative url() inside a stylesheet resolves against THAT STYLESHEET'S url, not the document —
|
||||||
|
// `url("../media/x.woff2")` in `/a/b/css/sheet.css` points at `/a/b/media/x.woff2`, which is a
|
||||||
|
// different file from the document-relative `/media/x.woff2`. Resolving font/image srcs against
|
||||||
|
// `document.baseURI` fetches the wrong path (often the SPA router's 200+HTML shell), so a harvested
|
||||||
|
// face src must carry its owning sheet's base. Walk up through `@import` nesting (parentStyleSheet
|
||||||
|
// via ownerRule) to the nearest sheet that actually has an href; an inline `<style>` has none, so
|
||||||
|
// it correctly falls back to the document base.
|
||||||
|
const sheetBaseHref = (sheet: CSSStyleSheet | null | undefined): string | undefined => {
|
||||||
|
let s: CSSStyleSheet | null | undefined = sheet;
|
||||||
|
// Bound the walk defensively; @import chains are shallow in practice.
|
||||||
|
for (let i = 0; s && i < 32; i++) {
|
||||||
|
if (s.href) return s.href;
|
||||||
|
s = s.ownerRule?.parentStyleSheet ?? null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const harvestUrlsFromText = (text: string, base?: string): void => {
|
||||||
const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
const raw = m[2];
|
const raw = m[2];
|
||||||
if (!raw || raw.startsWith("data:")) continue;
|
if (!raw || raw.startsWith("data:")) continue;
|
||||||
cssUrlSet.add(absUrl(raw));
|
cssUrlSet.add(absUrl(raw, base));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -663,7 +684,12 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const readRules = (rules: CSSRuleList): void => {
|
// `base` is the url of the stylesheet these rules live in (undefined for an inline <style>, which
|
||||||
|
// resolves against the document). It is threaded so every harvested url() — @font-face src and
|
||||||
|
// ordinary style-rule url() alike — resolves relative to its OWNING sheet, not the page. The src
|
||||||
|
// string is left verbatim on the FontFace (parseSrcUrls re-absolutizes it downstream against the
|
||||||
|
// same base), but the base still drives the cssUrlSet entry that triggers the download.
|
||||||
|
const readRules = (rules: CSSRuleList, base?: string): void => {
|
||||||
for (const rule of Array.from(rules)) {
|
for (const rule of Array.from(rules)) {
|
||||||
const type = rule.constructor.name;
|
const type = rule.constructor.name;
|
||||||
if (type === "CSSFontFaceRule") {
|
if (type === "CSSFontFaceRule") {
|
||||||
@@ -680,14 +706,15 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
display: s.getPropertyValue("font-display") || undefined,
|
display: s.getPropertyValue("font-display") || undefined,
|
||||||
unicodeRange: s.getPropertyValue("unicode-range") || undefined,
|
unicodeRange: s.getPropertyValue("unicode-range") || undefined,
|
||||||
stretch: s.getPropertyValue("font-stretch") || undefined,
|
stretch: s.getPropertyValue("font-stretch") || undefined,
|
||||||
|
baseHref: base,
|
||||||
});
|
});
|
||||||
harvestUrlsFromText(src);
|
harvestUrlsFromText(src, base);
|
||||||
}
|
}
|
||||||
} else if (type === "CSSKeyframesRule") {
|
} else if (type === "CSSKeyframesRule") {
|
||||||
keyframes.push((rule as CSSKeyframesRule).cssText);
|
keyframes.push((rule as CSSKeyframesRule).cssText);
|
||||||
} else if (type === "CSSStyleRule") {
|
} else if (type === "CSSStyleRule") {
|
||||||
const r = rule as CSSStyleRule;
|
const r = rule as CSSStyleRule;
|
||||||
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
|
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText, base);
|
||||||
if (r.selectorText && r.style &&
|
if (r.selectorText && r.style &&
|
||||||
(isExplicitHeight(r.style.getPropertyValue("height")) ||
|
(isExplicitHeight(r.style.getPropertyValue("height")) ||
|
||||||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
||||||
@@ -699,17 +726,20 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
explicitWidthSelectors.push(r.selectorText);
|
explicitWidthSelectors.push(r.selectorText);
|
||||||
}
|
}
|
||||||
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
|
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
|
||||||
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
|
// Nested grouping rules live in the same sheet — keep the base.
|
||||||
|
try { readRules((rule as CSSGroupingRule).cssRules, base); } catch { /* ignore */ }
|
||||||
} else if (type === "CSSImportRule") {
|
} else if (type === "CSSImportRule") {
|
||||||
|
// The imported sheet is a separate file: its rules resolve against ITS url, falling back to
|
||||||
|
// the importing sheet's base only if the imported sheet reports none.
|
||||||
const imp = rule as CSSImportRule;
|
const imp = rule as CSSImportRule;
|
||||||
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules); } catch { /* cross-origin */ }
|
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules, sheetBaseHref(imp.styleSheet) ?? base); } catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const sheet of Array.from(document.styleSheets)) {
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
try {
|
try {
|
||||||
readRules(sheet.cssRules);
|
readRules(sheet.cssRules, sheetBaseHref(sheet));
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin sheet — record its href so the capture layer can fetch the
|
// Cross-origin sheet — record its href so the capture layer can fetch the
|
||||||
// raw text out-of-band and parse font-faces/urls from it.
|
// raw text out-of-band and parse font-faces/urls from it.
|
||||||
@@ -746,7 +776,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
|||||||
try { sheets.push(...(sr.adoptedStyleSheets || [])); } catch { /* ignore */ }
|
try { sheets.push(...(sr.adoptedStyleSheets || [])); } catch { /* ignore */ }
|
||||||
try { sheets.push(...Array.from(sr.styleSheets || [])); } catch { /* ignore */ }
|
try { sheets.push(...Array.from(sr.styleSheets || [])); } catch { /* ignore */ }
|
||||||
for (const sheet of sheets) {
|
for (const sheet of sheets) {
|
||||||
try { readRules(sheet.cssRules); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
|
try { readRules(sheet.cssRules, sheetBaseHref(sheet)); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2450,7 +2450,16 @@ function declsForViewport(
|
|||||||
// neither structural test detects the authored height. Trust the node's own probe.
|
// neither structural test detects the authored height. Trust the node's own probe.
|
||||||
{
|
{
|
||||||
const osz = node.sizingByVp?.[vp];
|
const osz = node.sizingByVp?.[vp];
|
||||||
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && osz && osz.hAuto === false && osz.hFill === false) explicitHeight = true;
|
// Never trust this probe on a document-height wrapper. A root landmark (body/html/main) — or
|
||||||
|
// any node whose captured box IS the page's scroll height — probes hAuto:false/hFill:false as
|
||||||
|
// measurement noise (min-h-screen + the %-height re-measure chain at document level), not an
|
||||||
|
// authored height. Baking that px pins the whole page to a fixed height: combined with an
|
||||||
|
// `overflow:hidden` main it hard-clips any content-height drift, and it makes the layout gate's
|
||||||
|
// heightDelta tautologically zero (gate blindness). Such wrappers must keep flowing.
|
||||||
|
const isRootWrapper = tag === "body" || tag === "html" || tag === "main";
|
||||||
|
const isPageHeightBox = !!rootScrollHeight && !!nb && nb.height > 0 &&
|
||||||
|
Math.abs(nb.height - rootScrollHeight) <= rootScrollHeight * 0.02;
|
||||||
|
if (!noCollapse && !isLeaf && !isRootWrapper && !isPageHeightBox && cs.height && cs.height !== "auto" && osz && osz.hAuto === false && osz.hFill === false) explicitHeight = true;
|
||||||
}
|
}
|
||||||
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && nb) {
|
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && nb) {
|
||||||
const top = nb.y + (parseFloat(cs.paddingTop || "0") || 0) + (parseFloat(cs.borderTopWidth || "0") || 0);
|
const top = nb.y + (parseFloat(cs.paddingTop || "0") || 0) + (parseFloat(cs.borderTopWidth || "0") || 0);
|
||||||
@@ -3565,7 +3574,24 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
continue;
|
// Not painted here, not display:none, not ancestor-hidden, and NOT visible at base —
|
||||||
|
// an item that is off-viewport/clipped at EVERY captured width, base included (the
|
||||||
|
// walker marks a box invisible when `bbox.x >= vpW`). A carousel slide 6–12 that lives
|
||||||
|
// off-screen right at 1280 (the canonical base) as well as at 375/768 lands here. It is
|
||||||
|
// the identical load-bearing case as the shownAtBase occupying arm above: a display:block
|
||||||
|
// flex item that still sets its flex track's cross-size, so freezing the base rule's
|
||||||
|
// canonical (desktop) geometry inflates the mobile track. Run the same occupying-box test —
|
||||||
|
// in-flow, unsuppressed, non-zero bbox at THIS width → fall through to the per-viewport
|
||||||
|
// delta so it carries this width's measured geometry. A genuinely suppressed
|
||||||
|
// (opacity:0 / visibility:hidden) or zero-box node keeps the current skip.
|
||||||
|
const bb = node.bboxByVp[b.vp];
|
||||||
|
const suppressed = pf(vpCs.opacity) === 0 || /^(hidden|collapse)$/.test(vpCs.visibility || "");
|
||||||
|
const inFlowHere = (vpCs.position || "static") === "static" || (vpCs.position || "static") === "relative";
|
||||||
|
if (!suppressed && inFlowHere && bb && (bb.width > 0 || bb.height > 0)) {
|
||||||
|
// Occupying, in-flow, off-viewport at every width — fall through to the per-viewport delta.
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-15
@@ -61,19 +61,14 @@ export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, so
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: FontEntry[] = [];
|
// Resolve one face's src descriptor against the downloaded-asset graph. A face harvested from
|
||||||
const cssBlocks: string[] = [];
|
// CSSOM carries the url of its owning sheet in `baseHref`; its relative src url()s must resolve
|
||||||
const seen = new Set<string>();
|
// against THAT, not the document, or `../media/x` clamps to the wrong path (commonly the SPA
|
||||||
|
// router's HTML shell). Faces parsed out-of-band already have absolute srcs baked in, so
|
||||||
for (const ff of fontFaces) {
|
// `baseHref` is absent and the document url is a harmless fallback.
|
||||||
const weight = (ff.weight || "400").trim();
|
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
|
||||||
const style = (ff.style || "normal").trim();
|
const resolveFace = (ff: FontFace): { resolved: Array<{ localPath: string; format: string }>; dataUris: string[] } => {
|
||||||
const display = (ff.display || "swap").trim();
|
const srcs = parseSrcUrls(ff.src, ff.baseHref || sourceUrl);
|
||||||
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
|
|
||||||
const srcs = parseSrcUrls(ff.src, sourceUrl);
|
|
||||||
const resolved: Array<{ localPath: string; format: string }> = [];
|
const resolved: Array<{ localPath: string; format: string }> = [];
|
||||||
const dataUris: string[] = [];
|
const dataUris: string[] = [];
|
||||||
for (const s of srcs) {
|
for (const s of srcs) {
|
||||||
@@ -84,10 +79,47 @@ export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, so
|
|||||||
resolved.push({ localPath: entry.localPath, format: s.format || FORMAT_BY_EXT[ext] || "woff2" });
|
resolved.push({ localPath: entry.localPath, format: s.format || FORMAT_BY_EXT[ext] || "woff2" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer woff2 > woff > others for ordering.
|
// Prefer woff2 > woff > others for ordering.
|
||||||
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
|
|
||||||
resolved.sort((a, b) => order.indexOf(a.format) - order.indexOf(b.format));
|
resolved.sort((a, b) => order.indexOf(a.format) - order.indexOf(b.format));
|
||||||
|
return { resolved, dataUris };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deduplicate faces by family|weight|style|unicodeRange. Two sources can supply the same face
|
||||||
|
// with DIFFERENT (correctly- vs wrongly-resolved) src urls — the CSSOM harvest and the css-text
|
||||||
|
// parse both land in `fontFaces`. Keeping the first-inserted face lets a src that resolves to
|
||||||
|
// nothing (a rejected impostor, or a mis-based path) win the slot, so choose validity-aware:
|
||||||
|
// the first face whose src resolves to a downloaded/data-uri source wins; only if NONE in the
|
||||||
|
// group resolves do we fall back to the first-seen face (recorded as unavailable). Ties (more
|
||||||
|
// than one resolving) keep insertion order — determinism preserved.
|
||||||
|
const chosen = new Map<string, { ff: FontFace; res: ReturnType<typeof resolveFace> }>();
|
||||||
|
const orderKeys: string[] = [];
|
||||||
|
for (const ff of fontFaces) {
|
||||||
|
const weight = (ff.weight || "400").trim();
|
||||||
|
const style = (ff.style || "normal").trim();
|
||||||
|
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
|
||||||
|
const res = resolveFace(ff);
|
||||||
|
const resolves = res.resolved.length > 0 || res.dataUris.length > 0;
|
||||||
|
const prev = chosen.get(key);
|
||||||
|
if (!prev) {
|
||||||
|
chosen.set(key, { ff, res });
|
||||||
|
orderKeys.push(key);
|
||||||
|
} else if (resolves && !(prev.res.resolved.length > 0 || prev.res.dataUris.length > 0)) {
|
||||||
|
// Upgrade: the incumbent resolved to nothing, this candidate resolves — replace it in place
|
||||||
|
// (keeping its original emission position).
|
||||||
|
chosen.set(key, { ff, res });
|
||||||
|
}
|
||||||
|
// Otherwise keep the incumbent (first-resolving wins; ties hold insertion order).
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: FontEntry[] = [];
|
||||||
|
const cssBlocks: string[] = [];
|
||||||
|
|
||||||
|
for (const key of orderKeys) {
|
||||||
|
const { ff, res } = chosen.get(key)!;
|
||||||
|
const weight = (ff.weight || "400").trim();
|
||||||
|
const style = (ff.style || "normal").trim();
|
||||||
|
const display = (ff.display || "swap").trim();
|
||||||
|
const { resolved, dataUris } = res;
|
||||||
|
|
||||||
if (resolved.length > 0 || dataUris.length > 0) {
|
if (resolved.length > 0 || dataUris.length > 0) {
|
||||||
const srcParts: string[] = [];
|
const srcParts: string[] = [];
|
||||||
|
|||||||
@@ -269,6 +269,31 @@ describe("generateCss hidden-node banded geometry", () => {
|
|||||||
assert.ok(mobile.includes("visibility:hidden"), `child should carry the hide, got: ${mobile}`);
|
assert.ok(mobile.includes("visibility:hidden"), `child should carry the hide, got: ${mobile}`);
|
||||||
assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`);
|
assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits per-band geometry for an occupying slide off-viewport at EVERY width, base included", () => {
|
||||||
|
// A carousel slide 6–12: a shrink-0 flex item off-screen RIGHT at every captured width — 375,
|
||||||
|
// the 1280 canonical base, AND 1920 — so it is never ownHidden, never ancestor-hidden, and never
|
||||||
|
// visibleByVp[base]=true (the walker marks `bbox.x >= vpW` invisible). It is NOT suppressed
|
||||||
|
// (no opacity:0 / visibility:hidden) and stays IN FLOW, so it still sets its flex track's
|
||||||
|
// cross-size. Without a band the base's canonical desktop width freezes across every viewport and
|
||||||
|
// inflates the mobile track. The band must carry THIS width's measured geometry.
|
||||||
|
const slide = nodeAt("n2", "div", {
|
||||||
|
375: { cs: { display: "block", position: "static", opacity: "1", width: "220px" }, bbox: { x: 400, y: 0, width: 220, height: 432 }, visible: false },
|
||||||
|
1280: { cs: { display: "block", position: "static", opacity: "1", width: "285px" }, bbox: { x: 1500, y: 0, width: 285, height: 532 }, visible: false },
|
||||||
|
1920: { cs: { display: "block", position: "static", opacity: "1", width: "285px" }, bbox: { x: 2200, y: 0, width: 285, height: 532 }, visible: false },
|
||||||
|
});
|
||||||
|
const track = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 375, height: 432 } },
|
||||||
|
1280: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 1280, height: 532 } },
|
||||||
|
1920: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 1920, height: 532 } },
|
||||||
|
}, [slide]);
|
||||||
|
const root = nodeAt("n0", "body", {}, [track]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n2").includes("width:285px"), `base bakes canonical width, got: ${baseRule(css, "n2")}`);
|
||||||
|
const mobile = bandRule(css, /max-width/, "n2");
|
||||||
|
assert.ok(mobile.includes("width:220px"), `mobile band must carry the measured 220px width, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("display:none"), "an occupying off-viewport slide must stay in layout");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fix 1 — mobile nav chip-strip overlap. A horizontally-scrollable flex strip (overflow-x:auto
|
// Fix 1 — mobile nav chip-strip overlap. A horizontally-scrollable flex strip (overflow-x:auto
|
||||||
@@ -924,6 +949,42 @@ describe("generateCss authored-height kept via own probe (T3)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// R2 — the own-probe height trust (T3) must NEVER bake a document-height wrapper. A body/html/main
|
||||||
|
// landmark, or any node whose captured box IS the page scroll height, probes hAuto:false/hFill:false
|
||||||
|
// as document-level re-measure noise (min-h-screen + %-height chain), not an authored height. Baking
|
||||||
|
// it pins the page to a fixed height — which, under an overflow:hidden main, hard-clips content drift
|
||||||
|
// and blinds the layout gate's heightDelta. Such wrappers must keep flowing (auto height).
|
||||||
|
describe("generateCss own-probe height trust never bakes a page-height wrapper (R2)", () => {
|
||||||
|
const explicit = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: false, hFill: false });
|
||||||
|
// xIr sets perViewport.scrollHeight = 800 at every viewport.
|
||||||
|
function pageWrapper(tag: string, h: number) {
|
||||||
|
// A non-leaf wrapper with an in-flow child, whose own probe reads authored (hAuto/hFill false).
|
||||||
|
const child = xNode("n2", "div", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: h } }])));
|
||||||
|
const wrap = xNode("n1", tag, Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static", height: `${h}px` }, bbox: { x: 0, y: 0, width: vp, height: h }, sizing: explicit() }])), [child]);
|
||||||
|
return xNode("n0", "body", Object.fromEntries(XVPS.map((vp) => [vp, { bbox: { x: 0, y: 0, width: vp, height: h } }])), [wrap]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does NOT bake a <main> landmark height even when its own probe is authored", () => {
|
||||||
|
const css = generateCss(xIr(pageWrapper("main", 800)), new Map());
|
||||||
|
const all = allRulesX(css, "n1");
|
||||||
|
assert.ok(!/height:800px/.test(all), `main landmark must keep flowing, got: ${all}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT bake a wrapper whose captured height IS the page scroll height (±2%)", () => {
|
||||||
|
// 792 is within 2% of the 800px scrollHeight — a page-height wrapper, not authored content.
|
||||||
|
const css = generateCss(xIr(pageWrapper("div", 792)), new Map());
|
||||||
|
const all = allRulesX(css, "n1");
|
||||||
|
assert.ok(!/height:792px/.test(all), `near-page-height wrapper must keep flowing, got: ${all}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still bakes a genuinely authored height well below the page height", () => {
|
||||||
|
// 300px is far from the 800px scrollHeight — a real authored box (the T3 case) still survives.
|
||||||
|
const css = generateCss(xIr(pageWrapper("div", 300)), new Map());
|
||||||
|
const all = allRulesX(css, "n1");
|
||||||
|
assert.ok(/height:300px/.test(all), `authored sub-page height must still survive, got: ${all}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// C1 (css side) — a computed `grid-template-rows: subgrid` is a STRUCTURAL keyword, not a baked px
|
// C1 (css side) — a computed `grid-template-rows: subgrid` is a STRUCTURAL keyword, not a baked px
|
||||||
// regime. It must NEVER be dropped by the reflow-mode dropGridRows path (which strips px row tracks).
|
// regime. It must NEVER be dropped by the reflow-mode dropGridRows path (which strips px row tracks).
|
||||||
describe("collectNodeRules never drops a subgrid rows template (C1)", () => {
|
describe("collectNodeRules never drops a subgrid rows template (C1)", () => {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { looksLikeFontFile } from "../src/capture/capture.js";
|
||||||
|
import { buildFontGraph } from "../src/infer/fonts.js";
|
||||||
|
import type { FontFace } from "../src/capture/walker.js";
|
||||||
|
import type { AssetGraph, AssetEntry } from "../src/infer/assets.js";
|
||||||
|
|
||||||
|
// --- Magic-byte validator table -------------------------------------------------------------
|
||||||
|
|
||||||
|
function head(tag: string, len = 64): Buffer {
|
||||||
|
return Buffer.concat([Buffer.from(tag, "latin1"), Buffer.alloc(Math.max(0, len - tag.length))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("looksLikeFontFile (font container magic)", () => {
|
||||||
|
it("accepts every real font-container signature", () => {
|
||||||
|
assert.equal(looksLikeFontFile(head("wOF2")), true, "woff2");
|
||||||
|
assert.equal(looksLikeFontFile(head("wOFF")), true, "woff");
|
||||||
|
assert.equal(looksLikeFontFile(head("OTTO")), true, "CFF OpenType (otf)");
|
||||||
|
assert.equal(looksLikeFontFile(head("true")), true, "TrueType 'true' sfnt");
|
||||||
|
assert.equal(looksLikeFontFile(head("ttcf")), true, "TrueType collection");
|
||||||
|
assert.equal(
|
||||||
|
looksLikeFontFile(Buffer.concat([Buffer.from([0x00, 0x01, 0x00, 0x00]), Buffer.alloc(60)])),
|
||||||
|
true,
|
||||||
|
"TrueType \\x00\\x01\\x00\\x00 sfnt (ttf)",
|
||||||
|
);
|
||||||
|
// EOT: 0x504C ("LP") marker at byte offset 34.
|
||||||
|
const eot = Buffer.alloc(80);
|
||||||
|
eot[34] = 0x4c;
|
||||||
|
eot[35] = 0x50;
|
||||||
|
assert.equal(looksLikeFontFile(eot), true, "eot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects HTML/text impostor bodies (the SPA-router 200 shell)", () => {
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.from("<!DOCTYPE html><html><head></head></html>")), false);
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.from("<!doctype html>")), false);
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.from("<html><body>not a font</body></html>")), false);
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.from("<?xml version=\"1.0\"?>")), false);
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.from("just some plain text body")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects truncated/empty buffers", () => {
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.alloc(0)), false);
|
||||||
|
assert.equal(looksLikeFontFile(Buffer.from([0x77, 0x4f])), false); // 2 bytes of "wO"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Validity-aware first-insert preference in buildFontGraph -------------------------------
|
||||||
|
|
||||||
|
function fontEntry(sourceUrl: string, localPath: string | null): AssetEntry {
|
||||||
|
return {
|
||||||
|
sourceUrl,
|
||||||
|
type: "font",
|
||||||
|
classification: localPath ? "downloaded" : "skipped",
|
||||||
|
localPath,
|
||||||
|
storedFile: localPath ? localPath.split("/").pop()! : null,
|
||||||
|
bytes: localPath ? 45_000 : 0,
|
||||||
|
reason: localPath ? null : "font_file_unavailable",
|
||||||
|
impact: null,
|
||||||
|
via: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function graphOf(entries: AssetEntry[]): AssetGraph {
|
||||||
|
const byUrl = new Map<string, AssetEntry>();
|
||||||
|
for (const e of entries) byUrl.set(e.sourceUrl, e);
|
||||||
|
return { entries, byUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC = "https://host.example/page";
|
||||||
|
// The genuine subset lives under the css file's sibling media dir; only THIS url is downloaded.
|
||||||
|
const GOOD_URL = "https://host.example/marketing-static/_next/static/media/good.woff2";
|
||||||
|
// The wrongly-based (document-relative) url that the SPA router answered with HTML and that was
|
||||||
|
// therefore rejected at store time — it never becomes a downloaded asset.
|
||||||
|
const BAD_URL = "https://host.example/media/impostor.woff2";
|
||||||
|
|
||||||
|
describe("buildFontGraph validity-aware preference (first-insert race)", () => {
|
||||||
|
it("prefers the face whose src resolves to a downloaded file even when it was inserted SECOND", () => {
|
||||||
|
const graph = graphOf([fontEntry(GOOD_URL, "/assets/cloned/fonts/good.woff2")]);
|
||||||
|
// Insertion order mirrors the real race: the mis-based CSSOM face lands first, the correctly
|
||||||
|
// based css-text face second. Only the good url is a downloaded asset.
|
||||||
|
const faces: FontFace[] = [
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: `url("${BAD_URL}") format("woff2")` },
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
|
||||||
|
];
|
||||||
|
const { entries, css } = buildFontGraph(faces, graph, DOC);
|
||||||
|
const gothic = entries.filter((e) => e.family === "Gothic");
|
||||||
|
assert.equal(gothic.length, 1, "deduped to a single face");
|
||||||
|
assert.equal(gothic[0]!.status, "resolved");
|
||||||
|
assert.deepEqual(gothic[0]!.localPaths, ["/assets/cloned/fonts/good.woff2"]);
|
||||||
|
assert.match(css, /good\.woff2/);
|
||||||
|
assert.doesNotMatch(css, /impostor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the FIRST face when neither resolves (fallback, ties hold order)", () => {
|
||||||
|
const graph = graphOf([]); // nothing downloaded
|
||||||
|
const faces: FontFace[] = [
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: `url("${BAD_URL}") format("woff2")` },
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
|
||||||
|
];
|
||||||
|
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||||
|
const gothic = entries.filter((e) => e.family === "Gothic");
|
||||||
|
assert.equal(gothic.length, 1);
|
||||||
|
assert.equal(gothic[0]!.status, "fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the FIRST resolving face when BOTH resolve (ties hold insertion order)", () => {
|
||||||
|
const alt = "https://host.example/marketing-static/_next/static/media/alt.woff2";
|
||||||
|
const graph = graphOf([
|
||||||
|
fontEntry(GOOD_URL, "/assets/cloned/fonts/good.woff2"),
|
||||||
|
fontEntry(alt, "/assets/cloned/fonts/alt.woff2"),
|
||||||
|
]);
|
||||||
|
const faces: FontFace[] = [
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: `url("${alt}") format("woff2")` },
|
||||||
|
];
|
||||||
|
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||||
|
const gothic = entries.filter((e) => e.family === "Gothic");
|
||||||
|
assert.equal(gothic.length, 1);
|
||||||
|
assert.deepEqual(gothic[0]!.localPaths, ["/assets/cloned/fonts/good.woff2"], "first resolving wins");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- baseHref-driven relative resolution in buildFontGraph ----------------------------------
|
||||||
|
|
||||||
|
describe("buildFontGraph resolves a face's relative src against its owning sheet (baseHref)", () => {
|
||||||
|
it("uses baseHref, not the document url, so ../media climbs from the css file", () => {
|
||||||
|
// The sheet lives at /marketing-static/_next/static/css/sheet.css; `../media/x` from THERE is
|
||||||
|
// /marketing-static/_next/static/media/x.woff2, which is what actually downloaded. Resolved
|
||||||
|
// against the document (/page) it would clamp to /marketing-static/_next/static/media only if
|
||||||
|
// the css path were the base — the point of baseHref.
|
||||||
|
const sheetUrl = "https://host.example/marketing-static/_next/static/css/sheet.css";
|
||||||
|
const resolved = "https://host.example/marketing-static/_next/static/media/x.woff2";
|
||||||
|
const graph = graphOf([fontEntry(resolved, "/assets/cloned/fonts/x.woff2")]);
|
||||||
|
const faces: FontFace[] = [
|
||||||
|
{ family: "Gothic", weight: "400", style: "normal", src: 'url("../media/x.woff2") format("woff2")', baseHref: sheetUrl },
|
||||||
|
];
|
||||||
|
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||||
|
assert.equal(entries[0]!.status, "resolved");
|
||||||
|
assert.deepEqual(entries[0]!.localPaths, ["/assets/cloned/fonts/x.woff2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the document url when baseHref is absent (inline <style> / out-of-band parse)", () => {
|
||||||
|
// An out-of-band parse bakes the absolute url into src, so document fallback is harmless.
|
||||||
|
const abs = "https://host.example/fonts/y.woff2";
|
||||||
|
const graph = graphOf([fontEntry(abs, "/assets/cloned/fonts/y.woff2")]);
|
||||||
|
const faces: FontFace[] = [
|
||||||
|
{ family: "Mono", weight: "400", style: "normal", src: `url("${abs}") format("woff2")` },
|
||||||
|
];
|
||||||
|
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||||
|
assert.equal(entries[0]!.status, "resolved");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
|
import { collectPage, type PageSnapshot } from "../src/capture/walker.js";
|
||||||
|
|
||||||
|
// A relative url() inside a stylesheet must resolve against THAT sheet's url, not the document.
|
||||||
|
// These tests serve real CSS files at a NESTED path so `../media/x` climbs differently depending
|
||||||
|
// on the base: against the css file it lands under the nested dir, against the page it clamps to
|
||||||
|
// the site root. We assert the harvested url (cssUrls) and the face's baseHref carry the sheet
|
||||||
|
// base — the exact fix for the "HTML saved as woff2" font-materialization cluster.
|
||||||
|
|
||||||
|
// The nested layout mirrors a real bundler output:
|
||||||
|
// /page (document)
|
||||||
|
// /marketing-static/_next/static/css/x.css (external sheet)
|
||||||
|
// /marketing-static/_next/static/media/f.woff2 ← where ../media/f.woff2 SHOULD resolve
|
||||||
|
const CSS_PATH = "/marketing-static/_next/static/css/x.css";
|
||||||
|
const EXPECTED_MEDIA = "/marketing-static/_next/static/media/f.woff2";
|
||||||
|
// The WRONG (document-relative) resolution the old code produced:
|
||||||
|
const WRONG_MEDIA = "/media/f.woff2";
|
||||||
|
|
||||||
|
function serveText(body: string, type: string, res: import("node:http").ServerResponse): void {
|
||||||
|
res.writeHead(200, { "content-type": type });
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("font url() resolution against the owning stylesheet", () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
let server: Server;
|
||||||
|
let origin = "";
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
const url = (req.url || "/").split("?")[0];
|
||||||
|
if (url === "/page") {
|
||||||
|
return serveText(
|
||||||
|
`<!doctype html><html><head><link rel="stylesheet" href="${CSS_PATH}"></head><body><p>hi</p></body></html>`,
|
||||||
|
"text/html",
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url === "/import-host") {
|
||||||
|
// A same-origin sheet that @imports the nested sheet; the imported sheet's own url must be
|
||||||
|
// the base for its faces, not the host sheet or the page.
|
||||||
|
return serveText(
|
||||||
|
`<!doctype html><html><head><link rel="stylesheet" href="/top.css"></head><body><p>hi</p></body></html>`,
|
||||||
|
"text/html",
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url === "/top.css") {
|
||||||
|
return serveText(`@import url("${CSS_PATH}");`, "text/css", res);
|
||||||
|
}
|
||||||
|
if (url === CSS_PATH) {
|
||||||
|
return serveText(
|
||||||
|
`@font-face{font-family:"Gothic";font-style:normal;font-weight:400;` +
|
||||||
|
`src:url("../media/f.woff2") format("woff2");}` +
|
||||||
|
`.hero{background:url("../media/bg.png")}`,
|
||||||
|
"text/css",
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const addr = server.address();
|
||||||
|
if (addr && typeof addr === "object") origin = `http://127.0.0.1:${addr.port}`;
|
||||||
|
browser = await chromium.launch();
|
||||||
|
page = await browser.newPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const snap = async (path: string): Promise<PageSnapshot> => {
|
||||||
|
await page.goto(`${origin}${path}`, { waitUntil: "networkidle" });
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
return page.evaluate(collectPage) as Promise<PageSnapshot>;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("resolves a font-face src against the external sheet's url, not the document", async () => {
|
||||||
|
const s = await snap("/page");
|
||||||
|
assert.ok(
|
||||||
|
s.cssUrls.includes(`${origin}${EXPECTED_MEDIA}`),
|
||||||
|
`expected sheet-relative ${EXPECTED_MEDIA} in cssUrls, got ${JSON.stringify(s.cssUrls)}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!s.cssUrls.includes(`${origin}${WRONG_MEDIA}`),
|
||||||
|
"must NOT produce the document-relative (wrong) resolution",
|
||||||
|
);
|
||||||
|
const face = s.fontFaces.find((f) => f.family === "Gothic");
|
||||||
|
assert.ok(face, "font face captured");
|
||||||
|
assert.equal(face!.baseHref, `${origin}${CSS_PATH}`, "face carries its owning sheet's base");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves ordinary style-rule url() against the sheet too", async () => {
|
||||||
|
const s = await snap("/page");
|
||||||
|
assert.ok(
|
||||||
|
s.cssUrls.includes(`${origin}/marketing-static/_next/static/media/bg.png`),
|
||||||
|
`expected sheet-relative bg.png, got ${JSON.stringify(s.cssUrls)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an @import-nested sheet's face against the IMPORTED sheet's url", async () => {
|
||||||
|
const s = await snap("/import-host");
|
||||||
|
// top.css @imports x.css; the face lives in x.css, so ../media resolves from x.css's dir.
|
||||||
|
assert.ok(
|
||||||
|
s.cssUrls.includes(`${origin}${EXPECTED_MEDIA}`),
|
||||||
|
`@import base must be the imported sheet, got ${JSON.stringify(s.cssUrls)}`,
|
||||||
|
);
|
||||||
|
const face = s.fontFaces.find((f) => f.family === "Gothic");
|
||||||
|
assert.equal(face?.baseHref, `${origin}${CSS_PATH}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the document base for an inline <style> (no sheet href)", async () => {
|
||||||
|
await page.goto(`${origin}/page`, { waitUntil: "networkidle" });
|
||||||
|
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const st = document.createElement("style");
|
||||||
|
st.textContent =
|
||||||
|
'@font-face{font-family:"Inline";src:url("./inline/g.woff2") format("woff2")}';
|
||||||
|
document.head.appendChild(st);
|
||||||
|
});
|
||||||
|
const s = (await page.evaluate(collectPage)) as PageSnapshot;
|
||||||
|
// document base is /page → ./inline/g.woff2 resolves to /inline/g.woff2 (page-relative).
|
||||||
|
assert.ok(
|
||||||
|
s.cssUrls.includes(`${origin}/inline/g.woff2`),
|
||||||
|
`inline <style> resolves against the document, got ${JSON.stringify(s.cssUrls)}`,
|
||||||
|
);
|
||||||
|
const face = s.fontFaces.find((f) => f.family === "Inline");
|
||||||
|
assert.equal(face?.baseHref, undefined, "inline face has no sheet href");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user