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.
65 lines
2.6 KiB
TypeScript
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([]);
|
|
});
|
|
});
|