Files
ditto.site/compiler/test/irPrune.test.ts
T
Samraaj BathandClaude Fable 5 0dd71a68d9 Popup dismissal hardening: shadow roots, overlay units, scroll-lock recovery
- Overlay detection resolves effective z-index through stacking ancestors,
  treats a zero-size max-z wrapper plus its full-viewport descendant as one
  removable unit, pierces open shadow roots, and relaxes the z floor when
  the page is scroll-locked; a lock persisting after dismissal reports
  blocking even with no detected overlay
- clickDismiss runs in every frame (close buttons live in cross-origin
  popup iframes), matches decline/no-thanks/aria-label close affordances,
  and re-runs before every snapshot AND screenshot to catch late-mounting
  dialogs
- rootUnclamp triggers from IR content extent (a scroll-lock collapses
  captured scrollHeight) and strips vendor-injected body position/overflow
  locks when firing
- Pollution gate fails on the scroll-lock contradiction: scrollHeight
  pinned to one viewport while IR content spans multiple
- Frame graft skips popup-creative vendor hosts; inline embedded signup
  forms remain graftable (regression-tested)

322 tests pass (24 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:44:12 -07:00

185 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RawNode, RawChild } from "../src/capture/walker.js";
import { buildIR, isTextChild, neutralizeAnimatedTransforms, 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);
});
});
describe("IR drops font-metric probe nodes (fix 4)", () => {
it("excludes a walker-tagged probe node from the IR, keeping its siblings", () => {
const probe: RawNode = {
...raw("div", { class: "font-probe" }, [{ text: "Mgy" }]),
probe: true,
};
const real = raw("h1", { class: "title" }, [{ text: "Heading" }]);
const body = raw("body", {}, [real, probe]);
const root = buildFixtureIR(body);
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
assert.deepEqual(kept, ["h1"], "probe div is dropped, the real heading survives");
// Its text must not leak into the tree either.
assert.equal(findByTag(root, "div"), null);
});
});
describe("IR drops popup-vendor OVERLAY containers, keeps inline embedded forms", () => {
it("drops an email-capture popup overlay container (vendor overlay id) but keeps a real section", () => {
const overlay = raw("div", { id: "attentive_overlay" }, [
raw("iframe", { id: "attentive_creative", src: "https://creatives.attn.tv/x" }),
]);
const real = raw("section", { class: "hero" }, [raw("h1", {}, [{ text: "Real content" }])]);
const body = raw("body", {}, [real, overlay]);
const root = buildFixtureIR(body);
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
assert.deepEqual(kept, ["section"], "the attentive overlay subtree is dropped");
assert.equal(findByTag(root, "iframe"), null, "the popup creative iframe never reaches the IR");
});
it("does NOT drop an INLINE embedded signup form that merely carries a vendor name (feature, not popup)", () => {
// A deliberately-grafted inline Klaviyo form: a real, sized form embedded in page content. Its
// class names the vendor but is NOT an overlay-container marker, so it must survive.
const inlineForm = raw("div", { class: "klaviyo-form klaviyo-form-inline" }, [
raw("form", { id: "email-signup" }, [raw("input", { type: "email" })]),
]);
const body = raw("body", {}, [inlineForm]);
const root = buildFixtureIR(body);
assert.ok(findByTag(root, "form"), "the inline signup form survives the prune");
assert.ok(findByTag(root, "input"), "its input survives too");
});
});
// Defect C (normalize side) — an infinite CSS animation gated to a breakpoint (a Webflow `max-lg`
// marquee) is `animation:none` at the widths it does not run, but the browser still reports the last
// FROZEN translateX there. `neutralizeAnimatedTransforms` zeroes the transform at EVERY viewport
// once a genuine infinite animation is present at some width, so no frozen mid-scroll offset (the
// base residue or the animated phases) is banded and clips the strip offscreen at rest.
describe("neutralizeAnimatedTransforms (Defect C)", () => {
it("zeroes the transform at every viewport when an infinite animation runs at some width", () => {
const byVp = {
375: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -284.025, 0)" } as any,
768: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -280.982, 0)" } as any,
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -9.94731, 0)" } as any,
};
neutralizeAnimatedTransforms(byVp as any);
assert.equal(byVp[375].transform, "none");
assert.equal(byVp[768].transform, "none");
assert.equal(byVp[1280].transform, "none", "the non-animated base residue is neutralized too");
});
it("leaves a static transform untouched when NO viewport carries an infinite animation", () => {
const byVp = {
375: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
};
neutralizeAnimatedTransforms(byVp as any);
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -40, 0)", "a deliberate static offset is preserved");
assert.equal(byVp[1280].transform, "matrix(1, 0, 0, 1, -40, 0)");
});
it("does not fire for a FINITE animation (a one-shot entrance, not a perpetual marquee)", () => {
const byVp = {
375: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
1280: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
};
neutralizeAnimatedTransforms(byVp as any);
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -100, 0)", "finite animations own a settled end state, not a perpetual phase");
});
});