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. A bone says it is * one (`data-skeleton`): a pulse alone is not loading — a running job's dot pulses as long as it runs. */ async function settled(page: Page): Promise { await expect(page.locator('[role="status"] .animate-spin, [data-skeleton]')).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", "activity"]; 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([]); }); });