diff --git a/extension/src/rules/__tests__/disguised-ad-flag.property.test.ts b/extension/src/rules/__tests__/disguised-ad-flag.property.test.ts new file mode 100644 index 0000000..1824392 --- /dev/null +++ b/extension/src/rules/__tests__/disguised-ad-flag.property.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property-based tests for disguised-ad-flag. fast-check explores the +// boundary case the feed-wrapper guard is supposed to enforce: +// - a wrapper containing N >= 2 labeled card-shaped descendants must +// never itself be replaced as one placeholder. Each card gets its +// own placeholder; the wrapper survives. +// This is the invariant violated by #228 — entire reddit feed replaced +// as a single placeholder. + +import fc from "fast-check"; + +import { PLACEHOLDER_CLASS } from "../../lib/placeholder"; +import { disguisedAdFlagRule } from "../disguised-ad-flag"; + +const LABEL_PHRASES = [ + "Sponsored", + "Promoted", + "Advertorial", + "Paid Post", + "Branded Content", + "[Ad]", + "(promoted)", +] as const; + +afterEach(() => { + disguisedAdFlagRule.teardown(); + document.body.innerHTML = ""; +}); + +function buildFeed( + cardCount: number, + labelText: string, + headlessCount: number, +): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.dataset.fixture = "feed"; + for (let i = 0; i < cardCount; i++) { + const card = document.createElement("article"); + card.dataset.fixture = `card-${i}`; + const heading = + i < headlessCount ? "" : `

Title ${i}

`; + card.innerHTML = ` + ${labelText} + ${heading} + + Visit +

Body copy for card ${i} that exceeds the eighty-character prose minimum the disguised-ad-flag rule applies to article-shape candidates.

+ `; + wrapper.append(card); + } + return wrapper; +} + +describe("disguisedAdFlagRule feed-wrapper invariants (property)", () => { + it("a wrapper with multiple labeled card-shaped children is never replaced", () => { + fc.assert( + fc.property( + fc.integer({ min: 2, max: 6 }), + fc.constantFrom(...LABEL_PHRASES), + (cardCount, labelText) => { + document.body.innerHTML = ""; + const wrapper = buildFeed(cardCount, labelText, 0); + document.body.append(wrapper); + + disguisedAdFlagRule.apply(document.body); + + // Wrapper survives — only individual cards are replaced. + expect( + document.querySelector('[data-fixture="feed"]'), + ).not.toBeNull(); + // Every card got its own placeholder. + expect( + document.querySelectorAll(`.${PLACEHOLDER_CLASS}`), + ).toHaveLength(cardCount); + + disguisedAdFlagRule.teardown(); + }, + ), + { numRuns: 30 }, + ); + }); + + it("a wrapper with a mix of headed and headless labeled cards is never replaced", () => { + // Mirrors the #228 trigger: not every ad post carries its own + // headline. The headless cards walk past their own boundary; the + // feed wrapper must still reject them. + fc.assert( + fc.property( + fc.integer({ min: 2, max: 6 }), + fc.integer({ min: 1, max: 5 }), + (totalCards, headlessRaw) => { + const headlessCount = Math.min(headlessRaw, totalCards - 1); + document.body.innerHTML = ""; + const wrapper = buildFeed(totalCards, "Promoted", headlessCount); + document.body.append(wrapper); + + disguisedAdFlagRule.apply(document.body); + + expect( + document.querySelector('[data-fixture="feed"]'), + ).not.toBeNull(); + // Headed cards are hidden; headless ones are left alone. + const headedCount = totalCards - headlessCount; + expect( + document.querySelectorAll(`.${PLACEHOLDER_CLASS}`), + ).toHaveLength(headedCount); + for (let i = 0; i < headlessCount; i++) { + expect( + document.querySelector(`[data-fixture="card-${i}"]`), + ).not.toBeNull(); + } + + disguisedAdFlagRule.teardown(); + }, + ), + { numRuns: 40 }, + ); + }); + + it("a role='feed' wrapper is never crossed by the walk-up", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 5 }), + fc.constantFrom(...LABEL_PHRASES), + (cardCount, labelText) => { + document.body.innerHTML = ""; + const wrapper = buildFeed(cardCount, labelText, cardCount); + wrapper.setAttribute("role", "feed"); + document.body.append(wrapper); + + disguisedAdFlagRule.apply(document.body); + + // No card has its own heading, and the feed boundary stops + // the walk — nothing gets hidden, but critically the feed + // itself is preserved. + expect( + document.querySelector('[data-fixture="feed"]'), + ).not.toBeNull(); + expect( + document.querySelectorAll(`.${PLACEHOLDER_CLASS}`), + ).toHaveLength(0); + + disguisedAdFlagRule.teardown(); + }, + ), + { numRuns: 30 }, + ); + }); +}); diff --git a/extension/src/rules/__tests__/disguised-ad-flag.test.ts b/extension/src/rules/__tests__/disguised-ad-flag.test.ts index 2cc8a2f..d45e4a0 100644 --- a/extension/src/rules/__tests__/disguised-ad-flag.test.ts +++ b/extension/src/rules/__tests__/disguised-ad-flag.test.ts @@ -344,6 +344,112 @@ describe("disguisedAdFlagRule false-positive guards", () => { }); }); +describe("disguisedAdFlagRule feed-wrapper guard", () => { + it("does not replace a feed wrapper when one card lacks its own heading (#228)", () => { + // Repro from #228 — entire reddit feed replaced as one placeholder. + // Reddit's contains multiple + // cards. Each ad post carries its title in a + // element. When a + // particular ad post momentarily lacks its own headline class (a + // different ad creative, a mid-hydration burst), the walk from that + // post's "Promoted" label skips past the post and reaches the feed. + // Pre-fix, the feed passed isArticleShaped because *sibling* ad + // posts carry headlines — so the rule replaced the whole feed. + document.body.innerHTML = ` +
+
+ Promoted + + Visit advertiser +

Ad post body copy that satisfies the prose-length minimum but whose card boundary lacks any heading element.

+
+
+ Promoted +

Buy our energy drink

+ +

A long-form advertorial body that satisfies the prose-length check on its own card boundary.

+
+
+ Promoted +

Drive the Acme Truck

+ +

A second long-form advertorial body that satisfies the prose-length check on its own card boundary.

+
+
+ `; + disguisedAdFlagRule.apply(document.body); + + // Feed wrapper survives. + expect(document.querySelector('[data-fixture="feed"]')).not.toBeNull(); + // Cards with their own heading are hidden as individual placeholders. + expect( + document.querySelector('[data-fixture="ad-with-heading-1"]'), + ).toBeNull(); + expect( + document.querySelector('[data-fixture="ad-with-heading-2"]'), + ).toBeNull(); + // The headless card is left alone (no article-shaped ancestor below + // the feed). That's the degraded but acceptable outcome — better + // than replacing the whole feed. + expect( + document.querySelector('[data-fixture="ad-headless"]'), + ).not.toBeNull(); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(2); + }); + + it("stops at a role='feed' wrapper", () => { + // ARIA infinite-scroll feed pattern — same boundary semantics as + //
. A label inside a feed item that doesn't resolve to a + // card-shaped ancestor *below* the feed must not climb to it. + document.body.innerHTML = ` +
+
+ Promoted + + Visit +

Ad post body without any heading element inside its own card boundary.

+
+
+

Editorial story

+ + Read +

Editorial copy that runs long enough to satisfy the prose-length minimum.

+
+
+ `; + disguisedAdFlagRule.apply(document.body); + + expect(document.querySelector('[data-fixture="feed"]')).not.toBeNull(); + expect(document.querySelector('[data-fixture="editorial"]')).not.toBeNull(); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(0); + }); + + it("does not match a container with multiple headings (long-form section landing)", () => { + // A wrapper that holds two headings — e.g. an editorial section + // header plus a card heading — is not a single advertorial card. + // Even with a "Sponsored" label inside, the rule should not + // collapse the whole wrapper. + document.body.innerHTML = ` +
+

Section header

+
+ Sponsored +

Card headline

+ +

Card body copy that exceeds the eighty-character prose minimum required by the rule's article-shape check.

+
+
+ `; + disguisedAdFlagRule.apply(document.body); + + // The card itself is the (single-heading) article-shaped ancestor — + // it gets hidden. The outer section is preserved. + expect(document.querySelector('[data-fixture="section"]')).not.toBeNull(); + expect(document.querySelector('[data-fixture="card"]')).toBeNull(); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(1); + }); +}); + describe("disguisedAdFlagRule reveal flow", () => { it("does not re-hide an article the user revealed via click", () => { document.body.innerHTML = articleCard({ label: "Sponsored" }); diff --git a/extension/src/rules/disguised-ad-flag.ts b/extension/src/rules/disguised-ad-flag.ts index 699f61e..3ad2ab9 100644 --- a/extension/src/rules/disguised-ad-flag.ts +++ b/extension/src/rules/disguised-ad-flag.ts @@ -186,13 +186,16 @@ function isNavigationAncestor(element: Element): boolean { // Boundary tags we treat as "you've walked too far". Once we reach
, // , or , the label isn't sitting on an article card. // `
` is intentionally not a boundary — it's the natural wrapper -// for an advertorial. +// for an advertorial. `role="feed"` is the ARIA infinite-scroll pattern; +// crossing it would let a label inside one card adopt sibling cards' +// signals (see #228 — entire reddit feed replaced as one placeholder). function isPageBoundary(element: Element): boolean { const name = element.localName; if (name === "main" || name === "body" || name === "html") { return true; } - return element.getAttribute("role") === "main"; + const role = element.getAttribute("role"); + return role === "main" || role === "feed"; } // Heading-equivalent selector. Beyond the literal h1–h6 set, accept two @@ -209,15 +212,56 @@ function isPageBoundary(element: Element): boolean { const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6, [role="heading"], [class~="headline"]'; -// True if `element` looks like an article card: contains a heading, plus -// at least one of an image or an outgoing link, plus enough prose text to -// be confusable with editorial. The label text itself and headings are -// excluded from the prose count so a label-only row doesn't qualify. +// True if `element` contains a *sibling* card-shaped subtree — a +// descendant outside `labelElement`'s ancestor chain that itself +// carries both an image and an outgoing link. Used as the feed-wrapper +// guard inside `isArticleShaped`: a single advertorial card has at +// most one such subtree (its own); a feed wraps many. The image+link +// pair is the cheapest stable signal for "card-ness" — every card- +// shaped feed item in the wild carries a thumbnail and a click target, +// and the pair is rare in non-card chrome. +function hasOtherCardSubtree(element: Element, labelElement: Element): boolean { + for (const candidate of element.querySelectorAll("*")) { + if (candidate === labelElement) { + continue; + } + if (labelElement.contains(candidate)) { + continue; + } + if (candidate.contains(labelElement)) { + continue; + } + if (candidate.querySelector("img, picture") === null) { + continue; + } + if (candidate.querySelector("a[href]") === null) { + continue; + } + return true; + } + return false; +} + +// True if `element` looks like an article card: contains a heading, +// plus at least one of an image or an outgoing link, plus enough prose +// text to be confusable with editorial. The label text itself and +// headings are excluded from the prose count so a label-only row +// doesn't qualify. The candidate must also not enclose a *second* +// card-shaped subtree (see #228: when one ad post momentarily lacks +// its own heading — different ad creative, mid-hydration — the walk +// reaches the surrounding feed; pre-guard the feed passed every check +// because *sibling* cards carry headings/images/links, and the entire +// feed got replaced with one placeholder). The sibling-card guard lets +// each card match on its own walk-up while keeping the feed wrapper +// unmatched. function isArticleShaped(element: Element, labelElement: Element): boolean { const heading = element.querySelector(HEADING_SELECTOR); if (heading === null) { return false; } + if (hasOtherCardSubtree(element, labelElement)) { + return false; + } const hasImage = element.querySelector("img, picture") !== null; const hasLink = element.querySelector("a[href]") !== null; if (!hasImage && !hasLink) {