Skip to content

feat(a11y): add jest-axe CI gate for Lit component ARIA patterns - #13006

Merged
lokesh merged 7 commits into
masterfrom
a11y/jest-axe-ci-gate
Aug 5, 2026
Merged

feat(a11y): add jest-axe CI gate for Lit component ARIA patterns#13006
lokesh merged 7 commits into
masterfrom
a11y/jest-axe-ci-gate

Conversation

@mekarpeles

@mekarpeles mekarpeles commented Jun 23, 2026

Copy link
Copy Markdown
Member

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.

  • OlPopover — role="dialog", aria-label, aria-haspopup / aria-expanded / aria-controls on the slotted trigger, non-modal panel, and the mobile tray
  • OlOptionsPopover — trigger wiring, role="radiogroup", radio inputs taking their name from a wrapping <label>, aria-hidden group heading
  • OlToast — role="status" / role="alert" and aria-live per type, close button accessible name

Each 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:

  • Color contrast is not tested. jest-axe automatically disables axe's entire cat.color category in a jsdom environment, because the computed colors it would need don't exist. That's color-contrast (WCAG 1.4.3), color-contrast-enhanced (1.4.6), and link-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.
  • Three document-level rules are disabled explicitly in test-utils/a11y.js: region, landmark-one-main, and page-has-heading-one. They check whole-page structure, which a mounted component fragment can never satisfy.
  • Nothing visual or temporal: focus rings, animation, reflow, z-index and stacking, or how a real screen reader actually announces any of this.

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 transform entry that transpiles lit out of node_modules. Nothing in tests/unit/js/ imports from 'lit' today, so this is what unlocks testing real Lit components at all. It bypasses the root .babelrc deliberately: that config uses useBuiltIns: "usage" with core-js, and injecting polyfill imports into lit's ESM would be wrong.

Testing

npm run test:a11y

 PASS  openlibrary/components/__tests__/OlPopover.a11y.test.js
 PASS  openlibrary/components/__tests__/OlOptionsPopover.a11y.test.js
 PASS  openlibrary/components/__tests__/OlToast.a11y.test.js

Test Suites: 3 passed, 3 total
Tests:       15 passed, 15 total

npm run test (full suite)

Test Suites: 29 passed, 29 total
Tests:       510 passed, 510 total

These run in CI already: openlibrary/components/__tests__/ is in jest.roots, so npm run test picks them up. test:a11y is a convenience for running just this group locally.

package-lock.json regenerated inside Docker (node v24) to match CI.

Changes

  • openlibrary/components/__tests__/OlPopover.a11y.test.js — 5 tests
  • openlibrary/components/__tests__/OlOptionsPopover.a11y.test.js — 5 tests
  • openlibrary/components/__tests__/OlToast.a11y.test.js — 5 tests
  • openlibrary/components/test-utils/a11y.js — shared mount / axe / jsdom-stub helpers
  • package.json — adds jest-axe devDependency, test:a11y script, openlibrary/components/__tests__/ to Jest roots, and the lit transform
  • eslint.config.cjs — Jest globals for the new test and test-utils directories

Related

Closes #13005

mekarpeles and others added 2 commits June 23, 2026 00:03
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

@lokesh lokesh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  • openlibrary/components/__tests__/*.a11y.test.js — These tests don't actually exercise the components. Each one sets document.body.innerHTML to a hand-transcribed copy of what the component supposedly renders, then runs axe on that string. No component is imported or instantiated, so this validates our understanding of the correct ARIA markup, not the component output. If OlToast.render() drops the close button's aria-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.js as precedent, but that test imports the real FocusableHostMixin, defines real custom elements, and drives real DOM. The renderHTML there 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: OlToast renders <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.

@lokesh

lokesh commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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: lit ships as untranspiled ESM, and our jest transform ignores node_modules (and .babelrc doesn't apply across the package boundary anyway), so import '../lit/OlToast.js' throws Cannot use import statement outside a module. So it's not that the author skipped rendering out of laziness, jest fights you here.

There are two ways out. We can force jest to transform Lit (promote .babelrc to a root babel.config.js plus a transformIgnorePatterns whitelist), which is a small diff and reuses jest-axe. That works for structural ARIA, but jsdom does no layout or style computation, so color-contrast and visibility rules are silently skipped there.

The path I'd lean toward for real component testing is @web/test-runner + @open-wc/testing. It's what the Lit and Open WC teams recommend: tests run in a real browser (native ESM, so the transform problem disappears entirely), and @open-wc/testing gives us a real-browser axe check behind a clean matcher:

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, ::slotted, focus order, and contrast all behave truthfully, which is exactly the stuff jsdom fakes. The tradeoff is a second test runner and CI job alongside jest. Given we're standing up an a11y gate we'll want to grow, I think that's the right foundation rather than fighting jest's ESM handling. Happy to pick the runner question up in the next Frontend meeting if we want to talk through the CI cost before committing.

lokesh added 2 commits August 3, 2026 15:33
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.
@lokesh

lokesh commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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:

  • The tests can fail now. That's the entire point of a gate, and it wasn't true before.
  • I proved it rather than claiming it. I broke four things on purpose — removed the toast's close-button label, removed the popover dialog's name, added the modal flag back, and un-hid a heading from screen readers. All four were caught. All four passed under the old approach.
  • No more duplicate HTML to maintain by hand. It had already fallen out of date within a few weeks, with nothing to flag it.
  • The tests now describe how the components genuinely behave, so they double as documentation you can trust when you're wiring up a new one.

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 <ol-options-popover>, used exactly the way our design docs page documents it, renders no button to open it. It used to supply its own; that was removed from master and the docs page wasn't updated. Nothing to do with this PR, but someone should decide whether the component or the docs is the one that's wrong — if anything in production uses it that way, it's currently unopenable.

@lokesh
lokesh self-requested a review August 3, 2026 22:36

@lokesh lokesh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...

@lokesh
lokesh self-requested a review August 3, 2026 23:05

@lokesh lokesh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@lokesh lokesh changed the title feat(a11y): add jest-axe CI gate for Lit component ARIA patterns (WCAG 2.1 AA) feat(a11y): add jest-axe CI gate for Lit component ARIA patterns Aug 3, 2026
@lokesh
lokesh merged commit 33c7c74 into master Aug 5, 2026
9 checks passed
lokesh added a commit to lokesh/openlibrary that referenced this pull request Aug 5, 2026
…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.
lokesh added a commit that referenced this pull request Aug 11, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add automated a11y CI gate using jest-axe (WCAG 2.1 AA)

2 participants