Add automated accessibility regression coverage

This commit is contained in:
pulse-triage[bot]
2026-08-30 06:52:43 +01:00
parent 71c16e8da9
commit 918d87c47a
8 changed files with 137 additions and 7 deletions
@@ -97,11 +97,10 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
border={false}
class="relative flex min-w-0 max-w-full overflow-visible border-y border-border max-sm:rounded-none sm:border lg:min-h-[600px] lg:flex-row lg:overflow-hidden"
>
<div
<nav
data-settings-navigation
class={`${props.isMobileMenuOpen() ? 'flex w-full flex-col' : 'hidden lg:flex lg:flex-col'} ${props.sidebarCollapsed() ? 'lg:w-16 lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:w-72 lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative z-10 max-h-[calc(100dvh-8rem)] flex-shrink-0 overflow-y-auto overscroll-contain border-b border-border bg-surface transition-all duration-200 lg:max-h-none lg:overflow-visible lg:border-b-0 lg:border-r lg:bg-transparent lg:align-top`}
aria-label={shellCopy().navigationAriaLabel}
aria-expanded={!props.sidebarCollapsed()}
>
<div
class={`${props.sidebarCollapsed() ? 'px-2' : 'px-3 lg:px-4'} space-y-4 py-3 transition-all duration-200 lg:sticky lg:top-0 lg:space-y-5 lg:py-5`}
@@ -136,6 +135,8 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
onClick={() => props.setSidebarCollapsed(true)}
class="hidden rounded-md p-1 transition-colors hover:bg-surface-hover lg:inline-flex"
aria-label={shellCopy().collapseSidebarLabel}
aria-controls="settings-sidebar-menu"
aria-expanded="true"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@@ -154,6 +155,8 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
onClick={() => props.setSidebarCollapsed(false)}
class="hidden w-full rounded-md p-2 transition-colors hover:bg-surface-hover lg:block"
aria-label={shellCopy().expandSidebarLabel}
aria-controls="settings-sidebar-menu"
aria-expanded="false"
>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@@ -253,7 +256,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</For>
</div>
</div>
</div>
</nav>
<div
data-settings-content
@@ -51,7 +51,17 @@ describe('SettingsPageShell mobile navigation', () => {
const navigation = document.querySelector('[data-settings-navigation]');
const contentBody = document.querySelector('[data-settings-content-body]');
expect(shell).toHaveClass('space-y-0', 'lg:space-y-6');
expect(navigation?.tagName).toBe('NAV');
expect(navigation).not.toHaveAttribute('aria-expanded');
expect(navigation).toHaveClass('max-h-[calc(100dvh-8rem)]');
expect(screen.getByRole('button', { name: 'Collapse settings navigation' })).toHaveAttribute(
'aria-controls',
'settings-sidebar-menu',
);
expect(screen.getByRole('button', { name: 'Collapse settings navigation' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(contentBody).toHaveClass('min-w-0', 'p-0', 'sm:p-4', 'lg:p-5');
setActiveTab('api');
@@ -135,6 +145,14 @@ describe('SettingsPageShell mobile navigation', () => {
));
expect(sidebarCollapsed()).toBe(true);
expect(screen.getByRole('button', { name: 'Expand settings navigation' })).toHaveAttribute(
'aria-controls',
'settings-sidebar-menu',
);
expect(screen.getByRole('button', { name: 'Expand settings navigation' })).toHaveAttribute(
'aria-expanded',
'false',
);
await screen.getByRole('button', { name: 'Settings' }).click();
await waitFor(() => {
@@ -8153,6 +8153,12 @@ describe('shared primitive guardrails', () => {
expect(frontendIndexCssSource).toContain('font-variant-numeric: tabular-nums');
});
it('disables login entrance animations when reduced motion is requested', () => {
expect(frontendIndexCssSource).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{[\s\S]*?\.animate-fade-in,[\s\S]*?\.animate-slide-up \{[\s\S]*?animation: none;/,
);
});
it('keeps search field on shell, runtime, and model owners', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
+7
View File
@@ -1322,6 +1322,13 @@ body,
animation: slide-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-slide-up {
animation: none;
}
}
.delay-100 {
animation-delay: 100ms;
}
+6 -3
View File
@@ -263,10 +263,9 @@ export function Alerts() {
/>
<Card padding="none" class="relative lg:flex overflow-hidden">
<div
<nav
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200`}
aria-label={t('alerts.nav.ariaLabel')}
aria-expanded={!sidebarCollapsed()}
>
<div
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
@@ -279,6 +278,8 @@ export function Alerts() {
onClick={() => setSidebarCollapsed(true)}
class="p-1 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.collapseSidebar')}
aria-controls="alerts-sidebar-menu"
aria-expanded="true"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@@ -297,6 +298,8 @@ export function Alerts() {
onClick={() => setSidebarCollapsed(false)}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label={t('alerts.nav.expandSidebar')}
aria-controls="alerts-sidebar-menu"
aria-expanded="false"
>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@@ -348,7 +351,7 @@ export function Alerts() {
</For>
</div>
</div>
</div>
</nav>
<div class="flex-1 overflow-hidden">
<Show when={flatTabs().length > 0}>
+24
View File
@@ -9,11 +9,25 @@
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@playwright/test": "1.56.1",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.13.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
@@ -40,6 +54,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/axe-core": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+1
View File
@@ -29,6 +29,7 @@
"author": "rcourtman",
"license": "MIT",
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@playwright/test": "1.56.1",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
@@ -1,10 +1,32 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type Page } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WCAG_TAGS = [
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22aa",
] as const;
const scanForWcagViolations = async (page: Page) => {
const results = await new AxeBuilder({ page })
.withTags([...WCAG_TAGS])
.analyze();
return results.violations.map((violation) => ({
id: violation.id,
impact: violation.impact,
help: violation.help,
targets: violation.nodes.map((node) => node.target.join(" ")),
}));
};
type WorkerFixtures = { authStorageStatePath: string };
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) =>
@@ -60,6 +82,7 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o
"aria-current",
"page",
);
expect(await scanForWcagViolations(page)).toEqual([]);
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth >
@@ -73,3 +96,48 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o
contentType: "image/png",
});
});
test("representative authenticated surfaces have no automatically detectable WCAG A/AA violations", async ({
page,
}) => {
const surfaces = [
{ route: "/alerts/overview", heading: "Alerts Overview" },
{ route: "/settings/system-general", heading: "General" },
{ route: "/patrol", heading: "Patrol" },
] as const;
for (const surface of surfaces) {
await page.goto(surface.route, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { level: 1, name: surface.heading }),
).toBeVisible();
expect(
await scanForWcagViolations(page),
`${surface.route} should have no automatically detectable WCAG A/AA violations`,
).toEqual([]);
}
});
test("the logged-out entry surface has no automatically detectable WCAG A/AA violations", async ({
browser,
}, testInfo) => {
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
const page = await context.newPage();
try {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/", { waitUntil: "domcontentloaded" });
const heading = page.getByRole("heading", { name: "Welcome to Pulse" });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("animation-name", "none");
await expect(page.locator("form").first()).toHaveCSS(
"animation-name",
"none",
);
expect(await scanForWcagViolations(page)).toEqual([]);
} finally {
await context.close();
}
});