diff --git a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts index a3595c1..4931acc 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts @@ -94,6 +94,89 @@ describe("hidden-text-strip (property)", () => { ); }); + // Cross-product invariant for #203 item #10: landmark + aria-hidden + // allowlists kick in only for positional hide reasons. A future tweak + // that moves a check back into the unconditional skip block (or adds + // a new positional-shaped reason without updating the allowlist set) + // would re-open the bypass; pinning the class as a property catches it. + const POSITIONAL_TRIGGER = fc.constantFrom( + "position: absolute; left: -10000px", + "position: fixed; top: -10000px", + "clip-path: inset(100%)", + "text-indent: -10000px", + ); + const NON_POSITIONAL_TRIGGER = fc.constantFrom( + "visibility: hidden", + "opacity: 0", + ); + const LANDMARK_TAG = fc.constantFrom( + "nav", + "main", + "header", + "footer", + "aside", + ); + + it("preserves a positional-hide landmark; strips a non-positional one", () => { + fc.assert( + fc.property( + LANDMARK_TAG, + fc.boolean(), + POSITIONAL_TRIGGER, + NON_POSITIONAL_TRIGGER, + PAYLOAD, + (tag, positional, positionalStyle, nonPositionalStyle, payload) => { + document.body.innerHTML = ""; + const target = document.createElement(tag); + target.id = "target"; + target.setAttribute( + "style", + positional ? positionalStyle : nonPositionalStyle, + ); + target.append(document.createTextNode(payload)); + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.isConnected).toBe(true); + // Positional → landmark allowlist preserves text. + // Non-positional → injection-shaped on a landmark, stripped. + expect(target.textContent === "").toBe(!positional); + }, + ), + ); + }); + + it("preserves positional hides inside aria-hidden; strips non-positional ones", () => { + fc.assert( + fc.property( + fc.boolean(), + POSITIONAL_TRIGGER, + NON_POSITIONAL_TRIGGER, + PAYLOAD, + (positional, positionalStyle, nonPositionalStyle, payload) => { + document.body.innerHTML = ""; + const wrapper = document.createElement("div"); + wrapper.setAttribute("aria-hidden", "true"); + const target = document.createElement("span"); + target.id = "target"; + target.setAttribute( + "style", + positional ? positionalStyle : nonPositionalStyle, + ); + target.append(document.createTextNode(payload)); + wrapper.append(target); + document.body.append(wrapper); + + hiddenTextStripRule.apply(document.body); + + expect(target.isConnected).toBe(true); + expect(target.textContent === "").toBe(!positional); + }, + ), + ); + }); + it("blanks color-matched text and keeps wrapper + descendants attached", () => { // Color-match uses a different code path than the CSS triggers above — // it walks ancestors to compute the effective background. Cover it diff --git a/extension/src/rules/__tests__/hidden-text-strip.test.ts b/extension/src/rules/__tests__/hidden-text-strip.test.ts index d4b586c..500b146 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.test.ts @@ -436,15 +436,76 @@ describe("hiddenTextStripRule", () => { expect(document.querySelector("#x")).not.toBeNull(); }); - it("preserves text inside aria-hidden=true ancestors", () => { + // aria-hidden subtree allowlist is positional-only: a visibility:hidden + // (or opacity:0 / color-match) child inside an aria-hidden wrapper used + // to bypass the rule because the wrapper's aria-hidden attribute was + // treated as a blanket exemption. Now stripped — aria-hidden suppresses + // the a11y tree but doesn't keep text out of `textContent` where an + // agent reads it. + it("scrubs visibility:hidden text inside an aria-hidden subtree", () => { document.body.innerHTML = `
`; hiddenTextStripRule.apply(document.body); - expect(document.querySelector("#x")).not.toBeNull(); + expectScrubbed("#x"); + }); + + // Counterpart: aria-hidden subtree still preserves a positional hide + // (off-screen) — the SR-only / decorative-icon idiom commonly uses + // aria-hidden + off-screen positioning together. + it("preserves off-screen text inside an aria-hidden subtree", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toContain("decorative"); + }); + + // Landmark allowlist is positional-only too: a NAV with + // visibility:hidden text on the landmark element itself used to be a + // bypass. + it("scrubs visibility:hidden text on a landmark element", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("scrubs color-matched text on a landmark element", () => { + document.body.innerHTML = ` +