Files
siftlode/frontend/e2e/a11y.spec.ts
T
peter 24448e7c6f feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.

Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.

Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
2026-10-18 03:45:18 +02:00

65 lines
2.6 KiB
TypeScript

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", "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([]);
});
});