Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions extension/src/rules/__tests__/disguised-ad-flag.property.test.ts
Original file line number Diff line number Diff line change
@@ -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 ? "" : `<h2><a href="/${i}">Title ${i}</a></h2>`;
card.innerHTML = `
<span class="label">${labelText}</span>
${heading}
<img alt="" src="/${i}.jpg" />
<a href="/${i}">Visit</a>
<p>Body copy for card ${i} that exceeds the eighty-character prose minimum the disguised-ad-flag rule applies to article-shape candidates.</p>
`;
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 },
);
});
});
106 changes: 106 additions & 0 deletions extension/src/rules/__tests__/disguised-ad-flag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shreddit-feed> contains multiple <shreddit-ad-post>
// cards. Each ad post carries its title in a
// <shreddit-dynamic-ad-link class="headline"> 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 = `
<div class="feed" data-fixture="feed">
<div class="ad-post" data-fixture="ad-headless">
<span class="label">Promoted</span>
<img alt="" src="a.jpg" />
<a href="/a">Visit advertiser</a>
<p>Ad post body copy that satisfies the prose-length minimum but whose card boundary lacks any heading element.</p>
</div>
<div class="ad-post" data-fixture="ad-with-heading-1">
<span class="label">Promoted</span>
<h2><a href="/b">Buy our energy drink</a></h2>
<img alt="" src="b.jpg" />
<p>A long-form advertorial body that satisfies the prose-length check on its own card boundary.</p>
</div>
<div class="ad-post" data-fixture="ad-with-heading-2">
<span class="label">Promoted</span>
<h2><a href="/c">Drive the Acme Truck</a></h2>
<img alt="" src="c.jpg" />
<p>A second long-form advertorial body that satisfies the prose-length check on its own card boundary.</p>
</div>
</div>
`;
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
// <main>. 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 = `
<div role="feed" data-fixture="feed">
<div class="ad-post" data-fixture="ad-headless">
<span class="label">Promoted</span>
<img alt="" src="x.jpg" />
<a href="/x">Visit</a>
<p>Ad post body without any heading element inside its own card boundary.</p>
</div>
<article data-fixture="editorial">
<h2>Editorial story</h2>
<img alt="" src="y.jpg" />
<a href="/y">Read</a>
<p>Editorial copy that runs long enough to satisfy the prose-length minimum.</p>
</article>
</div>
`;
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 data-fixture="section">
<h1>Section header</h1>
<article data-fixture="card">
<span class="label">Sponsored</span>
<h2><a href="/x">Card headline</a></h2>
<img alt="" src="x.jpg" />
<p>Card body copy that exceeds the eighty-character prose minimum required by the rule's article-shape check.</p>
</article>
</section>
`;
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" });
Expand Down
56 changes: 50 additions & 6 deletions extension/src/rules/disguised-ad-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,16 @@ function isNavigationAncestor(element: Element): boolean {
// Boundary tags we treat as "you've walked too far". Once we reach <main>,
// <body>, or <html>, the label isn't sitting on an article card.
// `<section>` 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
Expand All @@ -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;
}
Comment on lines +223 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasOtherCardSubtree returns true as soon as it finds any descendant outside the label's ancestor chain that contains both an <img>/<picture> and an <a href>. This correctly rejects a feed with multiple cards, but it also rejects a single ad card whenever a wrapper <div> sits between the label and the card's content.

Example — common in the wild but absent from the test suite:

<article>
  <span class="label">Sponsored</span>
  <div class="card-body">
    <h2><a href="/ad">Title</a></h2>
    <img src="ad.jpg" />
    <p>Long body text that exceeds the prose minimum …</p>
  </div>
</article>

Trace: div.card-body is not the label, is not contained by the label, and does not contain the label. div.card-body.querySelector("img, picture")<img> (not null). div.card-body.querySelector("a[href]")<a> (not null). → returns trueisArticleShaped returns false → the legitimate ad card escapes detection.

The comment on line R218-R219 says "a single advertorial card has at most one such subtree (its own)", but that assumption breaks whenever a card's label is a sibling to (rather than inside) a content wrapper.

Consider requiring two non-overlapping matches before rejecting, e.g. count the number of qualifying subtrees and return count >= 2. That way a single card's content wrapper counts as one (tolerated), but a feed with multiple cards still trips the guard.


// 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) {
Expand Down
Loading