mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add automated accessibility regression coverage
This commit is contained in:
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user