From 5c2d4bdbcd0c770ece18b5d84cdad801fa0477bc Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 21 Jul 2026 22:49:42 +0700 Subject: [PATCH] refactor(e2e): hang a11y waivers on the route they belong to Keying the waivers by route path made the spec restate the manifest's sample flow id, so renaming that id would silently unhook the waiver and redden a route over debt that was already accepted. The waivers now live on the manifest entry and travel with it; scanA11y takes the list directly, which also drops the route-keyed map the hand-written sweep needed. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/helpers/a11y.ts | 15 ++++++-------- frontend/e2e/routes.ts | 17 +++++++++++++++ frontend/e2e/specs/cross/a11y.spec.ts | 30 ++------------------------- 3 files changed, 25 insertions(+), 37 deletions(-) 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); }); }); }