Files
ditto.site/compiler/test/overlayDismiss.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

312 lines
15 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, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import { finalizeOverlaysInPage, clickDismissInPage } from "../src/capture/capture.js";
import { irContentExtent, type IRNode, type BBox, type StyleMap } from "../src/normalize/ir.js";
import { gatePollution } from "../src/validate/gates.js";
import type { CaptureResult } from "../src/capture/capture.js";
import type { IR } from "../src/normalize/ir.js";
// tsx/esbuild wraps serialized page functions with __name()/__defProp; shim so page.evaluate works.
const ESBUILD_SHIM =
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
const VW = 1280, VH = 800;
// ---------------------------------------------------------------------------
// finalizeOverlaysInPage — effective-z resolution, overlay-unit grouping,
// lock-relaxed z gate, shadow-root traversal, loud-failure blocking.
// ---------------------------------------------------------------------------
describe("capture: finalizeOverlaysInPage overlay detection", () => {
let browser: Browser;
before(async () => { browser = await chromium.launch(); });
after(async () => { await browser.close(); });
const newPage = async (html: string): Promise<Page> => {
const page = await browser.newPage();
await page.setViewportSize({ width: VW, height: VH });
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(html);
await page.evaluate(ESBUILD_SHIM);
return page;
};
it("groups a 0-height max-z wrapper + its full-viewport z:auto iframe as ONE overlay and removes both (scroll-locked)", async () => {
// Mirrors the Attentive structure: body scroll-locked; a fixed z=INT_MAX wrapper of height 0
// whose child is a fixed full-viewport iframe with z-index:auto. Neither passes the naive
// per-element area+z gate alone (wrapper: area 0; iframe: z parses to 0).
const page = await newPage(`
<style>
html,body{margin:0}
body{position:absolute;top:0;left:0;right:0;height:${VH}px;overflow:hidden}
#wrap{position:fixed;top:${VH}px;left:0;width:${VW}px;height:0;z-index:2147483647}
#creative{position:fixed;top:0;left:0;width:${VW}px;height:${VH}px;z-index:auto;background:#000}
</style>
<main>real page content</main>
<div id="wrap"><iframe id="creative"></iframe></div>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 1, "the wrapper+iframe unit is removed as one node");
assert.ok(res.removedLabels.some((l) => l.includes("wrap")), `removed the max-z wrapper (got ${JSON.stringify(res.removedLabels)})`);
const gone = await page.evaluate(() => !document.getElementById("wrap") && !document.getElementById("creative"));
assert.ok(gone, "both the wrapper and its inner iframe are gone");
await page.close();
});
it("detects a z=50 full-viewport fixed dialog ONLY because the page is scroll-locked (lock relaxes the z>=100 gate)", async () => {
const page = await newPage(`
<style>
html,body{margin:0}
body{overflow:hidden}
#ccpa{position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:50;background:rgba(0,0,0,.7)}
</style>
<main>content</main>
<div id="ccpa">California Residents Privacy Rights</div>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 1, "the z=50 dialog is removed on a locked page");
await page.close();
});
it("does NOT remove a z=50 full-viewport fixed layer when the page is NOT scroll-locked (z floor preserved)", async () => {
const page = await newPage(`
<style>
html,body{margin:0}
#hero{position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:50;background:#eee}
</style>
<div id="hero">a legit fixed hero, page scrolls normally</div>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 0, "no removal off a locked page — the z>=100 floor guards legit fixed content");
assert.equal(res.blocking, false);
await page.close();
});
it("removes a promo popup mounted inside an OPEN shadow root and reports the host label", async () => {
const page = await newPage(`
<style>html,body{margin:0}body{overflow:hidden}</style>
<main>content</main>
<div id="recart-popup-root"></div>
<script>
const host = document.getElementById("recart-popup-root");
const root = host.attachShadow({ mode: "open" });
const layer = document.createElement("div");
layer.style.cssText = "position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:999;background:rgba(0,0,0,.6)";
layer.textContent = "Enjoy 15% off";
root.appendChild(layer);
</script>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 1, "the shadow-hosting popup element is removed as one unit");
const gone = await page.evaluate(() => !document.getElementById("recart-popup-root"));
assert.ok(gone, "the shadow host is gone from the light DOM");
await page.close();
});
it("reports blocking=true when the scroll-lock persists even with NO overlay detected (loud failure)", async () => {
// Body is scroll-locked but there is no fixed full-viewport layer to find (e.g. a closed
// shadow root we cannot pierce). A silently-locked capture is polluted → surface it.
const page = await newPage(`
<style>html,body{margin:0}body{overflow:hidden;position:absolute;height:${VH}px}</style>
<main>content</main>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 0);
assert.equal(res.blocking, true, "a persistent lock with nothing removable still reports blocking");
await page.close();
});
it("reports blocking=false and removes nothing on an ordinary unlocked page", async () => {
const page = await newPage(`
<style>html,body{margin:0}</style>
<header>nav</header><main>lots of content</main><footer>footer</footer>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 0);
assert.equal(res.blocking, false);
assert.equal(res.overlaysRemaining, 0);
await page.close();
});
it("protects real sticky page chrome (header/nav) from removal even when detected", async () => {
// A scroll-locked page with a sticky header that happens to be tall — the header is PROTECTED,
// and it isn't full-viewport, so it never even qualifies. Assert nothing is stripped.
const page = await newPage(`
<style>
html,body{margin:0}body{overflow:hidden}
#masthead{position:sticky;top:0;width:${VW}px;height:80px;z-index:1000;background:#fff}
</style>
<header id="masthead">site chrome</header>
<main>content</main>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
const present = await page.evaluate(() => !!document.getElementById("masthead"));
assert.ok(present, "the header survives");
await page.close();
});
});
// ---------------------------------------------------------------------------
// clickDismissInPage — decline/close matchers, shadow-root traversal.
// ---------------------------------------------------------------------------
describe("capture: clickDismissInPage matchers", () => {
let browser: Browser;
before(async () => { browser = await chromium.launch(); });
after(async () => { await browser.close(); });
const run = async (html: string): Promise<string[]> => {
const page = await browser.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(html);
await page.evaluate(ESBUILD_SHIM);
const res = await page.evaluate(clickDismissInPage);
await page.close();
return res;
};
it("clicks a Decline button inside an overlay container (email-capture opt-out)", async () => {
const dismissed = await run(`
<div class="signup-overlay" style="position:fixed;inset:0;width:400px;height:400px">
<button onclick="window.__declined=true">Decline</button>
</div>
`);
assert.ok(dismissed.includes("text:decline"), `clicked Decline (got ${JSON.stringify(dismissed)})`);
});
it("clicks an aria-label close (×) inside an overlay container when no accept/decline text matches", async () => {
const dismissed = await run(`
<div id="promo-popup" style="position:fixed;inset:0;width:400px;height:400px">
<button aria-label="Close dialog">×</button>
</div>
`);
assert.ok(dismissed.some((d) => d.startsWith("close:")), `clicked the aria close (got ${JSON.stringify(dismissed)})`);
});
it("matches an overlay/ccpa/privacy container id/class (extended container selector)", async () => {
const dismissed = await run(`
<div id="ccpaPop" style="position:fixed;inset:0;width:400px;height:400px">
<button>Accept</button>
</div>
`);
assert.ok(dismissed.includes("text:accept"), `matched the ccpa container (got ${JSON.stringify(dismissed)})`);
});
it("traverses an open shadow root to find the close control", async () => {
const page = await browser.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(`<div id="host"></div>`);
await page.evaluate(ESBUILD_SHIM);
await page.evaluate(() => {
const host = document.getElementById("host")!;
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `<div class="popup-modal" style="position:fixed;inset:0;width:400px;height:400px"><button aria-label="close">×</button></div>`;
});
const dismissed = await page.evaluate(clickDismissInPage);
await page.close();
assert.ok(dismissed.some((d) => d.startsWith("close:")), `found the shadow-root close (got ${JSON.stringify(dismissed)})`);
});
it("never clicks an ordinary page button outside any overlay container", async () => {
const dismissed = await run(`<main><button>Accept</button><a>Continue</a></main>`);
assert.deepEqual(dismissed, [], "no overlay container ⇒ no clicks");
});
});
// ---------------------------------------------------------------------------
// irContentExtent — pure geometry (unclamp / pollution trigger).
// ---------------------------------------------------------------------------
describe("normalize: irContentExtent", () => {
const bbox = (x: number, y: number, w: number, h: number): BBox => ({ x, y, width: w, height: h });
const style = (over: Partial<StyleMap> = {}): StyleMap => ({ position: "static", ...over } as StyleMap);
const node = (over: Partial<IRNode>): IRNode => ({
id: "n", tag: "div", attrs: {},
visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 0, 100, 100) },
computedByVp: { 1280: style() }, children: [], ...over,
});
it("returns the max in-flow descendant border-box bottom", () => {
const root = node({
tag: "body",
children: [
node({ bboxByVp: { 1280: bbox(0, 0, 1280, 800) } }),
node({ bboxByVp: { 1280: bbox(0, 800, 1280, 4398) } }), // footer bottom = 5198
],
});
assert.equal(irContentExtent(root, 1280), 5198);
});
it("excludes out-of-flow (fixed/absolute) and floated boxes", () => {
const root = node({
tag: "body",
children: [
node({ bboxByVp: { 1280: bbox(0, 0, 1280, 900) } }),
node({ computedByVp: { 1280: style({ position: "fixed" }) }, bboxByVp: { 1280: bbox(0, 0, 1280, 9999) } }),
node({ computedByVp: { 1280: style({ float: "left" } as Partial<StyleMap>) }, bboxByVp: { 1280: bbox(0, 0, 1280, 8888) } }),
],
});
assert.equal(irContentExtent(root, 1280), 900, "fixed + float extents ignored");
});
it("descends into nested in-flow subtrees", () => {
const root = node({
tag: "body",
children: [node({ children: [node({ bboxByVp: { 1280: bbox(0, 0, 500, 3000) } })] })],
});
assert.equal(irContentExtent(root, 1280), 3000);
});
it("ignores nodes invisible at the queried viewport", () => {
const root = node({
tag: "body",
children: [node({ visibleByVp: { 1280: false }, bboxByVp: { 1280: bbox(0, 0, 100, 5000) } })],
});
assert.equal(irContentExtent(root, 1280), 0);
});
});
// ---------------------------------------------------------------------------
// gatePollution — scroll-locked-capture contradiction.
// ---------------------------------------------------------------------------
describe("validate: pollution gate scroll-locked contradiction", () => {
const bbox = (x: number, y: number, w: number, h: number): BBox => ({ x, y, width: w, height: h });
const st = (over: Partial<StyleMap> = {}): StyleMap => ({ position: "static", ...over } as StyleMap);
// A body with plenty of text and a footer at y=5198 (content extent ~6.4 viewports @800).
const mkIr = (): IR => ({
doc: {
sourceUrl: "https://x.test", title: "t", lang: "en", charset: "UTF-8", metaViewport: "",
viewports: [1280], sampleViewports: [1280], canonicalViewport: 1280,
perViewport: { 1280: { scrollHeight: 800, scrollWidth: 1280, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
nodeCount: 200, keyframes: [],
},
root: {
id: "n0", tag: "body", attrs: {}, visibleByVp: { 1280: true },
bboxByVp: { 1280: bbox(0, 0, 1280, 800) }, computedByVp: { 1280: st() },
children: [
{ id: "n1", tag: "section", attrs: {}, visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 0, 1280, 800) }, computedByVp: { 1280: st() },
children: [{ text: "hero copy ".repeat(20) }] },
{ id: "n2", tag: "footer", attrs: {}, visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 4074, 1280, 1124) }, computedByVp: { 1280: st() },
children: [{ text: "footer copy ".repeat(20) }] },
],
},
});
const mkCapture = (scrollHeight: number): CaptureResult => ({
perViewport: [
{ viewport: 375, height: 812, scrollHeight: 812, nodeCount: 200, truncated: false },
{ viewport: 1280, height: 800, scrollHeight, nodeCount: 200, truncated: false },
],
dismissal: { dismissed: [], overlaysRemaining: 0, removed: 0, videoStills: 0, blocking: false },
} as unknown as CaptureResult);
it("FAILS when scrollHeight is pinned to ~1 viewport at every width but IR content spans multiple viewports", () => {
const res = gatePollution(mkIr(), mkCapture(800), [1280]);
assert.equal(res.pass, false, "the scroll-locked contradiction is caught");
assert.ok(res.issues.some((i) => i.includes("scroll-locked capture")), `issue reported (got ${JSON.stringify(res.issues)})`);
});
it("PASSES when scrollHeight matches the tall content (a genuinely scrollable page)", () => {
const res = gatePollution(mkIr(), mkCapture(5198), [1280]);
assert.ok(!res.issues.some((i) => i.includes("scroll-locked capture")), "no false positive when the page really scrolled");
});
});