Files
supabase/e2e/docs/utils/axe-helpers.ts
T
Miranda LimonczenkoandClaude Opus 5 777c02c205 test(docs): scan changed pages for WCAG 2.1 A/AA in warn mode (#48727)
Closes DOCS-1233

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Test coverage. The docs accessibility check now covers the full WCAG 2.1
A/AA rule set instead of two rules.

**Note:** This PR tests _only_ the main article of changed pages
(meaning, the content itself). A follow-up Linear issue is to address
scanning the pieces outside of that: header, navigation, and interactive
elements.

## What is the current behavior?

The `@a11y` test in `e2e/docs` runs two axe rules against each in-scope
page, `heading-order` and `page-has-heading-one`. Both already pass
everywhere, so the check only guards a result we have. Nothing else in
WCAG A/AA is checked.

## What is the new behavior?

The same test runs the full WCAG 2.1 A/AA rule set.

- **Existing debt does not block PRs.** Only the two heading rules fail.
Everything else reports.
- **The check stays fast.** It scans the article only and skips nine
rules that cannot fire there. Scan time drops from 2405ms to 981ms.
- **Findings belong to us.** Legacy mode excludes cross-origin frames.
YouTube embeds were counting against us, 11 of 15 violations on one
page.
- **A pass carries meaning.** A 404 reports as a load failure, not an
a11y bug. A page scanned before it hydrates warns instead of quietly
reporting clean.

## How the findings appear

The test is named `has no blocking accessibility violations`, so a
failure listed by CI is always something to fix. It is not named for the
full rule set, because a green check would then claim more than the
check verifies.

| | Rules | Where you see it |
| --- | --- | --- |
| Blocking | `heading-order`, `page-has-heading-one` | Test failure, so
the runner reports it on the PR |
| Reported | Everything else in WCAG A/AA | `::warning` annotation on
the run |

An annotation looks like this, on a run that still passes:

```
::warning title=Accessibility::/docs/guides/database/functions has 1 non-blocking accessibility finding(s): frame-title (4)
```

The full axe result for each page is attached to the report as
`axe-results.json`.

## Matching the Studio ratchet

This follows the ESLint ratchet in `apps/studio`. That pattern warns on
pre-existing debt rather than blocking on it, surfaces findings as
annotations rather than PR comments, and promotes a rule to an error
once its violations reach zero.

The mechanism here is `ENFORCED_RULES` in `utils/axe-helpers.ts`. The
two heading rules are on it because the heading-hierarchy work drove
them to zero site-wide.

The intent is to migrate rules into that list one at a time. Pick a
rule, fix its violations, then move it into `ENFORCED_RULES` so it
cannot come back. An exhaustive scan of the site groups the current
backlog by root cause to sequence that work, and two fixes cover 99.1%
of it.

Studio keeps per-file baseline counts, which this does not. A whole-rule
list is coarser, and it works here because docs violations reach zero
across the site rather than per file.

## Manual testing

Install the browser once, then run each step from the repo root. Every
command scans production, so you do not need a local docs server.

```bash
pnpm -C e2e/docs exec playwright install chromium
```

1. Confirm a reported finding does not fail the check.

   ```bash
DOCS_E2E_PAGE_PATHS=/docs/guides/database/functions
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:a11y
   ```

Expect `1 passed`, and the `::warning` annotation above in the output.

2. Confirm the scan finds that violation. Same page, now failing on
every rule.

   ```bash
A11Y_ENFORCE_ALL=1 DOCS_E2E_PAGE_PATHS=/docs/guides/database/functions
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:a11y
   ```

Expect `1 failed`, reporting `frame-title (serious, 4 node(s))`. Steps 1
and 2 together are the point of this PR.

3. Confirm the skipped rules stay skipped.

   ```bash
A11Y_ENFORCE_ALL=1
DOCS_E2E_PAGE_PATHS=/docs/guides/getting-started/quickstarts/nextjs
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:a11y
   ```

Expect `button-name (critical, 2 node(s))` and `label (critical, 2
node(s))`, and no `color-contrast`.

4. Confirm a page that does not load reports a load failure.

   ```bash
DOCS_E2E_PAGE_PATHS=/docs/guides/does-not-exist-xyz
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:a11y
   ```

Expect `Expected a successful response for
/docs/guides/does-not-exist-xyz, got 404`, and no axe assertion.

5. Confirm the link checker still passes alongside the a11y test.

   ```bash
DOCS_E2E_PAGE_PATHS=/docs/guides/auth/passwords
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs
   ```

   Expect `3 passed`.

## Known gaps

- `/docs/reference/*` is not scanned. Those routes render client-side
into tens of thousands of elements, where axe exceeds its timeout and
results depend on whether the scan caught the page mid-render.
- Shared chrome is outside the article scope, so nav, sidebar, footer,
menus, and drawers are not covered.
- axe catches roughly 30-40% of WCAG issues. Keyboard navigation, focus
management, and screen reader behavior still need manual testing.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:41:23 -07:00

149 lines
3.8 KiB
TypeScript

import { AxeBuilder } from '@axe-core/playwright'
import type { Page, TestInfo } from '@playwright/test'
import type { Result } from 'axe-core'
export const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
export const ENFORCED_RULES = ['heading-order', 'page-has-heading-one']
export const EXCLUDED_RULES = [
'color-contrast',
'html-has-lang',
'html-lang-valid',
'html-xml-lang-mismatch',
'document-title',
'aria-hidden-body',
'meta-viewport',
'meta-refresh',
'css-orientation-lock',
]
export interface A11yScanResult {
surface: string
url: string
include: string
excludedRules: string[]
loaded: boolean
status: number | null
elementCount: number
violations: Result[]
}
export function shouldEnforceAll(): boolean {
return !!process.env.A11Y_ENFORCE_ALL
}
export async function settleForAxe(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {})
await page
.evaluate(
({ quietMs, capMs }) =>
new Promise<void>((resolve) => {
let timer: ReturnType<typeof setTimeout>
const observer = new MutationObserver(() => {
clearTimeout(timer)
timer = setTimeout(finish, quietMs)
})
function finish() {
clearTimeout(cap)
clearTimeout(timer)
observer.disconnect()
resolve()
}
const cap = setTimeout(finish, capMs)
timer = setTimeout(finish, quietMs)
observer.observe(document.body, { subtree: true, childList: true, attributes: true })
}),
{ quietMs: 500, capMs: 5_000 }
)
.catch(() => {})
}
export async function scanArticle(
page: Page,
surface: string,
include: string
): Promise<A11yScanResult> {
const scan = () => new AxeBuilder({ page }).setLegacyMode(true).include(include)
const reported = await scan().withTags(WCAG_TAGS).disableRules(EXCLUDED_RULES).analyze()
const enforced = await scan().withRules(ENFORCED_RULES).analyze()
const byRule = new Map(
[...reported.violations, ...enforced.violations].map((violation) => [violation.id, violation])
)
const elementCount = await page.evaluate(
(selector) => document.querySelector(selector)?.querySelectorAll('*').length ?? 0,
include
)
return {
surface,
url: page.url(),
include,
excludedRules: EXCLUDED_RULES,
loaded: true,
status: null,
elementCount,
violations: [...byRule.values()],
}
}
export function unloadedResult(
surface: string,
url: string,
status: number | null,
include: string
): A11yScanResult {
return {
surface,
url,
include,
excludedRules: EXCLUDED_RULES,
loaded: false,
status,
elementCount: 0,
violations: [],
}
}
export const MIN_MEANINGFUL_ELEMENTS = 20
export function scanLooksEmpty(
result: A11yScanResult,
minElements: number = MIN_MEANINGFUL_ELEMENTS
): boolean {
return result.elementCount < minElements
}
export async function attachScanReport(testInfo: TestInfo, result: A11yScanResult): Promise<void> {
await testInfo.attach('axe-results.json', {
body: JSON.stringify(result, null, 2),
contentType: 'application/json',
})
}
export function blockingViolations(result: A11yScanResult): Result[] {
if (shouldEnforceAll()) return result.violations
return result.violations.filter((violation) => ENFORCED_RULES.includes(violation.id))
}
export function formatViolations(violations: Result[]): string {
return violations
.map(
(violation) =>
`${violation.id} (${violation.impact}, ${violation.nodes.length} node(s)): ${violation.help}\n` +
violation.nodes
.slice(0, 5)
.map((node) => ` ${node.target.join(' ')}\n ${node.html.slice(0, 200)}`)
.join('\n')
)
.join('\n')
}