diff --git a/frontend/e2e/helpers/a11y.ts b/frontend/e2e/helpers/a11y.ts index 9528a7ec..433d48f6 100644 --- a/frontend/e2e/helpers/a11y.ts +++ b/frontend/e2e/helpers/a11y.ts @@ -4,14 +4,12 @@ import AxeBuilder from '@axe-core/playwright'; import { expect } from '@playwright/test'; /** - * Known pre-existing violations, keyed by route. A waiver names the axe rule - * AND a pattern for the offending nodes' target selectors — only matching - * nodes are waived, so the rule still fires anywhere else on the page. An - * entry here is technical debt with a name — remove it when the violation is - * fixed, never add one without a look at the actual finding. + * One known pre-existing violation. A waiver names the axe rule AND a pattern + * for the offending nodes' target selectors — only matching nodes are waived, + * so the rule still fires anywhere else on the page. A waiver is technical debt + * with a name — remove it when the violation is fixed, never add one without a + * look at the actual finding. */ -export type A11yAllowlist = Record; - export interface A11yWaiver { rule: string; target: RegExp; @@ -19,8 +17,7 @@ export interface A11yWaiver { const BLOCKING_IMPACTS = new Set(['critical', 'serious']); -export const scanA11y = async (page: Page, route: string, allowlist: A11yAllowlist): Promise => { - const waivers = allowlist[route] ?? []; +export const scanA11y = async (page: Page, route: string, waivers: A11yWaiver[] = []): Promise => { const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) .analyze(); diff --git a/frontend/e2e/routes.ts b/frontend/e2e/routes.ts index a0766deb..f90d8344 100644 --- a/frontend/e2e/routes.ts +++ b/frontend/e2e/routes.ts @@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'; import { routes } from '@/lib/routes'; +import type { A11yWaiver } from './helpers/a11y.ts'; import type { Cassette } from './mocks/cassette.ts'; import { apiTokensCassette } from './mocks/cassettes/api-tokens.ts'; @@ -14,6 +15,8 @@ import { settingsProvidersCassette } from './mocks/cassettes/settings-providers. import { templatesCassette } from './mocks/cassettes/templates.ts'; export interface RouteManifestEntry { + /** Known accessibility debt on this route, waived node-by-node by the axe sweep. */ + a11yWaivers?: A11yWaiver[]; cassette: () => Cassette; path: string; /** The route counts as rendered when this locator is visible. */ @@ -42,6 +45,9 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ sources: ['src/pages/flows', 'src/features/flows', 'src/providers/flows-provider.tsx'], }, { + // Message metadata (date + ID) renders at 50% opacity by design; revisit + // with the design pass. + a11yWaivers: [{ rule: 'color-contrast', target: /text-muted-foreground\\?\/50/ }], cassette: flowsCassette, path: routes.flow('5'), // The terminal mounts after the header, and the visual spec masks it — @@ -77,6 +83,10 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ sources: ['src/pages/settings/settings-api-tokens.tsx'], }, { + // The period switcher drives Tabs as a segmented control with no + // TabsContent, so the active trigger's aria-controls names an element + // that never exists. + a11yWaivers: [{ rule: 'aria-valid-attr-value', target: /radix-.*-trigger-/ }], cassette: dashboardCassette, path: routes.dashboard, ready: (page) => page.getByRole('heading', { name: 'Flows Activity Over Time' }), @@ -95,6 +105,13 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ sources: ['src/pages/settings/settings-providers.tsx'], }, { + // Row size/modified metadata renders at 80% opacity and misses AA; the + // tree's expand toggle and row checkboxes sit under the 24px pointer + // target floor — widening them is a density decision for the file manager. + a11yWaivers: [ + { rule: 'color-contrast', target: /text-muted-foreground\\?\/80/ }, + { rule: 'target-size', target: /\.rounded|aria-label="Select / }, + ], cassette: resourcesCassette, path: routes.resources, ready: (page) => page.getByRole('treeitem', { name: /reports/ }), diff --git a/frontend/e2e/specs/cross/a11y.spec.ts b/frontend/e2e/specs/cross/a11y.spec.ts index 64cc79dc..307c0928 100644 --- a/frontend/e2e/specs/cross/a11y.spec.ts +++ b/frontend/e2e/specs/cross/a11y.spec.ts @@ -1,34 +1,8 @@ -import { routes } from '@/lib/routes'; - -import type { A11yAllowlist } from '../../helpers/a11y.ts'; - import { expect, test } from '../../fixtures/test.ts'; import { scanA11y } from '../../helpers/a11y.ts'; import { loginJourneyCassette } from '../../mocks/cassettes/smoke.ts'; import { ROUTE_MANIFEST } from '../../routes.ts'; -const ALLOWLIST: A11yAllowlist = { - // Debt surfaced once this sweep started covering the whole manifest. - // The period switcher drives Tabs as a segmented control with no TabsContent, - // so the active trigger's aria-controls names an element that never exists. - [routes.dashboard]: [{ rule: 'aria-valid-attr-value', target: /radix-.*-trigger-/ }], - - // Message metadata (date + ID) intentionally renders at 50% opacity — a - // design decision, not a regression; revisit with the design pass. Axe - // targets are class chains, so the pattern pins the 50%-muted class; any - // other contrast violation on the page still fails. - [routes.flow('5')]: [{ rule: 'color-contrast', target: /text-muted-foreground\\?\/50/ }], - - // Same 50%-muted decision as the flow messages above, one step lighter: the - // per-row size and modified-at metadata renders at 80% and misses AA. The - // tree's expand toggle and row checkboxes sit under the 24px pointer-target - // floor — widening them is a density decision for the file manager. - [routes.resources]: [ - { rule: 'color-contrast', target: /text-muted-foreground\\?\/80/ }, - { rule: 'target-size', target: /\.rounded|aria-label="Select / }, - ], -}; - // Both themes are scanned: contrast waivers are theme-independent, but dark mode // recomputes every color, so a token that passes in light can still fail in dark. const THEMES = ['light', 'dark'] as const; @@ -48,7 +22,7 @@ for (const theme of THEMES) { test('login page', async ({ page }) => { await page.goto('/login'); await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); - await scanA11y(page, '/login', ALLOWLIST); + await scanA11y(page, '/login'); }); }); @@ -61,7 +35,7 @@ for (const theme of THEMES) { test('has no axe violations', async ({ page }) => { await page.goto(entry.path); await expect(entry.ready(page)).toBeVisible(); - await scanA11y(page, entry.path, ALLOWLIST); + await scanA11y(page, entry.path, entry.a11yWaivers); }); }); }