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>
101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
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);
|
||
});
|
||
});
|