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
57 changes: 57 additions & 0 deletions extension/src/rules/__tests__/hidden-text-strip.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,63 @@ describe("hidden-text-strip (property)", () => {
);
});

// Live-region opt-ins that turn `display:none` into a strip target.
// The narrow carve-out around tab panels means the bypass surface
// here is the cross-product of (live-region attribute) × (subtree
// shape), and a future refactor that special-cased one role at the
// expense of another would only fail a single unit test. The
// property pins the whole class so the next regression is caught
// before it ships.
const LIVE_REGION_ATTR = fc.constantFrom(
{ role: "status" },
{ role: "alert" },
{ role: "log" },
{ role: "marquee" },
{ role: "timer" },
{ role: "alertdialog" },
{ "aria-live": "polite" },
{ "aria-live": "assertive" },
);

it("strips display:none on any live-region role / aria-live opt-in", () => {
fc.assert(
fc.property(
LIVE_REGION_ATTR,
PAYLOAD,
CHILD_COUNT,
(attributes, payload, childCount) => {
document.body.innerHTML = "";
const target = document.createElement("div");
target.id = "target";
target.setAttribute("style", "display: none");
for (const [name, value] of Object.entries(attributes)) {
target.setAttribute(name, value);
}
target.append(document.createTextNode(payload));

const markers: HTMLSpanElement[] = [];
for (let i = 0; i < childCount; i++) {
const marker = document.createElement("span");
marker.id = `marker-${i}`;
marker.textContent = `marker-${i}`;
target.append(marker);
markers.push(marker);
}
document.body.append(target);

hiddenTextStripRule.apply(document.body);

expect(target.isConnected).toBe(true);
expect(target.textContent).toBe("");
for (const marker of markers) {
expect(marker.isConnected).toBe(true);
expect(marker.parentElement).toBe(target);
}
},
),
);
});

// Every shape the matrix → scale check is supposed to recognize as
// "x or y axis collapsed to zero". Unit tests pin one example each;
// the property test pins the whole class so a future tweak to the
Expand Down
65 changes: 65 additions & 0 deletions extension/src/rules/__tests__/hidden-text-strip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,71 @@ describe("hiddenTextStripRule", () => {
hiddenTextStripRule.apply(document.body);

expect(document.querySelector("#panel")).not.toBeNull();
expect(document.querySelector("#panel")?.textContent).toContain(
"tab panel content",
);
});

it("scrubs display:none on role=status (live-region smuggling)", () => {
document.body.innerHTML = `
<div id="x" role="status" style="display: none">${FIXTURES.HIDDEN_IGNORE_PRIOR}</div>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("scrubs display:none on role=alert", () => {
document.body.innerHTML = `
<div id="x" role="alert" style="display: none">${FIXTURES.HIDDEN_IGNORE_PRIOR}</div>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("scrubs display:none on aria-live=polite", () => {
document.body.innerHTML = `
<div id="x" aria-live="polite" style="display: none">${FIXTURES.HIDDEN_IGNORE_PRIOR}</div>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("scrubs display:none on aria-live=assertive", () => {
document.body.innerHTML = `
<div id="x" aria-live="assertive" style="display: none">${FIXTURES.HIDDEN_IGNORE_PRIOR}</div>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

// `aria-live="off"` is explicit opt-out — the page is telling AT not
// to announce the region. The display:none carve-out for tab panels
// still applies.
it("leaves display:none with aria-live=off alone", () => {
document.body.innerHTML = `
<div id="panel" aria-live="off" style="display: none">tab content</div>
`;
hiddenTextStripRule.apply(document.body);

expect(document.querySelector("#panel")?.textContent).toContain(
"tab content",
);
});

it("scrubs display:none on role=status while preserving descendant element nodes", () => {
document.body.innerHTML = `
<div id="x" role="status" style="display: none">
<span id="inner">${FIXTURES.HIDDEN_IGNORE_PRIOR}</span>
</div>
`;
hiddenTextStripRule.apply(document.body);

expect(document.querySelector("#inner")).not.toBeNull();
expect(document.querySelector("#x")?.textContent).toBe("");
});

it("leaves visible text alone", () => {
Expand Down
40 changes: 39 additions & 1 deletion extension/src/rules/hidden-text-strip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,16 @@
// not catching a hypothetical adversary who specifically targets agents
// via 1×1 boxes. That tradeoff is intentional.
//
// display:none is intentionally NOT a trigger: collapsed menus, tab panels,
// display:none is generally NOT a trigger: collapsed menus, tab panels,
// and dropdowns commonly toggle display, and stripping their text content
// would corrupt the underlying app state once the user/agent expands them.
// The narrow exception is display:none on a live region (role=status /
// alert / log / marquee / timer / alertdialog, or aria-live=polite /
// assertive): those elements are explicitly opted in to "announce my
// contents," so the carrier reaches agents walking textContent or the
// a11y tree while staying invisible to sighted users. Tab panels never
// wear those attributes, so the narrower trigger preserves the original
// carve-out.
//
// When a non-allowlisted match is found, we blank every text node inside
// the element rather than detaching the element itself. Frameworks
Expand Down Expand Up @@ -131,6 +138,28 @@ function isLandmark(element: Element): boolean {
return role !== null && LANDMARK_ROLES.has(role);
}

// Live-region attributes that opt an element in to assistive-tech
// announcements. `display:none` on one of these is the narrow shape we
// strip — see the file-level comment for why this carve-out is narrower
// than the general display:none exclusion.
const LIVE_REGION_ROLES: ReadonlySet<string> = new Set([
"status",
"alert",
"log",
"marquee",
"timer",
"alertdialog",
]);

function isLiveRegion(element: Element): boolean {
const role = element.getAttribute("role");
if (role !== null && LIVE_REGION_ROLES.has(role)) {
return true;
}
const live = element.getAttribute("aria-live");
return live === "polite" || live === "assertive";
}

// Match reasons whose only signal is "the element is positioned out of
// the visible viewport / clipped to zero area". These are the shapes a
// landmark or aria-hidden subtree legitimately uses to keep content
Expand Down Expand Up @@ -433,6 +462,15 @@ function detectHiddenByCss(
element: Element,
style: CSSStyleDeclaration,
): MatchDetail | null {
if (style.display === "none" && isLiveRegion(element)) {
return {
reason: "display-none-live-region",
details: {
role: element.getAttribute("role") ?? "",
ariaLive: element.getAttribute("aria-live") ?? "",
},
};
}
if (style.visibility === "hidden" || style.visibility === "collapse") {
return {
reason: `visibility-${style.visibility}`,
Expand Down
Loading