test(a11y): the aria-label role guard was blind to the role word inside the t() default (#2979)
## What [#2965](https://github.com/../pull/2965) fixed thirteen dialogs whose accessible name restated its role, and shipped a source-scanning guard so the next copy-paste could not sneak back in. Good fix, real ratchet — its own header even documents catching one blind spot by mutation during review. It has a second one. The matcher looks for the role word at the **end** of the `ariaLabel` value, which is where all thirteen had it. One position over is invisible: ```tsx ariaLabel={t("scripts.title", "Scripts dialog")} ``` That renders the accessible name **"Scripts dialog"** — identical symptom, announced as *"Scripts dialog, dialog"* — but the value does not END in the role word, because a `")` closes the call after it. **Measured:** re-introducing this shape into `ScriptsModal` left the shipped suite green at **13/13**. ## Why this shape matters more than the one already covered The rendered label *is* the translator's default string. Whoever writes the next modal naturally puts the word where the title lives, inside `t()`, rather than appending it outside the call. The suffix form is what the original thirteen happened to be; this is the form the fourteenth takes. ## The fix Every quoted literal that is actually rendered is checked with the same matcher, not just the whole value. i18n **keys** are excluded, or the guard fires on `t("agents.onboarding.dialogLabel", "AI Interview")` — live in the tree today, and it announces nothing of the sort. Key-shaped means dotted and whitespace-free, which no real accessible name is. Keeping this narrow is the whole difficulty: a scan that flags every translated title gets deleted within a week. ## Measured | run | result | |---|---| | baseline, unmutated `main` | **18/18 green** — no false positive anywhere in the corpus | | mutation A — role word inside the `t()` default | **1 failed / 17 passed** (was green before) | | mutation B — original trailing-suffix shape | **1 failed / 17 passed** — no regression | Both defect shapes now fail the guard. Five matcher cases added, three of them negative; the negatives are what keep the scan from flagging every translated title. ## Note for the queue **#2946 is fixed on `main` and can be closed** — I verified all thirteen callers are clean there, the one grep hit being that i18n key. This PR is about the guard behind it, not the fix. Third time in this program an instrument has been blind to a case in its own motivating class, now across three tools and three authors. The pattern is not carelessness — each guard was checked against the shapes its author had in mind. Mutation against a shape you did *not* have in mind is the only thing that has caught any of them, which is an argument for making it routine when a ratchet ships rather than when someone gets suspicious later. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,36 @@ function extractAriaLabelValues(text: string): string[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Accessibility 2026-07-30-21:10:
|
||||
THE ROLE WORD CAN ALSO SIT INSIDE THE TRANSLATED DEFAULT, and the end-of-value scan cannot see it.
|
||||
|
||||
`ariaLabel={t("scripts.title", "Scripts dialog")}` renders the accessible name "Scripts dialog" —
|
||||
the identical defect — but the value does not END in the role word: a `")` closes the call after it.
|
||||
The suffix form above was the shape the original thirteen had; this is the shape the NEXT one takes,
|
||||
because the rendered label is the translator's default string and "dialog" reads as part of the title
|
||||
to whoever writes it.
|
||||
|
||||
Found by mutation against the shipped guard: re-adding the role word in this position left the suite
|
||||
green. So each quoted literal that is actually RENDERED is checked with the same matcher, not just
|
||||
the whole value.
|
||||
|
||||
I18N KEYS ARE EXCLUDED, or this would fire on every `t("agents.onboarding.dialogLabel", ...)` — the
|
||||
key is an identifier, never announced. Key-shaped means dotted and whitespace-free, which no real
|
||||
accessible name is.
|
||||
*/
|
||||
const KEY_SHAPED = /^[A-Za-z0-9_$-]+(\.[A-Za-z0-9_$-]+)+$/;
|
||||
|
||||
function renderedLiterals(value: string): string[] {
|
||||
const literals = value.match(/"[^"]*"|'[^']*'/g) ?? [];
|
||||
return literals.filter((literal) => !KEY_SHAPED.test(literal.slice(1, -1)));
|
||||
}
|
||||
|
||||
/** Every string a screen reader could end up announcing for one `ariaLabel` call site. */
|
||||
function announceableParts(value: string): string[] {
|
||||
return [value, ...renderedLiterals(value)];
|
||||
}
|
||||
|
||||
function componentSources(): { file: string; text: string }[] {
|
||||
return readdirSync(COMPONENTS_DIR)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
@@ -105,13 +135,24 @@ describe("a FloatingWindow's accessible name never restates its role", () => {
|
||||
["ariaLabel={`Settings dialog`}", true, "quote-free template literal"],
|
||||
['ariaLabel="Git Manager dialog"', true, "plain string"],
|
||||
["ariaLabel={`Node details modal`}", true, "'modal' is equally redundant"],
|
||||
/* THE SECOND POSITION: inside the translated default, where the value does not end in the role
|
||||
word. These are the cases the suffix-only matcher was blind to. */
|
||||
['ariaLabel={t("scripts.title", "Scripts dialog")}', true, "role word inside the t() default"],
|
||||
['ariaLabel={`${t("scripts.title", "Scripts modal")}`}', true, "inside the default, interpolated"],
|
||||
['ariaLabel={t("git.title", "Git Manager dialog", { repo })}', true, "default followed by an options arg"],
|
||||
['ariaLabel={t("settings.title", "Settings")}', false, "the fixed form — a bare t() call"],
|
||||
/* The KEY of a t() call routinely contains the word and is never announced — flagging it would
|
||||
make the guard unusable, and one such key is live in the tree today. */
|
||||
['ariaLabel={t("agents.onboarding.dialogLabel", "AI Interview")}', false, "role word in the KEY only"],
|
||||
['ariaLabel={t("nodes.detail.modal", "Node details")}', false, "key ends in a role word"],
|
||||
['ariaLabel={`${t("nodes.addNode", "Add Node")}`}', false, "interpolation with no suffix"],
|
||||
['ariaLabel="Settings"', false, "a clean literal"],
|
||||
['ariaLabel="Dialog settings"', false, "role word present but not trailing"],
|
||||
['ariaLabel="Windows update"', false, "'Windows' merely contains a role word"],
|
||||
])("matcher: %s -> %s (%s)", (source, shouldFlag) => {
|
||||
const flagged = extractAriaLabelValues(source).some((v) => ROLE_WORD_AT_END.test(v));
|
||||
const flagged = extractAriaLabelValues(source)
|
||||
.flatMap(announceableParts)
|
||||
.some((part) => ROLE_WORD_AT_END.test(part));
|
||||
expect(flagged).toBe(shouldFlag);
|
||||
});
|
||||
|
||||
@@ -119,7 +160,9 @@ describe("a FloatingWindow's accessible name never restates its role", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { file, text } of componentSources()) {
|
||||
for (const value of extractAriaLabelValues(text)) {
|
||||
if (ROLE_WORD_AT_END.test(value)) offenders.push(`${file}: ${value.slice(0, 100)}`);
|
||||
if (announceableParts(value).some((part) => ROLE_WORD_AT_END.test(part))) {
|
||||
offenders.push(`${file}: ${value.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user