Skip to content

Commit e13e971

Browse files
mj12albertmichelengelen
authored andcommitted
[test] Add an assertions mode to the axe regression harness
Lets a fixture assert every axe rule it exercises rather than only the CSS-dependent visual ones, which is what the per-component WCAG reports need.
1 parent 56afe30 commit e13e971

6 files changed

Lines changed: 63 additions & 39 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,15 @@ axe-core runs inside the visual-regression Playwright loop (`test/regressions/in
166166
Key files:
167167

168168
- `test/regressions/demoMeta.ts``SCREENSHOT_RULES` and `A11Y_RULES` arrays, matched last-wins (no inheritance: overrides restate every field) against `docs/data/material/components/{slug}/{Demo}` (minimatch globs).
169-
- `test/regressions/a11y/axe.ts` — asserts `color-contrast` and `link-in-text-block` unless listed in `skipAssertions`.
169+
- `test/regressions/a11y/axe.ts` — asserts `color-contrast` and `link-in-text-block` by default, or all exercised axe rules when the matching a11y rule sets `assertions: 'all'`; `skipAssertions` suppresses selected rule assertions.
170170
- `test/regressions/a11y/a11yReporter.ts` — writes one file per slug at `docs/data/material/components/{slug}/{slug}.a11y.json`. Each file is keyed by demo name, then by axe rule ID. Each rule records a `status` (`pass`, `fail`, or `incomplete`) and WCAG tags.
171171

172172
Enroll a component (slug-wide, or narrow with brace-glob):
173173

174174
```ts
175175
// test/regressions/demoMeta.ts
176176
{ test: 'docs/data/material/components/alert/*', enabled: true, skipAssertions: ['color-contrast'] },
177-
{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true },
177+
{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true, assertions: 'all' },
178178
```
179179

180180
Override a specific demo: append a per-demo rule _after_ the slug-wide rule (last-match-wins; the override must restate every field it wants):

test/regressions/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ A fixture can be loaded with `await renderFixture(fixturePath)`, for example `re
5252

5353
Accessibility checks are opt-in.
5454
Add rules in `./demoMeta.ts` under `A11Y_RULES`.
55+
By default, only CSS-dependent visual axe rules are asserted.
56+
Set `assertions: 'all'` when a fixture is expected to pass every axe rule it exercises.
5557

5658
Use a slug-wide rule for many demos, or a brace-glob for specific demos:
5759

5860
```ts
59-
{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true }
61+
{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true, assertions: 'all' }
6062
```
6163

6264
Filtered runs with `-t` only refresh matched slugs.

test/regressions/a11y/axe.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ function formatResults(results: AxeResults['violations']) {
5656
interface RecordA11yOptions {
5757
slug: string;
5858
demo: string;
59+
/**
60+
* `visual` asserts only rules that need real rendered CSS. `all` asserts
61+
* every axe violation/incomplete not listed in `skipAssertions`.
62+
*/
63+
assertions?: 'visual' | 'all';
5964
/**
6065
* Rule ids whose violations are recorded but not asserted on. The rule
6166
* still runs and still lands in the results JSON — only the test-failing
@@ -74,7 +79,7 @@ interface RecordA11yOptions {
7479
export function recordA11y(
7580
ctx: TestContext,
7681
results: AxeResults,
77-
{ slug, demo, skipAssertions = [] }: RecordA11yOptions,
82+
{ slug, demo, assertions = 'visual', skipAssertions = [] }: RecordA11yOptions,
7883
): void {
7984
const rules: Record<string, RuleEntry> = {};
8085
const buckets: ReadonlyArray<[AxeResults['passes'], RuleStatus]> = [
@@ -100,22 +105,20 @@ export function recordA11y(
100105
(ctx.task.meta as { a11y?: A11yMeta }).a11y = meta;
101106

102107
const skip = new Set(skipAssertions);
103-
const visualViolations = results.violations.filter(
104-
(v) => VISUAL_RULES.includes(v.id) && !skip.has(v.id),
105-
);
106-
const visualIncomplete = results.incomplete.filter(
107-
(v) => VISUAL_RULES.includes(v.id) && !skip.has(v.id),
108-
);
108+
const shouldAssert = (ruleId: string) =>
109+
!skip.has(ruleId) && (assertions === 'all' || VISUAL_RULES.includes(ruleId));
110+
const assertedViolations = results.violations.filter((v) => shouldAssert(v.id));
111+
const assertedIncomplete = results.incomplete.filter((v) => shouldAssert(v.id));
109112

110113
const failures: string[] = [];
111-
if (visualViolations.length > 0) {
114+
if (assertedViolations.length > 0) {
112115
failures.push(
113-
`${visualViolations.length} axe violation(s):\n\n${formatResults(visualViolations)}`,
116+
`${assertedViolations.length} axe violation(s):\n\n${formatResults(assertedViolations)}`,
114117
);
115118
}
116-
if (visualIncomplete.length > 0) {
119+
if (assertedIncomplete.length > 0) {
117120
failures.push(
118-
`${visualIncomplete.length} axe incomplete (needs review):\n\n${formatResults(visualIncomplete)}`,
121+
`${assertedIncomplete.length} axe incomplete (needs review):\n\n${formatResults(assertedIncomplete)}`,
119122
);
120123
}
121124
if (failures.length > 0) {

test/regressions/demoMeta.test.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,6 @@ describe('parseRoute', () => {
77
expect(parseRoute('/regression-Rating/FocusVisibleRating')).to.equal(null);
88
});
99

10-
it('parses a docs-components route into path/slug/demo', () => {
11-
expect(parseRoute('/docs-components-buttons/BasicButtons')).to.deep.equal({
12-
path: 'docs/data/material/components/buttons/BasicButtons',
13-
slug: 'buttons',
14-
demo: 'BasicButtons',
15-
});
16-
});
17-
1810
it('parses a docs-product route into the matching product*/ docs/src path', () => {
1911
expect(parseRoute('/docs-product-material/MaterialHero')).to.deep.equal({
2012
path: 'docs/src/components/productMaterial/MaterialHero',
@@ -42,22 +34,6 @@ describe('getConfig', () => {
4234
).to.deep.include({ enabled: false });
4335
});
4436

45-
it('returns the a11y rule for a brace-glob enrolment', () => {
46-
expect(
47-
getConfig(A11Y_RULES, 'docs/data/material/components/buttons/BasicButtons'),
48-
).to.deep.include({ enabled: true });
49-
expect(
50-
getConfig(A11Y_RULES, 'docs/data/material/components/buttons/ColorButtons'),
51-
).to.deep.include({ enabled: true });
52-
});
53-
54-
it('returns undefined for a demo outside a brace-glob enrolment', () => {
55-
// `buttons` enrols only {BasicButtons,ColorButtons}.
56-
expect(getConfig(A11Y_RULES, 'docs/data/material/components/buttons/DisabledButtons')).to.equal(
57-
undefined,
58-
);
59-
});
60-
6137
it('honours last-match-wins when multiple rules apply', () => {
6238
const rules = [
6339
{ test: 'docs/data/material/components/foo/*', enabled: true },

test/regressions/demoMeta.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export interface A11yRule {
3737
/** Minimatch glob against `docs/data/material/components/{slug}/{Demo}`. */
3838
test: string;
3939
enabled?: boolean;
40+
/**
41+
* `visual` asserts rules that depend on rendered CSS. `all` asserts every
42+
* axe violation/incomplete that is not listed in `skipAssertions`.
43+
*/
44+
assertions?: 'visual' | 'all';
4045
/** Axe rule IDs recorded into results JSON but not asserted on. */
4146
skipAssertions?: string[];
4247
}
@@ -150,7 +155,9 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [
150155
* Slug-wide rules use `*`; brace-globs narrow enrolment to specific demos;
151156
* later opt-out rules disable individual demos.
152157
*
153-
* Initial PR scope: `buttons` only. Other components onboard incrementally.
158+
* Scope: the components with a conformance report under
159+
* `packages/mui-material/src/<Component>/accessibility.md`. Others onboard
160+
* incrementally.
154161
*/
155162
export const A11Y_RULES: A11yRule[] = [
156163
{ test: 'docs/data/material/components/buttons/{BasicButtons,ColorButtons}', enabled: true },

test/regressions/index.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ async function main() {
219219
recordA11y({ task }, results, {
220220
slug: parsed.slug,
221221
demo: parsed.demo,
222+
assertions: a11yRule.assertions,
222223
skipAssertions: a11yRule.skipAssertions,
223224
});
224225
}
@@ -349,6 +350,41 @@ async function main() {
349350
});
350351
});
351352
});
353+
354+
describe('Avatar', () => {
355+
// Deterministic clip check for 1.4.12 Text Spacing, which axe cannot cover.
356+
// Renders `LetterAvatars` and targets the two-character ("OP") avatar,
357+
// whose fixed 40px box with `overflow: hidden` is the only clipping risk.
358+
test('1.4.12 Text Spacing: initials stay visible under the WCAG overrides', async ({
359+
pooled,
360+
}) => {
361+
const { page } = pooled;
362+
await renderFixture(page, '/docs-components-avatars/LetterAvatars');
363+
const clipped = await page.evaluate(() => {
364+
const style = document.createElement('style');
365+
style.textContent =
366+
'* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; }';
367+
document.head.appendChild(style);
368+
const avatar = Array.from(document.querySelectorAll('.MuiAvatar-root')).find(
369+
(node) => node.textContent === 'OP',
370+
);
371+
const range = document.createRange();
372+
range.selectNodeContents(avatar);
373+
const text = range.getBoundingClientRect();
374+
const box = avatar.getBoundingClientRect();
375+
style.remove();
376+
return (
377+
text.left < box.left - 0.5 ||
378+
text.right > box.right + 0.5 ||
379+
text.top < box.top - 0.5 ||
380+
text.bottom > box.bottom + 0.5
381+
);
382+
});
383+
if (clipped) {
384+
throw new Error('Avatar initials are clipped under WCAG text-spacing overrides');
385+
}
386+
});
387+
});
352388
});
353389
}
354390

0 commit comments

Comments
 (0)