diff --git a/extension/src/rules/__tests__/disguised-ad-flag.test.ts b/extension/src/rules/__tests__/disguised-ad-flag.test.ts index fd6317b..9afc036 100644 --- a/extension/src/rules/__tests__/disguised-ad-flag.test.ts +++ b/extension/src/rules/__tests__/disguised-ad-flag.test.ts @@ -294,3 +294,53 @@ describe("disguisedAdFlagRule false-positive guards", () => { expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); }); }); + +describe("disguisedAdFlagRule reveal flow", () => { + it("does not re-hide an article the user revealed via click", () => { + document.body.innerHTML = articleCard({ label: "Sponsored" }); + disguisedAdFlagRule.apply(document.body); + + const placeholder = document.querySelector( + `.${PLACEHOLDER_CLASS}`, + ); + expect(placeholder).not.toBeNull(); + + // attachReveal in lib/placeholder restores the original element with + // REVEALED_ATTR stamped on it. A subsequent scan (the second apply + // call here stands in for the mutation burst the subtree-watcher + // fans out when the original re-attaches) must not re-wrap the + // same article — otherwise the placeholder comes back the moment + // the user clicks reveal. + placeholder?.click(); + expect(document.querySelector('[data-fixture="card"]')).not.toBeNull(); + + disguisedAdFlagRule.apply(document.body); + + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(0); + expect(document.querySelector('[data-fixture="card"]')).not.toBeNull(); + }); + + it("re-hides a freshly-inserted advertorial card after a reveal elsewhere", () => { + // A reveal on one card must not desensitize the rule for unrelated + // siblings — only the revealed ancestor (and its descendants) are + // exempt. + document.body.innerHTML = ` + ${articleCard({ label: "Sponsored", heading: "First card" })} + ${articleCard({ label: "Sponsored", heading: "Second card" })} + `; + disguisedAdFlagRule.apply(document.body); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(2); + + const [first] = document.querySelectorAll( + `.${PLACEHOLDER_CLASS}`, + ); + first?.click(); + + disguisedAdFlagRule.apply(document.body); + + // First card is revealed and stays revealed; second card stays hidden. + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(1); + const revealedCards = document.querySelectorAll('[data-fixture="card"]'); + expect(revealedCards).toHaveLength(1); + }); +}); diff --git a/extension/src/rules/disguised-ad-flag.ts b/extension/src/rules/disguised-ad-flag.ts index 4d1a88c..14d34dd 100644 --- a/extension/src/rules/disguised-ad-flag.ts +++ b/extension/src/rules/disguised-ad-flag.ts @@ -266,7 +266,12 @@ function isCandidateSkipped(element: HTMLElement): boolean { if (element.hasAttribute(CONSIDERED_ATTR)) { return true; } - if (element.hasAttribute(REVEALED_ATTR)) { + // Skip if the label sits inside (or is) an element this rule already + // hid and the user then revealed. The reveal stamp lives on the article + // ancestor — not on the label — so checking only `element.hasAttribute` + // missed the common case and let a post-reveal mutation burst re-wrap + // the same article into an unrevealable loop. + if (element.closest(`[${REVEALED_ATTR}="${RULE_ID}"]`)) { return true; } if (isInsidePlaceholder(element)) {