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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
23505ee229
commit
0dd71a68d9
@@ -49,6 +49,42 @@ function baseRule(css: string, id: string): string {
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
// Root scroll-lock un-clamp: a popup vendor locks the page with
|
||||
// body{position:absolute;overflow:hidden;height:100vh}, which the capture bakes as the body's
|
||||
// computed style AND collapses document.scrollHeight down to the clamp (so the captured-scrollHeight
|
||||
// trigger can never fire). The IR's in-flow CONTENT EXTENT (a footer laid out at y=5000) still
|
||||
// exceeds the clamp, so the un-clamp must fire off that signal and strip the lock.
|
||||
describe("generateCss root scroll-lock un-clamp (content-extent trigger)", () => {
|
||||
it("drops the body height/position/overflow lock when IR content extent exceeds the clamp", () => {
|
||||
const footer = node("n1", "footer", computed());
|
||||
// Footer sits far below the one-viewport clamp at every viewport.
|
||||
for (const vp of VPS) footer.bboxByVp[vp] = { x: 0, y: 4000, width: vp, height: 1000 }; // bottom = 5000
|
||||
const bodyCs = computed({ position: "absolute", overflow: "hidden", overflowY: "hidden", height: "812px", top: "0px", left: "0px" });
|
||||
const root = node("n0", "body", bodyCs, [footer]);
|
||||
for (const vp of VPS) root.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 812 };
|
||||
// perViewport.scrollHeight stays collapsed to 800 (the lock's doing) — so ONLY the content
|
||||
// extent can trigger the un-clamp.
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
const body = baseRule(css, "n0");
|
||||
assert.ok(!/height:812px/.test(body), `body height clamp dropped (got: ${body})`);
|
||||
assert.ok(!/position:absolute/.test(body), `body position:absolute stripped (got: ${body})`);
|
||||
assert.ok(/overflow-y:visible/.test(body), `overflow-y forced visible (got: ${body})`);
|
||||
assert.ok(!/overflow:hidden/.test(body), `overflow:hidden not emitted (got: ${body})`);
|
||||
});
|
||||
|
||||
it("does NOT un-clamp a genuine one-screen page (content extent within the clamp)", () => {
|
||||
const inner = node("n1", "div", computed());
|
||||
for (const vp of VPS) inner.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 700 };
|
||||
const bodyCs = computed({ overflow: "hidden", overflowY: "hidden", height: "800px" });
|
||||
const root = node("n0", "body", bodyCs, [inner]);
|
||||
for (const vp of VPS) root.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 800 };
|
||||
const css = generateCss(irWith(root), new Map());
|
||||
const body = baseRule(css, "n0");
|
||||
// The clamp is legitimate (content fits) — height kept, overflow not forced to visible.
|
||||
assert.ok(/height:800px/.test(body), `legit body height preserved (got: ${body})`);
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -72,6 +72,23 @@ describe("planForFrameUrl", () => {
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
|
||||
assert.equal(planForFrameUrl("http://127.0.0.1:4001/iframe-embed.html"), "graft");
|
||||
});
|
||||
|
||||
it("SKIPS full-viewport promo POPUP CREATIVE hosts (Attentive/Recart/Wunderkind overlays)", () => {
|
||||
// These vendor hosts serve a full-viewport interstitial creative — grafting pours the
|
||||
// popup's copy into the DOM/text channel and paints the modal over the real page.
|
||||
assert.equal(planForFrameUrl("https://creatives.attn.tv/creative/12345"), "skip");
|
||||
assert.equal(planForFrameUrl("https://creative.attn.tv/loader/x"), "skip");
|
||||
assert.equal(planForFrameUrl("https://app.recart.com/popup/abc"), "skip");
|
||||
assert.equal(planForFrameUrl("https://tag.wunderkind.co/creative"), "skip");
|
||||
assert.equal(planForFrameUrl("https://api.bounceexchange.com/creative"), "skip");
|
||||
});
|
||||
|
||||
it("STILL grafts INLINE embed hosts even for the same vendors (inline forms are a feature)", () => {
|
||||
// Caution guard: a deliberately-grafted inline signup form must never be caught by the
|
||||
// popup-creative skip list. Inline-form hosts differ from overlay-creative hosts.
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
|
||||
assert.equal(planForFrameUrl("https://manage.kmail-lists.com/subscriptions/subscribe"), "graft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("graftFrameIntoSnapshot", () => {
|
||||
|
||||
@@ -117,6 +117,34 @@ describe("IR drops font-metric probe nodes (fix 4)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user