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:
Samraaj Bath
2026-07-03 17:45:42 -07:00
co-authored by Claude Fable 5
parent da537e68b8
commit 9aed2540aa
31 changed files with 2579 additions and 121 deletions
+37
View File
@@ -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);
});
});
+102
View File
@@ -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"));
});
});
+255
View File
@@ -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");
});
});
+100
View File
@@ -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);
});
});
+133
View File
@@ -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)}"`));
});
});
+164
View File
@@ -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}`);
});
});
+64
View File
@@ -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, 5721024, 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 15361600 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;"));
});
});
+25
View File
@@ -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"));
});
});
+166
View File
@@ -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");
});
});
+66
View File
@@ -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, []);
});
});