Skip to content

Commit 5587902

Browse files
mj12albertmichelengelen
authored andcommitted
[test] Automate the CSS-dependent WCAG criteria
Adds a table-driven Playwright suite covering 1.4.10 Reflow, 1.4.4 Resize Text and 1.4.12 Text Spacing across the assessed components. axe has no rule for any of them, so they were rated Manual on evidence from source review.
1 parent ca61860 commit 5587902

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

test/regressions/index.test.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,175 @@ async function main() {
386386
});
387387
});
388388

389+
registerCssLayoutSuites({ test, renderFixture, routes });
390+
});
391+
}
392+
393+
/**
394+
* Registers the CSS-dependent WCAG criteria that axe cannot cover: they need a
395+
* real layout at a real viewport. Kept out of `main` so the suite can be
396+
* introduced independently of the harness it runs on.
397+
*/
398+
function registerCssLayoutSuites({ test, renderFixture, routes }) {
399+
// CSS-dependent criteria that axe cannot cover: they need a real layout at a
400+
// real viewport. Each component is exercised through one representative demo;
401+
// the criteria a component is rated against are listed alongside it.
402+
const CSS_LAYOUT_SUITES = [
403+
{ component: 'Accordion', route: '/docs-components-accordion/AccordionUsage' },
404+
// The same demo renders the summary header, which is rated separately.
405+
{ component: 'AccordionSummary', route: '/docs-components-accordion/AccordionUsage' },
406+
{
407+
component: 'Avatar',
408+
route: '/docs-components-avatars/LetterAvatars',
409+
// 1.4.4 is asserted here as *text-only* resize, which the Avatar report
410+
// explicitly treats as out of scope: its fixed 40px box scales under
411+
// full-page zoom (the mechanism the criterion assumes) but not under
412+
// text-only zoom, which the report calls an author concern. Left rated
413+
// Manual rather than silently downgraded — see Avatar/accessibility.md.
414+
skipCriteria: ['1.4.4'],
415+
},
416+
{ component: 'Button', route: '/docs-components-buttons/BasicButtons' },
417+
{ component: 'Checkbox', route: '/docs-components-checkboxes/Checkboxes' },
418+
{ component: 'LinearProgress', route: '/docs-components-progress/LinearDeterminate' },
419+
{ component: 'Radio', route: '/docs-components-radio-buttons/RadioButtonsGroup' },
420+
{ component: 'Switch', route: '/docs-components-switches/BasicSwitches' },
421+
{ component: 'TextField', route: '/docs-components-text-fields/BasicTextFields' },
422+
{ component: 'ToggleButton', route: '/docs-components-toggle-button/ToggleButtons' },
423+
// The same demo renders the group wrapper, which is rated separately.
424+
{ component: 'ToggleButtonGroup', route: '/docs-components-toggle-button/ToggleButtons' },
425+
];
426+
427+
/**
428+
* Reports the document's horizontal overflow after applying `css`, plus any
429+
* element whose own content escapes its box. Both are the failure modes the
430+
* reflow, resize-text, and text-spacing criteria care about.
431+
*/
432+
async function measureOverflow(page, css) {
433+
return page.evaluate((styleText) => {
434+
const style = document.createElement('style');
435+
style.textContent = styleText;
436+
document.head.appendChild(style);
437+
438+
// Force layout before measuring.
439+
void document.documentElement.offsetHeight;
440+
441+
const root = document.documentElement;
442+
const horizontalOverflow = root.scrollWidth - root.clientWidth;
443+
444+
// These criteria are about losing *text*, so only elements that hide
445+
// overflow and actually render text count. Measuring the text range
446+
// rather than scrollWidth avoids flagging boxes that deliberately clip
447+
// decoration — a Switch root clipping its ripple loses no content.
448+
const clipped = Array.from(document.querySelectorAll('[class*="Mui"]'))
449+
.filter((node) => {
450+
const { overflowX, overflowY, visibility, display } = window.getComputedStyle(node);
451+
if (overflowX !== 'hidden' && overflowY !== 'hidden') {
452+
return false;
453+
}
454+
// Deliberately hidden content — a collapsed Accordion panel — is not
455+
// clipped content: these criteria are about what is on screen.
456+
if (visibility === 'hidden' || display === 'none') {
457+
return false;
458+
}
459+
const box = node.getBoundingClientRect();
460+
if (box.width === 0 || box.height === 0) {
461+
return false;
462+
}
463+
return node.textContent.trim() !== '';
464+
})
465+
.filter((node) => {
466+
const range = document.createRange();
467+
range.selectNodeContents(node);
468+
const text = range.getBoundingClientRect();
469+
const box = node.getBoundingClientRect();
470+
return (
471+
text.left < box.left - 0.5 ||
472+
text.right > box.right + 0.5 ||
473+
text.top < box.top - 0.5 ||
474+
text.bottom > box.bottom + 0.5
475+
);
476+
})
477+
.map((node) => node.className);
478+
479+
style.remove();
480+
return { horizontalOverflow, clipped: clipped.slice(0, 3) };
481+
}, css);
482+
}
483+
484+
const TEXT_SPACING_CSS =
485+
'* { line-height: 1.5 !important; letter-spacing: 0.12em !important;' +
486+
' word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }';
487+
488+
CSS_LAYOUT_SUITES.forEach(({ component, route, skipCriteria = [] }) => {
489+
// A slug only enters the demo bundle once its component is enrolled, so skip
490+
// entries this build doesn't contain rather than waiting for a fixture that
491+
// was never bundled.
492+
if (!routes.includes(route)) {
493+
return;
494+
}
495+
const covers = (criterion) => !skipCriteria.includes(criterion);
496+
497+
describe(`${component} layout criteria`, () => {
498+
test.runIf(covers('1.4.10'))(
499+
'1.4.10 Reflow: no horizontal scrolling at 320 CSS pixels',
500+
async ({ pooled }) => {
501+
const { page } = pooled;
502+
await page.setViewportSize({ width: 320, height: 512 });
503+
try {
504+
await renderFixture(page, route);
505+
const { horizontalOverflow } = await measureOverflow(page, '');
506+
if (horizontalOverflow > 1) {
507+
throw new Error(
508+
`${component} overflows horizontally by ${horizontalOverflow}px at 320px wide`,
509+
);
510+
}
511+
} finally {
512+
await page.setViewportSize(DEFAULT_VIEWPORT);
513+
}
514+
},
515+
);
516+
517+
test.runIf(covers('1.4.4'))(
518+
'1.4.4 Resize Text: content survives a 200% text size',
519+
async ({ pooled }) => {
520+
const { page } = pooled;
521+
await renderFixture(page, route);
522+
const { horizontalOverflow, clipped } = await measureOverflow(
523+
page,
524+
'html { font-size: 200% !important; }',
525+
);
526+
if (horizontalOverflow > 1) {
527+
throw new Error(
528+
`${component} overflows horizontally by ${horizontalOverflow}px at 200% text size`,
529+
);
530+
}
531+
if (clipped.length > 0) {
532+
throw new Error(`${component} clips its own content at 200%: ${clipped.join(', ')}`);
533+
}
534+
},
535+
);
536+
537+
test.runIf(covers('1.4.12'))(
538+
'1.4.12 Text Spacing: content survives the WCAG spacing overrides',
539+
async ({ pooled }) => {
540+
const { page } = pooled;
541+
await renderFixture(page, route);
542+
const { horizontalOverflow, clipped } = await measureOverflow(page, TEXT_SPACING_CSS);
543+
if (horizontalOverflow > 1) {
544+
throw new Error(
545+
`${component} overflows horizontally by ${horizontalOverflow}px under the` +
546+
' WCAG text-spacing overrides',
547+
);
548+
}
549+
if (clipped.length > 0) {
550+
throw new Error(
551+
`${component} clips its own content under the WCAG text-spacing overrides:` +
552+
` ${clipped.join(', ')}`,
553+
);
554+
}
555+
},
556+
);
557+
});
389558
});
390559
}
391560

0 commit comments

Comments
 (0)