From a8dddc8c24483fcd67b072606ca5c59f59157b75 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 17 Oct 2026 17:48:53 +0200 Subject: [PATCH] test(e2e): axe-core's WCAG A + AA rules over every page and the player (chromium only); contrast stays with the visual check --- frontend/e2e/README.md | 5 +++ frontend/e2e/a11y.spec.ts | 64 +++++++++++++++++++++++++++++++++++ frontend/package-lock.json | 24 +++++++++++++ frontend/package.json | 1 + frontend/playwright.config.ts | 5 +++ 5 files changed, 99 insertions(+) create mode 100644 frontend/e2e/a11y.spec.ts diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index 2fa68d23..6f8116fc 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -45,6 +45,11 @@ login-free `/watch/` share link). PW_BASE_URL=http://localhost:5173 siftlode e2e ``` +**Accessibility** — `a11y.spec.ts` runs axe-core's WCAG 2.x A + AA rules (`@axe-core/playwright`) +over every rail page, every admin page and the player, in `chromium` only (the rules read the DOM, +which the engines build alike). It does not judge colour contrast: axe files text on a gradient — +every glass surface — as "incomplete", not as a pass or a hit. + ## Account & fixture Tests run as a dedicated **admin** account `e2e@siftlode.local` (never a real account; diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts new file mode 100644 index 00000000..82984fc9 --- /dev/null +++ b/frontend/e2e/a11y.spec.ts @@ -0,0 +1,64 @@ +import AxeBuilder from "@axe-core/playwright"; +import { test, expect, type Page } from "@playwright/test"; +import { gotoPage, openApp, openFirstVideo } from "./helpers"; + +// Accessibility, the machine-checkable half: axe-core's WCAG 2.x A + AA rules over every page the +// owner reaches and the player. A hit fails with the rule, its impact and the elements. +// +// What it does NOT cover — colour contrast, mostly: axe cannot compute a contrast over a gradient, +// and the glass surfaces are gradients, so it files most text as "incomplete" instead of judging it +// (measured 2026-10-17: 110 nodes on the feed, 9-32 on the other pages). A green here says nothing +// about contrast; that stays with the visual check. +// +// Chromium only (playwright.config.ts leaves it out of the other projects). +const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]; + +/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. */ +async function settled(page: Page): Promise { + await expect( + page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'), + ).toHaveCount(0); +} + +async function violations(page: Page): Promise { + const result = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + return result.violations.map((v) => { + const shown = v.nodes.slice(0, 5).map((n) => n.target.join(" ")); + const more = v.nodes.length > 5 ? ` (+${v.nodes.length - 5})` : ""; + return `${v.id} (${v.impact ?? "?"}): ${v.help} — ${shown.join(" | ")}${more}`; + }); +} + +test.describe("accessibility (axe)", () => { + const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"]; + const ADMIN = ["scheduler", "config", "users", "audit"]; + + for (const id of RAIL) { + test(`page: ${id}`, async ({ page }) => { + await openApp(page); + await gotoPage(page, id); + await settled(page); + expect(await violations(page)).toEqual([]); + }); + } + + for (const id of ADMIN) { + test(`admin page: ${id}`, async ({ page }) => { + await openApp(page); + await page.getByTestId("nav-admin").click(); + await page.getByTestId(`nav-admin-${id}`).click(); + await expect(page.getByTestId("nav-admin")).toHaveAttribute("aria-current", "page"); + await settled(page); + expect(await violations(page)).toEqual([]); + }); + } + + test("player", async ({ page }) => { + // hermetic: the YouTube iframe is not loaded + await page.route(/youtube\.com/, (route) => route.abort()); + await openApp(page); + await openFirstVideo(page); + await settled(page); + expect(await violations(page)).toEqual([]); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 880bebdb..69e9d779 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "react-i18next": "^15.7.4" }, "devDependencies": { + "@axe-core/playwright": "4.13.0", "@babel/core": "7.29.7", "@eslint-community/eslint-plugin-eslint-comments": "4.8.1", "@eslint/js": "9.39.1", @@ -91,6 +92,19 @@ "dev": true, "license": "ISC" }, + "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/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -3631,6 +3645,16 @@ "dev": true, "license": "MIT" }, + "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/babel-plugin-react-compiler": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0fc26506..8b8604a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "react-i18next": "^15.7.4" }, "devDependencies": { + "@axe-core/playwright": "4.13.0", "@babel/core": "7.29.7", "@eslint-community/eslint-plugin-eslint-comments": "4.8.1", "@eslint/js": "9.39.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 6fe0aa9e..0acd2290 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -17,6 +17,9 @@ import { defineConfig, devices } from "@playwright/test"; const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080"; const STORAGE_STATE = "e2e/.auth/state.json"; +// The axe scan reads the DOM and the accessibility tree, which the engines build alike: one engine +// answers for all, so the other projects leave it out instead of tripling its time. +const CHROMIUM_ONLY = /a11y\.spec\.ts/; export default defineConfig({ testDir: "./e2e", @@ -54,11 +57,13 @@ export default defineConfig({ { name: "webkit", use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE }, + testIgnore: CHROMIUM_ONLY, dependencies: ["setup"], }, { name: "ipad", use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE }, + testIgnore: CHROMIUM_ONLY, dependencies: ["setup"], }, // The speed baseline (e2e/speed.measure.ts): readings, not tests. Only when PW_SPEED is set —