feat(a11y): add jest-axe CI gate for Lit component ARIA patterns - #13006
Conversation
Adds WCAG 2.1 AA automated testing for OlPopover, OlOptionsPopover, and OlToast. Tests validate rendered ARIA structure (roles, labels, live regions, focus sentinels) using the HTML-mock pattern from the existing test suite, keeping tests independent of Lit's ESM transform. - 10 tests across 3 files: default + open states for each component, plus regression guards that confirm axe catches violations when accessible names or labels are absent - jest-axe v10 added as devDependency - openlibrary/components/ added to Jest roots - npm run test:a11y script added; wired into javascript_tests.yml
for more information, see https://pre-commit.ci
lokesh
left a comment
There was a problem hiding this comment.
Blocking
openlibrary/components/__tests__/*.a11y.test.js— These tests don't actually exercise the components. Each one setsdocument.body.innerHTMLto a hand-transcribed copy of what the component supposedly renders, then runsaxeon that string. No component is imported or instantiated, so this validates our understanding of the correct ARIA markup, not the component output. IfOlToast.render()drops the close button'saria-label, or the markup shifts, these stay green. As a "CI gate" that's the one failure mode we can't afford.- The file comments cite
tests/unit/js/focusableHostMixin.test.jsas precedent, but that test imports the realFocusableHostMixin, defines real custom elements, and drives real DOM. TherenderHTMLthere is fixture content fed to code that actually runs. These tests run no component code at all, so it's not the same approach. - Concrete drift already exists:
OlToastrenders<span class="toast__icon"><svg aria-hidden="true">, but the fixture writes<span aria-hidden="true"><!-- info icon --></span>. The test and the component have diverged on day one.
The fix is to render the real components. Lit works in jsdom: createElement, await el.updateComplete, then run axe against el.shadowRoot, the same way focusableHostMixin.test.js drives real elements. That deletes the transcription-drift risk and turns this into a real gate. The regression-guard tests (asserting violations.length > 0 on broken markup) can go once that's in place, since they only prove axe is wired up.
Non-blocking
- The intent is good and the ARIA patterns in the fixtures are sound. If you'd rather not block on the refactor, an option is to land this as smoke scaffolding rather than a "CI gate," with a follow-up issue to render real components. It's test-only, so it can't break production.
Tested locally before merging? If yes and CI is green, this looks ready.
|
Following up on the review with a concrete path for rendering the real components. The reason the tests hand-write HTML today is a real constraint: There are two ways out. We can force jest to transform Lit (promote The path I'd lean toward for real component testing is import { fixture, html, expect } from '@open-wc/testing';
import '../lit/OlToast.js';
it('error toast is accessible', async () => {
const el = await fixture(html`<ol-toast type="error" message="Could not save."></ol-toast>`);
await expect(el).to.be.accessible(); // real shadow DOM, contrast included
});Real shadow DOM, |
The tests asserted against hand-written HTML copies of each component's output, so they could not fail when a component changed. Point them at the real elements instead. Teach Jest to transform Lit (scoped to node_modules/lit so the existing suite keeps using .babelrc untouched — eslint.config.cjs references it, so promoting it to babel.config.js would break lint). Add jsdom shims for matchMedia and ElementInternals.setFormValue, which the components need on first render. Verified by mutation: dropping OlToast's close-button aria-label, removing OlPopover's panel aria-label forwarding, re-adding aria-modal, and unhiding OlOptionsPopover's group heading each fail the suite. All four passed under the snapshot approach. Drops the separate test:a11y CI step; the tests are in Jest's roots, so `npm run test` already covers them.
|
I pushed a change to this branch. Happy to talk it through or back it out if you'd rather take it a different direction. The short version: the tests were checking a hand-typed copy of each component's HTML rather than the components themselves. I pointed them at the real thing. Why it matters. Because the copy and the component have no connection to each other, the tests pass whether or not the components are actually accessible. I checked this rather than assumed it: I deleted the close button's label from the toast component — a genuine accessibility bug — and the tests here still passed. Green check, broken button. How I noticed. The copies had already drifted. The popover copy describes its dialog as "modal", and master has since deliberately made it non-modal so keyboard users can Tab out of it. Worth saying plainly: that wasn't a mistake on your part, it was accurate when you wrote it. That's exactly the trouble with copies — they go stale quietly and nothing tells you. What's different now. The tests load the actual components, render them, and check what really comes out. Getting there needed a bit of plumbing: the test runner didn't know how to load our component library, and the simulated browser we run tests in is missing two small features the components rely on. That's all tucked into one shared helper file. The advantages:
Your regression-guard idea carried over — I kept it, and made each guard name the specific rule it expects so it can't pass for the wrong reason. Also in this push: merged master in, since the branch was a fair way behind and the popover test needs the current version. And I dropped the separate accessibility step in CI — the tests now sit inside the normal test run, so that step was just running them a second time. Failures still fail CI. What this still doesn't do: colour contrast. The simulated browser can't compute layout or colour, so it skips those checks entirely — that was equally true before, it's just worth being explicit that "WCAG AA" here means structure and labelling, not contrast. Real contrast checking means running in an actual browser, which is a bigger conversation. There's a wrinkle to know about before anyone starts: our components take their colours from a shared stylesheet that wouldn't be loaded in an isolated test, so contrast results would look authoritative and be wrong until that's handled. I'd keep it out of this PR. One unrelated thing I bumped into. While writing the options-popover test I found that |
There was a problem hiding this comment.
LGTM - I made some changes so we load the actual components for testing rather than a hand-typed one-off stamp of the component which could easily drift from the actual component. The testing happens in Node, and not in browser, so there are still some limitations. For example, color contrast testing requires more context, specifically our CSS custom properties/variables and their cascade, and that is something that is heard to recreate in a narrow test. Even if tested in browser, we would need to do some work to get color contrast testing to work. We'll revisit in the future.
The `testMatch` override existed only because `a11y-helpers.js` sat inside `__tests__/`, where Jest collects it as a suite with no tests. Overriding `testMatch` to `**/*.test.js` fixed that by dropping Jest's default discovery repo-wide, including `**/__tests__/**` and `*.spec.js`, so a future test in either form would silently never run. Move the helper to `components/test-utils/a11y.js` instead and delete the override. Also: - `stubMatchMedia` now answers an explicit query map and throws on an unknown query, rather than returning the `mobile` flag for everything that isn't `prefers-reduced-motion`. - Add an axe pass over the mobile tray, which renders a backdrop and different markup than the desktop panel. - Add the missing OlOptionsPopover regression guard, so all three files prove axe is actually reporting. - Query by role/tag instead of style class names where a semantic selector exists. - `transformIgnorePatterns` now handles both path separators, matching the `transform` key above it.
…ight-axe Resolves the package-lock.json conflict introduced by internetarchive#13006, which added jest-axe@10 (pinning axe-core to exactly 4.10.2) after this branch last synced with master. Regenerated rather than hand-merged: took master's lockfile wholesale and re-ran `npm install` against the merged package.json. npm keeps jest-axe's axe-core@4.10.2 in the top-level slot and nests axe-core@4.12.1 under @axe-core/playwright, so both packages get the version they pin.
* feat(a11y): wire @axe-core/playwright into the e2e suite Phase 1 (#13006) covers ARIA patterns in Lit components, but those tests run in jsdom, which never lays out or paints. Colour contrast, focus styling, and anything else needing rendered pixels can only be checked in a real browser. Adds a shared `a11yCheck` helper wrapping @axe-core/playwright with OL's WCAG 2.1 AA target, plus per-test scoping so a fix PR can assert just the rule it fixed while unrelated violations elsewhere stay outstanding. - `expectNoViolations` prints impact, offending markup, and remediation steps instead of Playwright's default array diff. - `THIRD_PARTY_FRAMES` is exported rather than applied by default, so a test that skips part of the page says so in its own body. The archive.org donation banner fails `image-alt` and isn't ours to fix. - One test asserts axe evaluated rules at all, so a bad selector can't leave the suite vacuously green. - tests/e2e/README.md documents the fix-PR pattern. - Ignore playwright-report/ and test-results/, which had no ignore rule. Closes #13007 * docs(e2e): document --ui and --headed, which are not the same thing * test(a11y): settle the page before scanning, count rules not passes Two fixes to the smoke tests. `goto` resolves on `load`, which can precede first paint. Axe's contrast rules read computed pixels, so scanning that early varies run to run. Anchor on #header-bar first, matching every other spec in this directory. The canary asserted `color-contrast` was in `passes`, but a rule that starts failing moves to `violations` — so the first real contrast regression would fail the canary too, reporting "axe is broken" when axe worked correctly. Count rules axe reached any conclusion about instead, so it keeps testing what it's named for. * test(a11y): block archive.org so scans only see OL-authored markup The donation banner's content rotates by campaign, and axe does scan inside cross-origin frames under Playwright, so an unblocked scan could flip between runs with no Open Library change. Abort archive.org requests before navigating and correct the comments/README that pinned the image-alt violation to the banner's current markup. Claude-Session: https://claude.ai/code/session_012W2ka8av54rbJviSEDHXW7
Summary
Adds automated testing of the ARIA semantics of Open Library's Lit web components using jest-axe. This is Phase 1 of OL's accessibility tooling roadmap: a CI gate that catches ARIA regressions before they ship.
Each test renders the real component and runs axe over its actual shadow DOM, so a change to a component's markup is what makes the test fail.
role="dialog",aria-label,aria-haspopup/aria-expanded/aria-controlson the slotted trigger, non-modal panel, and the mobile trayrole="radiogroup", radio inputs taking their name from a wrapping<label>,aria-hiddengroup headingrole="status"/role="alert"andaria-liveper type, close button accessible nameEach test file includes a regression guard that confirms axe catches violations when an accessible name is absent, so we know the rules are active rather than silently passing.
What this does NOT cover
These tests run in jsdom, which parses markup but never lays out or paints it. Anything that depends on rendered pixels is invisible to them:
cat.colorcategory in a jsdom environment, because the computed colors it would need don't exist. That'scolor-contrast(WCAG 1.4.3),color-contrast-enhanced(1.4.6), andlink-in-text-block(1.4.1). A component can pass every test here and still ship unreadable text. Contrast has to stay a manual or browser-based check.test-utils/a11y.js:region,landmark-one-main, andpage-has-heading-one. They check whole-page structure, which a mounted component fragment can never satisfy.So this gate is a floor, not a ceiling. It catches ARIA wiring that regresses in the markup. It does not certify a component as WCAG 2.1 AA conformant.
Making this work needed a Jest
transformentry that transpileslitout ofnode_modules. Nothing intests/unit/js/importsfrom 'lit'today, so this is what unlocks testing real Lit components at all. It bypasses the root.babelrcdeliberately: that config usesuseBuiltIns: "usage"with core-js, and injecting polyfill imports into lit's ESM would be wrong.Testing
These run in CI already:
openlibrary/components/__tests__/is injest.roots, sonpm run testpicks them up.test:a11yis a convenience for running just this group locally.package-lock.jsonregenerated inside Docker (node v24) to match CI.Changes
openlibrary/components/__tests__/OlPopover.a11y.test.js— 5 testsopenlibrary/components/__tests__/OlOptionsPopover.a11y.test.js— 5 testsopenlibrary/components/__tests__/OlToast.a11y.test.js— 5 testsopenlibrary/components/test-utils/a11y.js— shared mount / axe / jsdom-stub helperspackage.json— addsjest-axedevDependency,test:a11yscript,openlibrary/components/__tests__/to Jest roots, and thelittransformeslint.config.cjs— Jest globals for the new test and test-utils directoriesRelated
Closes #13005