Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes
Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
(collapsed sections, viewport-inconsistent captures); settle autoplay
carousels to home slide before every snapshot; force-reveal hidden
videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
picture>source through IR prune with srcset rewrite (mobile-crop-on-
desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
forms) with element-screenshot fallback; asset download retry +
visual-assets-missing reporting
Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
da537e68b8
commit
9aed2540aa
@@ -0,0 +1,37 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isRetryableAssetFailure, ASSET_RETRY_DELAY_MS } from "../src/capture/capture.js";
|
||||
|
||||
describe("asset download retry decision", () => {
|
||||
it("retries visual/font assets on network error (no response)", () => {
|
||||
for (const type of ["image", "svg", "video", "font"]) {
|
||||
assert.equal(isRetryableAssetFailure(type, null), true, `${type} + network error retries`);
|
||||
}
|
||||
});
|
||||
|
||||
it("retries visual/font assets on transient server states (5xx, 429)", () => {
|
||||
assert.equal(isRetryableAssetFailure("image", 500), true);
|
||||
assert.equal(isRetryableAssetFailure("image", 503), true);
|
||||
assert.equal(isRetryableAssetFailure("video", 502), true);
|
||||
assert.equal(isRetryableAssetFailure("font", 429), true);
|
||||
});
|
||||
|
||||
it("does NOT retry authoritative 4xx failures (404/403/410)", () => {
|
||||
assert.equal(isRetryableAssetFailure("image", 404), false);
|
||||
assert.equal(isRetryableAssetFailure("image", 403), false);
|
||||
assert.equal(isRetryableAssetFailure("video", 410), false);
|
||||
assert.equal(isRetryableAssetFailure("font", 401), false);
|
||||
});
|
||||
|
||||
it("does NOT retry non-visual asset types at all", () => {
|
||||
assert.equal(isRetryableAssetFailure("css", null), false);
|
||||
assert.equal(isRetryableAssetFailure("manifest", 500), false);
|
||||
assert.equal(isRetryableAssetFailure("lottie", 503), false);
|
||||
assert.equal(isRetryableAssetFailure("other", null), false);
|
||||
});
|
||||
|
||||
it("uses a fixed, bounded retry delay (deterministic — no jitter)", () => {
|
||||
assert.equal(typeof ASSET_RETRY_DELAY_MS, "number");
|
||||
assert.ok(ASSET_RETRY_DELAY_MS > 0 && ASSET_RETRY_DELAY_MS <= 2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import { generateCss } from "../src/generate/css.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
/** Computed style with the minimum the emitter reads, per viewport. */
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], visible = true): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { ...cs };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||
visibleByVp[vp] = visible;
|
||||
}
|
||||
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/css",
|
||||
title: "CSS Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: CANONICAL,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 4,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
/** The base-rule body for a selector (first non-banded `.c<id>{…}` block). */
|
||||
function baseRule(css: string, id: string): string {
|
||||
const m = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("generateCss list markers", () => {
|
||||
it("re-establishes list-style on a <ul> whose disc equals the parent's initial value", () => {
|
||||
// list-style-type's initial value is `disc` on EVERY element, so a real <ul disc>
|
||||
// equals its parent <div>'s computed value — but the reset (`ul, ol, menu
|
||||
// { list-style: none; }`) breaks the inheritance chain, so it must still emit.
|
||||
const li = node("n2", "li", computed({ display: "list-item" }));
|
||||
const ul = node("n1", "ul", computed(), [li]);
|
||||
const root = node("n0", "body", computed(), [ul]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("list-style-type:disc"));
|
||||
// The <li> inherits from the ul (not reset), so parent-equality elision still applies.
|
||||
assert.ok(!baseRule(css, "n2").includes("list-style-type"));
|
||||
});
|
||||
|
||||
it("does not emit list-style-type on non-list tags at the initial disc", () => {
|
||||
const div = node("n1", "div", computed());
|
||||
const root = node("n0", "body", computed(), [div]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(!baseRule(css, "n1").includes("list-style-type"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCss visibility", () => {
|
||||
it("emits visibility:hidden for a node hidden at the canonical viewport", () => {
|
||||
const hidden = node("n1", "div", computed({ visibility: "hidden" }), [], false);
|
||||
const shown = node("n2", "div", computed());
|
||||
const root = node("n0", "body", computed(), [hidden, shown]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||
assert.ok(!baseRule(css, "n2").includes("visibility"));
|
||||
});
|
||||
|
||||
it("restores inherited visibility at a band where the node is shown", () => {
|
||||
const n = node("n1", "div", computed({ visibility: "hidden" }), [], false);
|
||||
n.computedByVp[375]!.visibility = "visible";
|
||||
n.visibleByVp[375] = true;
|
||||
const root = node("n0", "body", computed(), [n]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||
const band = css.match(/@media \(max-width: \d+px\) \{\n([\s\S]*?)\n\}/);
|
||||
assert.ok(band?.[1]?.includes("visibility:inherit"));
|
||||
});
|
||||
|
||||
it("stays silent on the descendants of a hidden subtree (inheritance covers them)", () => {
|
||||
const child = node("n2", "div", computed({ visibility: "hidden" }), [], false);
|
||||
const parent = node("n1", "div", computed({ visibility: "hidden" }), [child], false);
|
||||
const root = node("n0", "body", computed(), [parent]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||
assert.ok(!baseRule(css, "n2").includes("visibility"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { PageSnapshot, RawNode, RawChild } from "../src/capture/walker.js";
|
||||
import {
|
||||
planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent, findFrameNode,
|
||||
} from "../src/capture/graft.js";
|
||||
import { captureSite } from "../src/capture/capture.js";
|
||||
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js";
|
||||
import { resolveTag, propsList } from "../src/generate/app.js";
|
||||
import { readJSON } from "../src/util/fsx.js";
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||
|
||||
// ---- Synthetic snapshot helpers (unit tests for the merge/offset logic) ----
|
||||
|
||||
function isText(c: RawChild): c is { text: string } {
|
||||
return (c as { text?: string }).text !== undefined;
|
||||
}
|
||||
|
||||
function raw(tag: string, attrs: Record<string, string> = {}, children: RawChild[] = [], over: Partial<RawNode> = {}): RawNode {
|
||||
return {
|
||||
tag, attrs,
|
||||
computed: { display: "block", position: "static" },
|
||||
bbox: { x: 0, y: 0, width: 100, height: 50 },
|
||||
visible: true,
|
||||
children,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function pageSnap(root: RawNode, url = "https://host.test/page"): PageSnapshot {
|
||||
return {
|
||||
doc: {
|
||||
url, title: "t",
|
||||
head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" },
|
||||
lang: "en", charset: "UTF-8", viewportWidth: 800, viewportHeight: 600,
|
||||
scrollWidth: 800, scrollHeight: 600, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "",
|
||||
metaViewport: "", nodeCount: 3, truncated: false,
|
||||
},
|
||||
root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("planForFrameUrl", () => {
|
||||
it("skips blank/js frames and ad/analytics/captcha domains", () => {
|
||||
assert.equal(planForFrameUrl(""), "skip");
|
||||
assert.equal(planForFrameUrl("about:blank"), "skip");
|
||||
assert.equal(planForFrameUrl("javascript:void(0)"), "skip");
|
||||
assert.equal(planForFrameUrl("https://googleads.g.doubleclick.net/pagead/ads"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html?id=GTM-X"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.google.com/recaptcha/api2/anchor"), "skip");
|
||||
});
|
||||
|
||||
it("screenshots media-player embeds instead of grafting their JS-built DOM", () => {
|
||||
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc123"), "still");
|
||||
assert.equal(planForFrameUrl("https://player.vimeo.com/video/1"), "still");
|
||||
assert.equal(planForFrameUrl("https://www.google.com/maps/embed?pb=x"), "still");
|
||||
});
|
||||
|
||||
it("grafts everything else (form/newsletter embeds)", () => {
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
|
||||
assert.equal(planForFrameUrl("http://127.0.0.1:4001/iframe-embed.html"), "graft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("graftFrameIntoSnapshot", () => {
|
||||
const mkHost = (): PageSnapshot =>
|
||||
pageSnap(raw("body", {}, [
|
||||
raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], {
|
||||
bbox: { x: 40, y: 30, width: 320, height: 160 },
|
||||
}),
|
||||
]));
|
||||
|
||||
const mkFrame = (): PageSnapshot => {
|
||||
const input = raw("input", { id: "email", placeholder: "Enter your email" }, [], {
|
||||
bbox: { x: 12, y: 34, width: 200, height: 30 },
|
||||
});
|
||||
const label = raw("label", { for: "email" }, [{ text: "Email" }]);
|
||||
const anchor = raw("a", { href: "#terms" }, [{ text: "Terms" }]);
|
||||
const img = raw("img", { src: "/pixel.png", srcset: "/pixel.png 1x, /pixel@2x.png 2x" });
|
||||
const form = raw("form", { id: "signup" }, [label, input, anchor, img], {
|
||||
bbox: { x: 0, y: 0, width: 320, height: 160 },
|
||||
});
|
||||
return pageSnap(raw("body", {}, [form]), "https://frame.test/embed");
|
||||
};
|
||||
|
||||
it("grafts the frame body as a <div> child of the iframe with offset bboxes", () => {
|
||||
const host = mkHost();
|
||||
const ok = graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||
assert.equal(ok, true);
|
||||
const iframe = findFrameNode(host.root, 0)!;
|
||||
assert.equal(iframe.children.length, 1);
|
||||
const wrapper = iframe.children[0] as RawNode;
|
||||
assert.equal(wrapper.tag, "div", "the grafted <body> is retagged to <div>");
|
||||
const form = wrapper.children[0] as RawNode;
|
||||
assert.deepEqual([form.bbox.x, form.bbox.y], [40, 30], "frame-doc coords shift by the content-box origin");
|
||||
const input = form.children.find((c) => !isText(c) && (c as RawNode).tag === "input") as RawNode;
|
||||
assert.deepEqual([input.bbox.x, input.bbox.y], [52, 64]);
|
||||
});
|
||||
|
||||
it("namespaces ids/for/#href so two frames cannot collide, and absolutizes frame-relative URLs", () => {
|
||||
const host = mkHost();
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||
const iframe = findFrameNode(host.root, 0)!;
|
||||
const form = (iframe.children[0] as RawNode).children[0] as RawNode;
|
||||
assert.equal(form.attrs.id, "f0-signup");
|
||||
const byTag = (t: string): RawNode => form.children.find((c) => !isText(c) && (c as RawNode).tag === t) as RawNode;
|
||||
assert.equal(byTag("input").attrs.id, "f0-email");
|
||||
assert.equal(byTag("label").attrs.for, "f0-email");
|
||||
assert.equal(byTag("a").attrs.href, "#f0-terms");
|
||||
assert.equal(byTag("img").attrs.src, "https://frame.test/pixel.png");
|
||||
assert.equal(byTag("img").attrs.srcset, "https://frame.test/pixel.png 1x, https://frame.test/pixel@2x.png 2x");
|
||||
});
|
||||
|
||||
it("makes the iframe clip like a real frame viewport (overflow:hidden)", () => {
|
||||
const host = mkHost();
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||
const iframe = findFrameNode(host.root, 0)!;
|
||||
assert.equal(iframe.computed.overflow, "hidden");
|
||||
assert.equal(iframe.computed.overflowY, "hidden");
|
||||
});
|
||||
|
||||
it("refuses to graft an empty/invisible frame (screenshot fallback territory)", () => {
|
||||
const host = mkHost();
|
||||
const empty = pageSnap(raw("body", {}, [raw("div", {}, [], { visible: false, bbox: { x: 0, y: 0, width: 0, height: 0 } })]), "https://frame.test/embed");
|
||||
assert.equal(frameHasRenderableContent(empty.root), false);
|
||||
assert.equal(graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, empty), false);
|
||||
assert.equal(findFrameNode(host.root, 0)!.children.length, 0);
|
||||
});
|
||||
|
||||
it("merges the frame's @keyframes into the page snapshot", () => {
|
||||
const host = mkHost();
|
||||
const frame = mkFrame();
|
||||
frame.keyframes = ["@keyframes spin { to { transform: rotate(360deg); } }"];
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----
|
||||
|
||||
describe("cross-origin iframe capture (integration)", () => {
|
||||
let hostServer: Server;
|
||||
let frameServer: Server;
|
||||
let hostUrl = "";
|
||||
let frameOrigin = "";
|
||||
let outDir = "";
|
||||
|
||||
// A 1x1 transparent PNG so the frame's <img src="/pixel.png"> resolves and downloads.
|
||||
const PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
const frameHtml = readFileSync(join(FIXTURES, "iframe-embed.html"), "utf8");
|
||||
frameServer = createServer((req, res) => {
|
||||
if (req.url?.startsWith("/pixel.png")) {
|
||||
res.writeHead(200, { "content-type": "image/png" });
|
||||
res.end(PIXEL);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "text/html" });
|
||||
res.end(frameHtml);
|
||||
});
|
||||
await new Promise<void>((r) => frameServer.listen(0, "127.0.0.1", r));
|
||||
frameOrigin = `http://127.0.0.1:${(frameServer.address() as { port: number }).port}`;
|
||||
|
||||
const hostHtml = readFileSync(join(FIXTURES, "iframe-host.html"), "utf8").replaceAll("{{FRAME_ORIGIN}}", frameOrigin);
|
||||
hostServer = createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/html" });
|
||||
res.end(hostHtml);
|
||||
});
|
||||
await new Promise<void>((r) => hostServer.listen(0, "127.0.0.1", r));
|
||||
// localhost:portA vs localhost:portB are DIFFERENT origins — the in-page walker
|
||||
// cannot see into the frame; only the Node-side graft can.
|
||||
hostUrl = `http://127.0.0.1:${(hostServer.address() as { port: number }).port}/`;
|
||||
outDir = mkdtempSync(join(tmpdir(), "ditto-iframe-graft-"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
hostServer?.close();
|
||||
frameServer?.close();
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("grafts the cross-origin form into the snapshot, merges its assets, and generates a <div>", async () => {
|
||||
const capture = await captureSite({
|
||||
url: hostUrl,
|
||||
outDir,
|
||||
viewports: [800],
|
||||
breakpoints: false,
|
||||
screenshots: false,
|
||||
});
|
||||
|
||||
// 1) The snapshot carries the grafted subtree under the iframe node.
|
||||
const snap = readJSON<PageSnapshot>(join(outDir, "capture", "dom-800.json"));
|
||||
const iframe = findFrameNode(snap.root, 0);
|
||||
assert.ok(iframe, "the iframe was stamped and captured");
|
||||
assert.equal(iframe!.children.length, 1, "the frame document was grafted");
|
||||
const wrapper = iframe!.children[0] as RawNode;
|
||||
assert.equal(wrapper.tag, "div");
|
||||
|
||||
const findIn = (n: RawNode, pred: (x: RawNode) => boolean): RawNode | null => {
|
||||
if (pred(n)) return n;
|
||||
for (const c of n.children) {
|
||||
if (isText(c)) continue;
|
||||
const hit = findIn(c, pred);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const input = findIn(wrapper, (n) => n.tag === "input")!;
|
||||
assert.ok(input, "the email input is part of the graft");
|
||||
assert.equal(input.attrs.id, "f0-email", "frame-internal ids are namespaced");
|
||||
assert.equal(input.attrs.placeholder, "Enter your email");
|
||||
assert.equal(input.placeholder?.color, "rgb(200, 150, 100)", "::placeholder captured inside the frame");
|
||||
// The iframe sits at margin-left:40px — grafted geometry is in page coordinates.
|
||||
assert.ok(input.bbox.x >= 40, `input.bbox.x (${input.bbox.x}) offset by the iframe origin`);
|
||||
const label = findIn(wrapper, (n) => n.tag === "label")!;
|
||||
assert.equal(label.attrs.for, "f0-email");
|
||||
const button = findIn(wrapper, (n) => n.tag === "button")!;
|
||||
assert.ok(button.visible, "the Sign up button is visible in the graft");
|
||||
|
||||
// 2) The frame's assets flow through the normal pipeline (absolute frame URL, downloaded).
|
||||
const pixel = capture.assets.find((a) => a.url === `${frameOrigin}/pixel.png`);
|
||||
assert.ok(pixel, "the frame-relative <img> was discovered under the frame's origin");
|
||||
assert.ok(pixel!.storedAs, "and its bytes were stored");
|
||||
|
||||
// 3) IR keeps the grafted children as ordinary nodes; generation emits a <div>.
|
||||
const ir = buildIR(outDir, [800]);
|
||||
const findIr = (n: IRNode, pred: (x: IRNode) => boolean): IRNode | null => {
|
||||
if (pred(n)) return n;
|
||||
for (const c of n.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const hit = findIr(c, pred);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const irIframe = findIr(ir.root, (n) => n.tag === "iframe")!;
|
||||
assert.ok(irIframe, "iframe survives into the IR");
|
||||
assert.ok(irIframe.children.some((c) => !isTextChild(c)), "grafted children survive the IR prune");
|
||||
assert.equal(resolveTag(irIframe, false), "div", "a grafted iframe renders as a positioned <div>");
|
||||
const props = new Map(propsList(irIframe, new Map(), hostUrl));
|
||||
assert.equal(props.get("src"), undefined, "document-loading attrs stay dropped");
|
||||
const irInput = findIr(ir.root, (n) => n.tag === "input")!;
|
||||
assert.ok(irInput.placeholderByVp?.[800], "placeholder style flows into the IR");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
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 type { RawNode, RawChild } from "../src/capture/walker.js";
|
||||
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
|
||||
function raw(tag: string, attrs: Record<string, string> = {}, children: RawChild[] = [], visible = true): RawNode {
|
||||
return {
|
||||
tag, attrs,
|
||||
computed: { display: visible ? "block" : "none", position: "static", visibility: "visible" },
|
||||
bbox: { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 },
|
||||
visible,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal PageSnapshot JSON around a body tree (same tree at every viewport). */
|
||||
function snapshot(vp: number, root: RawNode): object {
|
||||
return {
|
||||
doc: {
|
||||
url: "https://example.test/page", title: "Fixture",
|
||||
head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" },
|
||||
lang: "en", charset: "UTF-8", viewportWidth: vp, viewportHeight: 800,
|
||||
scrollWidth: vp, scrollHeight: 800, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)",
|
||||
bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial", metaViewport: "width=device-width, initial-scale=1",
|
||||
nodeCount: 10, truncated: false,
|
||||
},
|
||||
root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildFixtureIR(root: RawNode): IRNode {
|
||||
const sourceDir = mkdtempSync(join(tmpdir(), "ditto-ir-prune-"));
|
||||
mkdirSync(join(sourceDir, "capture"), { recursive: true });
|
||||
for (const vp of VPS) {
|
||||
writeFileSync(join(sourceDir, "capture", `dom-${vp}.json`), JSON.stringify(snapshot(vp, structuredClone(root))));
|
||||
}
|
||||
return buildIR(sourceDir, VPS).root;
|
||||
}
|
||||
|
||||
function findByTag(node: IRNode, tag: string): IRNode | null {
|
||||
if (node.tag === tag) return node;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const hit = findByTag(c, tag);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("IR prune keeps <source> media candidates", () => {
|
||||
it("keeps invisible source children of <picture> and <video>, still pruning other invisibles", () => {
|
||||
// <source> is 0×0 at EVERY viewport (never painted), which the visibility prune
|
||||
// reads as unobserved — real case (ooni.com): 47 <source> in dom-1280.json, 0 in
|
||||
// ir.json, so the mobile img.src got baked into desktop layouts.
|
||||
const picture = raw("picture", {}, [
|
||||
raw("source", { srcset: "/img/hero-1280.jpg 1x", media: "(min-width: 768px)" }, [], false),
|
||||
raw("img", { src: "/img/hero-375.jpg", alt: "hero" }),
|
||||
]);
|
||||
const video = raw("video", { autoplay: "" }, [
|
||||
raw("source", { src: "/media/hero.mp4", type: "video/mp4" }, [], false),
|
||||
]);
|
||||
const noise = raw("div", {}, [raw("span", {}, [], false)], false); // invisible everywhere → pruned
|
||||
const body = raw("body", {}, [picture, video, noise]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
|
||||
const pic = findByTag(root, "picture");
|
||||
assert.ok(pic, "picture survives");
|
||||
const picTags = pic!.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||
assert.deepEqual(picTags, ["source", "img"]);
|
||||
const src = findByTag(pic!, "source")!;
|
||||
assert.equal(src.attrs.srcset, "/img/hero-1280.jpg 1x");
|
||||
assert.equal(src.attrs.media, "(min-width: 768px)");
|
||||
|
||||
const vid = findByTag(root, "video");
|
||||
assert.ok(vid, "video survives");
|
||||
const vidTags = vid!.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||
assert.deepEqual(vidTags, ["source"]);
|
||||
|
||||
// The carve-out is scoped: unrelated invisible-everywhere subtrees still prune.
|
||||
assert.equal(findByTag(root, "span"), null);
|
||||
});
|
||||
|
||||
it("does not keep a <source> outside picture/video, nor resurrect a pruned picture", () => {
|
||||
const orphan = raw("div", {}, [raw("source", { srcset: "/x.jpg" }, [], false)]);
|
||||
// A picture invisible everywhere with no visible descendants is still pruned whole.
|
||||
const ghost = raw("picture", {}, [raw("source", { srcset: "/y.jpg" }, [], false)], false);
|
||||
const body = raw("body", {}, [orphan, ghost]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
|
||||
assert.equal(findByTag(root, "source"), null);
|
||||
assert.equal(findByTag(root, "picture"), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import { propsList, renderChildrenJsx } from "../src/generate/app.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const SOURCE = "https://example.test/page";
|
||||
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
function el(id: string, tag: string, attrs: Record<string, string> = {}, children: IRChild[] = [], visible = true): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { display: "block", position: "static", visibility: "visible" };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 };
|
||||
visibleByVp[vp] = visible;
|
||||
}
|
||||
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function props(node: IRNode, assetMap: Map<string, string>): Map<string, string> {
|
||||
return new Map(propsList(node, assetMap, SOURCE));
|
||||
}
|
||||
|
||||
describe("video src emission", () => {
|
||||
it("ships the local file and mirrors captured playback attrs when the src materialized", () => {
|
||||
const assetMap = new Map([
|
||||
["https://example.test/media/hero.mp4", "/assets/cloned/videos/ab.mp4"],
|
||||
["https://example.test/img/poster.jpg", "/assets/cloned/images/cd.jpg"],
|
||||
]);
|
||||
const video = el("n1", "video", {
|
||||
src: "/media/hero.mp4", poster: "/img/poster.jpg",
|
||||
autoplay: "", loop: "", muted: "", playsinline: "",
|
||||
});
|
||||
const p = props(video, assetMap);
|
||||
assert.equal(p.get("src"), JSON.stringify("/assets/cloned/videos/ab.mp4"));
|
||||
assert.equal(p.get("poster"), JSON.stringify("/assets/cloned/images/cd.jpg"));
|
||||
assert.equal(p.get("autoPlay"), "true");
|
||||
assert.equal(p.get("loop"), "true");
|
||||
assert.equal(p.get("muted"), "true");
|
||||
assert.equal(p.get("playsInline"), "true");
|
||||
// preload is mirrored, not forced: none captured → none emitted.
|
||||
assert.equal(p.get("preload"), undefined);
|
||||
});
|
||||
|
||||
it("falls back to poster-only when no video file materialized", () => {
|
||||
const assetMap = new Map([
|
||||
["https://example.test/img/poster.jpg", "/assets/cloned/images/cd.jpg"],
|
||||
]);
|
||||
const video = el("n1", "video", { src: "/media/hero.mp4", poster: "/img/poster.jpg", autoplay: "", loop: "" });
|
||||
const p = props(video, assetMap);
|
||||
assert.equal(p.get("src"), undefined);
|
||||
assert.equal(p.get("autoPlay"), undefined);
|
||||
assert.equal(p.get("loop"), undefined);
|
||||
assert.equal(p.get("poster"), JSON.stringify("/assets/cloned/images/cd.jpg"));
|
||||
assert.equal(p.get("preload"), JSON.stringify("none"));
|
||||
});
|
||||
|
||||
it("treats a materialized child <source> as a shippable video (src carried by the child)", () => {
|
||||
const assetMap = new Map([["https://example.test/media/hero.webm", "/assets/cloned/videos/ef.webm"]]);
|
||||
const source = el("n2", "source", { src: "/media/hero.webm", type: "video/webm" }, [], false);
|
||||
const video = el("n1", "video", { autoplay: "", muted: "" }, [source]);
|
||||
const p = props(video, assetMap);
|
||||
assert.equal(p.get("autoPlay"), "true");
|
||||
assert.equal(p.get("preload"), undefined);
|
||||
const jsx = renderChildrenJsx([video], assetMap, SOURCE, 0);
|
||||
assert.match(jsx, /<source[^>]*src="\/assets\/cloned\/videos\/ef\.webm"[^>]*\/>/);
|
||||
assert.match(jsx, /type="video\/webm"/);
|
||||
});
|
||||
|
||||
it("drops non-materialized <source> children (poster-only video keeps none)", () => {
|
||||
const source = el("n2", "source", { src: "/media/hero.webm", type: "video/webm" }, [], false);
|
||||
const video = el("n1", "video", { autoplay: "" }, [source]);
|
||||
const jsx = renderChildrenJsx([video], new Map(), SOURCE, 0);
|
||||
assert.ok(!jsx.includes("<source"));
|
||||
assert.ok(!jsx.includes("autoPlay"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("poster fallback policy", () => {
|
||||
it("DROPS a poster that missed the asset map instead of substituting the transparent GIF", () => {
|
||||
const video = el("n1", "video", { poster: "https://clone-still.local/0-abc.jpg" });
|
||||
const p = props(video, new Map());
|
||||
assert.equal(p.get("poster"), undefined);
|
||||
assert.ok(![...p.values()].includes(JSON.stringify(GIF)));
|
||||
});
|
||||
|
||||
it("keeps the transparent-GIF fallback for a missed <img> src", () => {
|
||||
const img = el("n1", "img", { src: "/img/gone.png", alt: "" });
|
||||
const p = props(img, new Map());
|
||||
assert.equal(p.get("src"), JSON.stringify(GIF));
|
||||
});
|
||||
});
|
||||
|
||||
describe("picture>source rewriting", () => {
|
||||
const mkPicture = () => {
|
||||
const desktop = el("n2", "source", {
|
||||
srcset: "/img/hero-1280.jpg 1x, /img/hero-2560.jpg 2x",
|
||||
media: "(min-width: 768px)", type: "image/jpeg", sizes: "100vw",
|
||||
}, [], false);
|
||||
const gone = el("n3", "source", { srcset: "/img/never-downloaded.avif", media: "(min-width: 1024px)" }, [], false);
|
||||
const img = el("n4", "img", { src: "/img/hero-375.jpg", alt: "hero" });
|
||||
return el("n1", "picture", {}, [desktop, gone, img]);
|
||||
};
|
||||
|
||||
it("emits surviving srcset candidates with media/type/sizes preserved, omits sources with none", () => {
|
||||
const assetMap = new Map([
|
||||
["https://example.test/img/hero-1280.jpg", "/assets/cloned/images/a.jpg"],
|
||||
["https://example.test/img/hero-375.jpg", "/assets/cloned/images/m.jpg"],
|
||||
]);
|
||||
const jsx = renderChildrenJsx([mkPicture()], assetMap, SOURCE, 0);
|
||||
// Desktop source survives with ONLY the materialized candidate; void element.
|
||||
assert.match(jsx, /<source[^>]*srcSet="\/assets\/cloned\/images\/a\.jpg 1x"[^>]*\/>/);
|
||||
assert.ok(!jsx.includes("hero-2560"));
|
||||
assert.match(jsx, /media="\(min-width: 768px\)"/);
|
||||
assert.match(jsx, /type="image\/jpeg"/);
|
||||
assert.match(jsx, /sizes="100vw"/);
|
||||
// The fully-missed source is omitted entirely (no placeholder-pointing variant).
|
||||
assert.ok(!jsx.includes("never-downloaded"));
|
||||
assert.ok(!jsx.includes("(min-width: 1024px)"));
|
||||
// The <img> fallback keeps its captured (rewritten) src.
|
||||
assert.match(jsx, /<img[^>]*src="\/assets\/cloned\/images\/m\.jpg"/);
|
||||
// <source> stays a void element: no children, no closing tag.
|
||||
assert.ok(!jsx.includes("</source>"));
|
||||
});
|
||||
|
||||
it("omits every source when nothing materialized, keeping the img fallback", () => {
|
||||
const jsx = renderChildrenJsx([mkPicture()], new Map(), SOURCE, 0);
|
||||
assert.ok(!jsx.includes("<source"));
|
||||
assert.match(jsx, new RegExp(`<img[^>]*src="${GIF.replace(/[+/]/g, (c) => "\\" + c)}"`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { collectPage, type RawNode, type RawChild } from "../src/capture/walker.js";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import { generateCss } from "../src/generate/css.js";
|
||||
import { buildTailwind } from "../src/generate/tailwind.js";
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||
|
||||
function isText(c: RawChild): c is { text: string } {
|
||||
return (c as { text?: string }).text !== undefined;
|
||||
}
|
||||
|
||||
function findBy(root: RawNode, pred: (n: RawNode) => boolean): RawNode | null {
|
||||
if (pred(root)) return root;
|
||||
for (const c of root.children) {
|
||||
if (isText(c)) continue;
|
||||
const hit = findBy(c, pred);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("walker ::placeholder capture", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("captures the styled ::placeholder color/font of input and textarea", async () => {
|
||||
await page.setContent(readFileSync(join(FIXTURES, "placeholder.html"), "utf8"));
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
const snap = await page.evaluate(collectPage);
|
||||
|
||||
const input = findBy(snap.root, (n) => n.tag === "input" && n.attrs.class === "styled")!;
|
||||
assert.ok(input.placeholder, "styled input carries a placeholder style");
|
||||
assert.equal(input.placeholder!.color, "rgb(120, 30, 200)");
|
||||
assert.equal(input.placeholder!.fontSize, "14px");
|
||||
|
||||
const textarea = findBy(snap.root, (n) => n.tag === "textarea")!;
|
||||
assert.ok(textarea.placeholder, "styled textarea carries a placeholder style");
|
||||
assert.equal(textarea.placeholder!.color, "rgb(5, 100, 5)");
|
||||
});
|
||||
|
||||
it("does not attach placeholder style to a control without placeholder text", async () => {
|
||||
await page.setContent(readFileSync(join(FIXTURES, "placeholder.html"), "utf8"));
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
const snap = await page.evaluate(collectPage);
|
||||
const plain = findBy(snap.root, (n) => n.tag === "input" && n.attrs.class === "plain")!;
|
||||
assert.equal(plain.placeholder, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Generated CSS ----
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { ...cs };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: 200, height: 40 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/placeholder",
|
||||
title: "Placeholder Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: CANONICAL,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 2,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateCss ::placeholder emission", () => {
|
||||
it("emits a ::placeholder rule with the captured color", () => {
|
||||
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)", fontSize: "16px" }));
|
||||
input.placeholderByVp = {
|
||||
375: { color: "rgb(120, 30, 200)", fontSize: "14px" },
|
||||
1280: { color: "rgb(120, 30, 200)", fontSize: "14px" },
|
||||
};
|
||||
const root = node("n0", "body", computed(), [input]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
const m = css.match(/\.cn1::placeholder\{([^}]*)\}/);
|
||||
assert.ok(m, "a .cn1::placeholder rule is emitted");
|
||||
assert.ok(m![1]!.includes("color:rgb(120, 30, 200)"));
|
||||
// fontSize differs from the host's own 16px, so it must be emitted too.
|
||||
assert.ok(m![1]!.includes("font-size:14px"));
|
||||
});
|
||||
|
||||
it("omits font props the placeholder inherits from the input itself", () => {
|
||||
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)", fontSize: "16px", fontFamily: "Arial" }));
|
||||
input.placeholderByVp = {
|
||||
375: { color: "rgb(200, 150, 100)", fontSize: "16px", fontFamily: "Arial" },
|
||||
1280: { color: "rgb(200, 150, 100)", fontSize: "16px", fontFamily: "Arial" },
|
||||
};
|
||||
const root = node("n0", "body", computed(), [input]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
const m = css.match(/\.cn1::placeholder\{([^}]*)\}/);
|
||||
assert.ok(m);
|
||||
assert.ok(m![1]!.includes("color:rgb(200, 150, 100)"));
|
||||
assert.ok(!m![1]!.includes("font-size"), "inherited font-size is not re-declared");
|
||||
assert.ok(!m![1]!.includes("font-family"), "inherited font-family is not re-declared");
|
||||
});
|
||||
|
||||
it("bands a placeholder color that changes across viewports", () => {
|
||||
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
|
||||
input.placeholderByVp = {
|
||||
375: { color: "rgb(1, 2, 3)" },
|
||||
1280: { color: "rgb(120, 30, 200)" },
|
||||
};
|
||||
const root = node("n0", "body", computed(), [input]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(/\.cn1::placeholder\{[^}]*rgb\(120, 30, 200\)/.test(css), "base carries the canonical color");
|
||||
assert.ok(/@media \(max-width: \d+px\)[\s\S]*\.cn1::placeholder\{[^}]*rgb\(1, 2, 3\)/.test(css), "mobile band overrides the color");
|
||||
});
|
||||
|
||||
it("emits no ::placeholder rule for nodes without placeholder styles", () => {
|
||||
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
|
||||
const root = node("n0", "body", computed(), [input]);
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
assert.ok(!css.includes("::placeholder"));
|
||||
});
|
||||
|
||||
it("tailwind mode (the default pipeline) carries the rule in ditto.css keyed by data-cid", () => {
|
||||
const input = node("n1", "input", computed({ color: "rgb(10, 20, 30)" }));
|
||||
input.placeholderByVp = {
|
||||
375: { color: "rgb(120, 30, 200)" },
|
||||
1280: { color: "rgb(120, 30, 200)" },
|
||||
};
|
||||
const root = node("n0", "body", computed(), [input]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
assert.ok(/\[data-cid="n1"\]::placeholder\s*\{[^}]*color:\s*rgb\(120, 30, 200\)/.test(tw.pseudoCss),
|
||||
`pseudoCss carries the placeholder rule:\n${tw.pseudoCss}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeBands } from "../src/generate/css.js";
|
||||
import { bandScreens, prefixFor, tailwindGlobalsCss } from "../src/generate/tailwind.js";
|
||||
|
||||
/** Interval covered by a ditto.css band media query (integer px, inclusive). */
|
||||
function bandInterval(media: string | null): [number, number] {
|
||||
if (!media) return [0, Infinity];
|
||||
const min = /min-width:\s*(\d+)px/.exec(media);
|
||||
const max = /max-width:\s*(\d+)px/.exec(media);
|
||||
return [min ? +min[1]! : 0, max ? +max[1]! : Infinity];
|
||||
}
|
||||
|
||||
/** Integer interval covered by a Tailwind variant prefix under the given screens.
|
||||
* v4 semantics for named AND arbitrary variants: `X:` = width >= X; `max-X:` = width < X. */
|
||||
function prefixInterval(prefix: string, screens: Map<string, number>): [number, number] {
|
||||
let lo = 0, hi = Infinity;
|
||||
for (const m of prefix.matchAll(/(max-)?(?:(sm|md|lg|xl|2xl)|(?:min-)?\[(\d+)px\]):/g)) {
|
||||
const px = m[3] ? +m[3] : screens.get(m[2]!);
|
||||
assert.ok(px !== undefined, `screen defined for ${m[0]}`);
|
||||
if (m[1]) hi = Math.min(hi, px! - 1);
|
||||
else lo = Math.max(lo, px!);
|
||||
}
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
function assertAgreement(viewports: number[], canonical: number): void {
|
||||
const screens = new Map(bandScreens(viewports, canonical));
|
||||
for (const b of computeBands(viewports, canonical)) {
|
||||
if (!b.media) continue;
|
||||
assert.deepEqual(
|
||||
prefixInterval(prefixFor(b.media), screens),
|
||||
bandInterval(b.media),
|
||||
`band vp${b.vp} (${b.media}) matches its Tailwind prefix`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("tailwind screens agree with computeBands boundaries", () => {
|
||||
it("standard 375/768/1280/1920 ladder: md/lg/2xl pinned to the band midpoint boundaries", () => {
|
||||
const screens = new Map(bandScreens([375, 768, 1280, 1920], 1280));
|
||||
// Midpoints 571/1024/1600 → bands ≤571, 572–1024, base, ≥1601.
|
||||
assert.deepEqual([...screens], [["md", 572], ["lg", 1025], ["2xl", 1601]]);
|
||||
// NOT Tailwind's stock 768/1024/1536 — stock 2xl (1536) would flip utility-classed
|
||||
// nodes to the 1920 layout in 1536–1600 while ditto.css still holds the 1280 layout.
|
||||
assert.notEqual(screens.get("2xl"), 1536);
|
||||
assertAgreement([375, 768, 1280, 1920], 1280);
|
||||
});
|
||||
|
||||
it("odd viewport set: bands fall back to exact arbitrary prefixes (no named screens needed)", () => {
|
||||
assert.deepEqual(bandScreens([320, 900, 1440], 900), []);
|
||||
assertAgreement([320, 900, 1440], 900);
|
||||
});
|
||||
|
||||
it("emits the derived screens into the generated @theme", () => {
|
||||
const css = tailwindGlobalsCss({
|
||||
reset: "", fontCss: "", tokensCss: "", htmlBg: "#fff", bodyFont: "sans-serif",
|
||||
clip: "", colorTokens: [], viewports: [375, 768, 1280, 1920], canonical: 1280,
|
||||
});
|
||||
assert.ok(css.includes("--breakpoint-md: 572px;"));
|
||||
assert.ok(css.includes("--breakpoint-lg: 1025px;"));
|
||||
assert.ok(css.includes("--breakpoint-2xl: 1601px;"));
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
seoRouteFiles,
|
||||
} from "../src/generate/seo.js";
|
||||
import { agentsMd, architectureMd } from "../src/generate/docs.js";
|
||||
import { NEXT_CONFIG } from "../src/generate/app.js";
|
||||
|
||||
function fixtureIr(): IR {
|
||||
return {
|
||||
@@ -169,4 +170,28 @@ describe("SEO inventory and emission", () => {
|
||||
assert.ok(agentsMd(docsInput).includes("src/app/ditto"));
|
||||
assert.ok(architectureMd(docsInput).includes("data-ditto-id"));
|
||||
});
|
||||
|
||||
it("passes a Next-supported og:type through to openGraph", () => {
|
||||
const ir = fixtureIr();
|
||||
ir.doc.head!.meta!.push({ property: "og:type", content: "article" });
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const metadata = metadataExport(report);
|
||||
assert.ok(metadata.includes('"type": "article"'));
|
||||
assert.ok(!metadata.includes('"og:type"'));
|
||||
});
|
||||
|
||||
it("routes an unsupported og:type into metadata.other (Next throws on unknown enum values)", () => {
|
||||
const ir = fixtureIr();
|
||||
ir.doc.head!.meta!.push({ property: "og:type", content: "product.group" });
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const metadata = metadataExport(report);
|
||||
assert.ok(metadata.includes('"og:type": "product.group"'));
|
||||
assert.ok(!metadata.includes('"type": "product.group"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated Next config", () => {
|
||||
it("disables the dev-tools badge so it cannot leak into screenshots", () => {
|
||||
assert.ok(NEXT_CONFIG.includes("devIndicators: false"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import {
|
||||
promoteLazyMediaInPage,
|
||||
settleCarouselsInPage,
|
||||
forceRevealForShot,
|
||||
restoreRevealForShot,
|
||||
} from "../src/capture/stabilize.js";
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||
// tsx/esbuild wraps functions with a __name() helper for stack traces; the serialized
|
||||
// page functions carry those calls, so shim it (same as capture.ts's init script).
|
||||
const ESBUILD_SHIM = "globalThis.__name = globalThis.__name || ((fn) => fn);";
|
||||
|
||||
describe("stabilize: lazy-media promotion", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.goto(pathToFileURL(join(FIXTURES, "lazy.html")).href);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("promotes data-lazy-src/data-src/data-srcset/data-bg and measures the loaded size", async () => {
|
||||
const count = await page.evaluate(promoteLazyMediaInPage);
|
||||
// lazy1 (src), lazy2 (src), lazysource (srcset), lazy3 (src), lazybg (background)
|
||||
assert.equal(count, 5);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const attr = (id: string, name: string) => document.getElementById(id)?.getAttribute(name) ?? null;
|
||||
const img = (id: string) => document.getElementById(id) as HTMLImageElement;
|
||||
return {
|
||||
lazy1Src: attr("lazy1", "src"),
|
||||
lazy1Loading: attr("lazy1", "loading"),
|
||||
lazy1Width: img("lazy1").naturalWidth,
|
||||
lazy2Src: attr("lazy2", "src"),
|
||||
lazy2Sizes: attr("lazy2", "sizes"), // data-sizes="auto" is a flag, not a value
|
||||
sourceSrcset: attr("lazysource", "srcset"),
|
||||
lazy3Src: attr("lazy3", "src"),
|
||||
bg: (document.getElementById("lazybg") as HTMLElement).style.backgroundImage,
|
||||
notaurlSrc: attr("notaurl", "src"),
|
||||
};
|
||||
});
|
||||
assert.equal(state.lazy1Src, "og-image.png");
|
||||
assert.equal(state.lazy1Loading, "eager");
|
||||
assert.ok(state.lazy1Width > 1, "promoted image decoded to its real size");
|
||||
assert.equal(state.lazy2Src, "seo-icon.png");
|
||||
assert.equal(state.lazy2Sizes, null);
|
||||
assert.equal(state.sourceSrcset, "og-image.png 1x");
|
||||
assert.equal(state.lazy3Src, "og-image.png");
|
||||
assert.ok(state.bg.includes("brand.svg"), `background promoted (got ${state.bg})`);
|
||||
assert.equal(state.notaurlSrc, null, "non-URL data attr must not be promoted");
|
||||
});
|
||||
|
||||
it("is idempotent and never clobbers an src that already equals the target", async () => {
|
||||
const again = await page.evaluate(promoteLazyMediaInPage);
|
||||
assert.equal(again, 0);
|
||||
const alreadySrc = await page.evaluate(() => document.getElementById("already")?.getAttribute("src"));
|
||||
assert.equal(alreadySrc, "seo-icon.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stabilize: carousel settling", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.goto(pathToFileURL(join(FIXTURES, "carousel-autoplay.html")).href);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const txOf = (sel: string) =>
|
||||
page.evaluate((s) => {
|
||||
const el = document.querySelector(s)!;
|
||||
return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41;
|
||||
}, sel);
|
||||
|
||||
it("returns an autoplaying carousel to its home slide and pauses autoplay", async () => {
|
||||
// let autoplay advance at least one slide so there is something to settle
|
||||
await page.waitForFunction("window.__autoplayTicks >= 2", null, { timeout: 5000 });
|
||||
assert.notEqual(await txOf(".splide__list"), 0);
|
||||
|
||||
const res = await page.evaluate(settleCarouselsInPage);
|
||||
assert.equal(res.roots, 2);
|
||||
assert.equal(res.normalized, 2);
|
||||
|
||||
assert.equal(await txOf(".splide__list"), 0, "track back at the home slide");
|
||||
const bulletActive = await page.evaluate(() =>
|
||||
document.querySelector(".splide__pagination__bullet")!.classList.contains("is-active"));
|
||||
assert.ok(bulletActive, "first bullet re-activated");
|
||||
|
||||
// paused: no further autoplay ticks, and the track holds its home transform
|
||||
const ticks = await page.evaluate("window.__autoplayTicks");
|
||||
await page.waitForTimeout(700); // > 2 autoplay intervals
|
||||
assert.equal(await page.evaluate("window.__autoplayTicks"), ticks, "autoplay latched paused");
|
||||
assert.equal(await txOf(".splide__list"), 0, "track still at home after the autoplay window");
|
||||
});
|
||||
|
||||
it("pins a control-less non-loop track left mid-offset back to translateX(0)", async () => {
|
||||
assert.equal(await txOf("#stuck .swiper-wrapper"), 0);
|
||||
});
|
||||
|
||||
it("cancels a mid-flight track transition instead of freezing it", async () => {
|
||||
// Start a slide transition and settle while it is still running: pausing the
|
||||
// CSSTransition would disassociate it from style and HOLD the frozen mid-flight
|
||||
// transform over the home navigation — the settled track must still land at 0.
|
||||
await page.evaluate(`(async () => {
|
||||
const bullets = document.querySelectorAll(".splide__pagination__bullet");
|
||||
bullets[2].click(); // transition toward slide 3 begins (0.2s)
|
||||
return await (${settleCarouselsInPage.toString()})();
|
||||
})()`);
|
||||
assert.equal(await txOf(".splide__list"), 0, "track settled at home, not a frozen mid-flight offset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stabilize: force-reveal for element screenshots", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(
|
||||
'<div id="wrap" style="visibility:hidden">' +
|
||||
'<video id="v" style="width:320px;height:180px;background:#000"></video>' +
|
||||
"</div>",
|
||||
);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("reveals the hidden ancestor chain so the screenshot succeeds, then restores exactly", async () => {
|
||||
const vis = () => page.evaluate(() => getComputedStyle(document.getElementById("v")!).visibility);
|
||||
assert.equal(await vis(), "hidden");
|
||||
|
||||
const forced = await page.evaluate(forceRevealForShot, "#v");
|
||||
assert.equal(forced, 2); // the video (inherited hidden) + the wrapping div
|
||||
assert.equal(await vis(), "visible");
|
||||
|
||||
const buf = await page.locator("#v").screenshot({ type: "jpeg", quality: 82, timeout: 2000, animations: "disabled" });
|
||||
assert.ok(buf.length > 0, "screenshot captured while revealed");
|
||||
|
||||
await page.evaluate(restoreRevealForShot);
|
||||
assert.equal(await vis(), "hidden");
|
||||
const after = await page.evaluate(() => ({
|
||||
wrapInline: (document.getElementById("wrap") as HTMLElement).style.visibility,
|
||||
videoInline: (document.getElementById("v") as HTMLElement).style.visibility,
|
||||
markers: document.querySelectorAll("[data-clone-vis-restore]").length,
|
||||
}));
|
||||
assert.equal(after.wrapInline, "hidden", "original inline value restored");
|
||||
assert.equal(after.videoInline, "", "no inline visibility left on the video");
|
||||
assert.equal(after.markers, 0, "restore markers removed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { collectPage, type RawNode, type RawChild } from "../src/capture/walker.js";
|
||||
|
||||
function isText(c: RawChild): c is { text: string } {
|
||||
return (c as { text?: string }).text !== undefined;
|
||||
}
|
||||
|
||||
function findByTag(root: RawNode, tag: string): RawNode | null {
|
||||
if (root.tag === tag) return root;
|
||||
for (const c of root.children) {
|
||||
if (isText(c)) continue;
|
||||
const hit = findByTag(c, tag);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function textRun(node: RawNode): string {
|
||||
let out = "";
|
||||
for (const c of node.children) out += isText(c) ? c.text : textRun(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("walker whitespace-only text nodes", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
// tsx/esbuild wraps functions with a __name() helper for stack traces; the
|
||||
// serialized collectPage carries those calls, so shim it (same as capture.ts).
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("keeps the lone space that is the only child of an inline element", async () => {
|
||||
// Real case (ooni.com): the space between "of" and "the" lives alone inside
|
||||
// <strong>; dropping it fuses the adjacent text runs ("ofthe").
|
||||
const snap = await capture("<p>Creator of<strong> </strong><em><strong>the world's</strong></em></p>");
|
||||
const p = findByTag(snap.root, "p")!;
|
||||
const strong = p.children.find((c) => !isText(c) && c.tag === "strong") as RawNode;
|
||||
assert.deepEqual(strong.children, [{ text: " " }]);
|
||||
assert.equal(textRun(p), "Creator of the world's");
|
||||
});
|
||||
|
||||
it("still keeps the single space between inline elements", async () => {
|
||||
const snap = await capture("<p><em>a</em> <em>b</em></p>");
|
||||
const p = findByTag(snap.root, "p")!;
|
||||
assert.equal(textRun(p), "a b");
|
||||
});
|
||||
|
||||
it("does not emit a space inside an empty block container", async () => {
|
||||
const snap = await capture("<main><section>\n \n</section></main>");
|
||||
const section = findByTag(snap.root, "section")!;
|
||||
assert.deepEqual(section.children, []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user