test(e2e): axe-core's WCAG A + AA rules over every page and the player (chromium only); contrast stays with the visual check
This commit is contained in:
@@ -45,6 +45,11 @@ login-free `/watch/<token>` share link).
|
|||||||
PW_BASE_URL=http://localhost:5173 siftlode e2e
|
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
|
## Account & fixture
|
||||||
|
|
||||||
Tests run as a dedicated **admin** account `e2e@siftlode.local` (never a real account;
|
Tests run as a dedicated **admin** account `e2e@siftlode.local` (never a real account;
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function violations(page: Page): Promise<string[]> {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+24
@@ -23,6 +23,7 @@
|
|||||||
"react-i18next": "^15.7.4"
|
"react-i18next": "^15.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/core": "7.29.7",
|
"@babel/core": "7.29.7",
|
||||||
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
@@ -91,6 +92,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -3631,6 +3645,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/babel-plugin-react-compiler": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"react-i18next": "^15.7.4"
|
"react-i18next": "^15.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/core": "7.29.7",
|
"@babel/core": "7.29.7",
|
||||||
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
|
|||||||
@@ -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 BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
||||||
const STORAGE_STATE = "e2e/.auth/state.json";
|
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({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
@@ -54,11 +57,13 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: "webkit",
|
name: "webkit",
|
||||||
use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE },
|
use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE },
|
||||||
|
testIgnore: CHROMIUM_ONLY,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ipad",
|
name: "ipad",
|
||||||
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
|
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
|
||||||
|
testIgnore: CHROMIUM_ONLY,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
// The speed baseline (e2e/speed.measure.ts): readings, not tests. Only when PW_SPEED is set —
|
// The speed baseline (e2e/speed.measure.ts): readings, not tests. Only when PW_SPEED is set —
|
||||||
|
|||||||
Reference in New Issue
Block a user