Editorial story
+
+ Read
+ Editorial copy that runs long enough to satisfy the prose-length minimum.
+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 ? "" : `
+ 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
+ Visit advertiser
+ Ad post body copy that satisfies the prose-length minimum but whose card boundary lacks any heading element.
+
+ A long-form advertorial body that satisfies the prose-length check on its own card boundary.
+
+ A second long-form advertorial body that satisfies the prose-length check on its own card boundary.
+
+ Read
+ Editorial copy that runs long enough to satisfy the prose-length minimum.
+
+ Card body copy that exceeds the eighty-character prose minimum required by the rule's article-shape check.
+