mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
ci: check the main screens for accessibility regressions on every push
A Playwright job on the perf guard's production build runs axe-core's WCAG 2.2 A/AA rules on the mail list, reading pane, composer, calendar, contacts and Settings > Accessibility, and fails when a screen has more serious or critical violations than scripts/a11y-guard/budget.json allows (all zero today). Two more checks cover what axe can't see: the keyboard focus outline stays visible in Windows contrast themes, and single-key shortcuts don't act on the mail behind an open dialog. axe-core is vendored next to the spec (MPL-2.0, see NOTICE), so the check adds no dependency. `A11Y_GUARD_UPDATE=1 npm run test:a11y-guard` rewrites the budget.
This commit is contained in:
@@ -47,3 +47,12 @@ jobs:
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run test:perf-guard
|
||||
|
||||
# Same build: axe's WCAG 2.2 A/AA checks per main screen against
|
||||
# scripts/a11y-guard/budget.json, plus keyboard checks axe can't make.
|
||||
# Runs even when the perf guard failed, so both results show.
|
||||
- name: Run accessibility guard
|
||||
if: success() || failure()
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run test:a11y-guard
|
||||
|
||||
@@ -50,3 +50,8 @@ Bundled fonts (app/fonts), offered in Settings > Accessibility:
|
||||
Open Font License 1.1
|
||||
|
||||
The woff2 files come from Fontsource (https://fontsource.org).
|
||||
|
||||
Vendored test tool (scripts/a11y-guard/axe.min.js): axe-core 4.10.3,
|
||||
Copyright (c) 2015-2025 Deque Systems, Inc., licensed under the Mozilla
|
||||
Public License 2.0 (https://mozilla.org/MPL/2.0/). It runs only in the
|
||||
accessibility check in CI and is not part of the app.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
|
||||
"test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts",
|
||||
"test:perf-guard": "playwright test -c scripts/perf-guard/playwright.config.ts",
|
||||
"test:a11y-guard": "playwright test -c scripts/a11y-guard/playwright.config.ts",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Fails when a main screen gets more serious or critical axe violations
|
||||
* than budget.json allows, or when one of the keyboard checks axe can't
|
||||
* make breaks. See playwright.config.ts.
|
||||
*/
|
||||
|
||||
type Budget = Record<string, number>;
|
||||
type AxeNode = { target: string[] };
|
||||
type AxeViolation = { id: string; impact: string | null; nodes: AxeNode[] };
|
||||
|
||||
const UPDATE = process.env.A11Y_GUARD_UPDATE === "1";
|
||||
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||
/** The message list's rows (a treegrid, components/email/list-a11y.ts). */
|
||||
const ROWS = '[data-tour="email-list"] [role="treegrid"] [role="row"]';
|
||||
|
||||
async function runAxe(page: Page, dir: string): Promise<AxeViolation[]> {
|
||||
// The app's CSP allows only nonce scripts, so addScriptTag is refused;
|
||||
// evaluate goes through the DevTools protocol, which CSP doesn't cover.
|
||||
if (!(await page.evaluate(() => "axe" in window))) {
|
||||
await page.evaluate(readFileSync(join(dir, "axe.min.js"), "utf8"));
|
||||
}
|
||||
return page.evaluate(async (tags) => {
|
||||
const w = window as unknown as { axe: { run: (ctx: unknown, opts: unknown) => Promise<{ violations: AxeViolation[] }> } };
|
||||
const result = await w.axe.run(document, { runOnly: { type: "tag", values: tags }, resultTypes: ["violations"] });
|
||||
return result.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.map((n) => ({ target: n.target })) }));
|
||||
}, TAGS);
|
||||
}
|
||||
|
||||
const serious = (vs: AxeViolation[]) => vs.filter((v) => v.impact === "serious" || v.impact === "critical");
|
||||
const nodeCount = (vs: AxeViolation[]) => vs.reduce((n, v) => n + v.nodes.length, 0);
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /^sign in$/i }).first().click();
|
||||
await page.locator('[data-tour="email-list"]').waitFor({ timeout: 90_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
for (const name of [/^got it$/i, /^dismiss$/i]) {
|
||||
const button = page.getByRole("button", { name }).first();
|
||||
// Best effort: a banner may sit under another one or be gone already.
|
||||
if (await button.isVisible().catch(() => false)) await button.click({ timeout: 3000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Rail navigation: a page.goto after sign-in would drop the session. */
|
||||
async function openApp(page: Page, app: string) {
|
||||
await page.locator(`[data-tour="nav-${app}"]`).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
test("main screens stay within their accessibility budget", async ({ page }, testInfo) => {
|
||||
const dir = testInfo.project.testDir;
|
||||
const budgetFile = join(dir, "budget.json");
|
||||
const budget: Budget = UPDATE ? {} : JSON.parse(readFileSync(budgetFile, "utf8"));
|
||||
const found: Budget = {};
|
||||
const details: string[] = [];
|
||||
|
||||
const measure = async (screen: string) => {
|
||||
const vs = serious(await runAxe(page, dir));
|
||||
found[screen] = nodeCount(vs);
|
||||
if (!UPDATE && found[screen] > (budget[screen] ?? 0)) {
|
||||
details.push(`${screen}: ${found[screen]} nodes (budget ${budget[screen] ?? 0})\n` +
|
||||
vs.map((v) => ` ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(", ")}`).join("\n"));
|
||||
}
|
||||
};
|
||||
|
||||
await signIn(page);
|
||||
await measure("mail-list");
|
||||
|
||||
await page.locator(ROWS).first().click();
|
||||
await page.locator('[data-tour="email-viewer"]').waitFor();
|
||||
await page.waitForTimeout(1000);
|
||||
await measure("reading-pane");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: /^(compose|new message)$/i }).first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
await measure("compose");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await openApp(page, "calendar");
|
||||
await measure("calendar");
|
||||
|
||||
await openApp(page, "contacts");
|
||||
await measure("contacts");
|
||||
|
||||
await openApp(page, "settings");
|
||||
await page.getByRole("button", { name: /^accessibility$/i }).first().click();
|
||||
await page.waitForTimeout(800);
|
||||
await measure("settings-accessibility");
|
||||
|
||||
if (UPDATE) {
|
||||
writeFileSync(budgetFile, JSON.stringify(found, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
expect(details, details.join("\n")).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyboard focus stays visible in Windows contrast themes", async ({ page }) => {
|
||||
await page.emulateMedia({ forcedColors: "active" });
|
||||
await signIn(page);
|
||||
// Tab to a few controls and check each draws a real outline, which forced
|
||||
// colours keep (a box-shadow ring would disappear).
|
||||
const outlines: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
outlines.push(await page.evaluate(() => {
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
if (!el || el === document.body || el.getAttribute("tabindex") === "-1") return "skip";
|
||||
const cs = getComputedStyle(el);
|
||||
return cs.outlineStyle !== "none" && parseFloat(cs.outlineWidth) >= 2 ? "ok" : `${el.tagName}:${cs.outlineStyle}`;
|
||||
}));
|
||||
}
|
||||
expect(outlines.filter((o) => o !== "ok" && o !== "skip")).toEqual([]);
|
||||
});
|
||||
|
||||
test("single-key shortcuts don't act behind an open dialog", async ({ page }) => {
|
||||
await signIn(page);
|
||||
const rows = page.locator(ROWS);
|
||||
await rows.first().click();
|
||||
const before = await rows.count();
|
||||
await page.keyboard.press("Shift+?");
|
||||
await page.getByRole("dialog").first().waitFor();
|
||||
await page.keyboard.press("Tab");
|
||||
for (const key of ["Delete", "#", "e", "j", "ArrowDown"]) await page.keyboard.press(key);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(800);
|
||||
expect(await rows.count()).toBe(before);
|
||||
});
|
||||
Vendored
+12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mail-list": 0,
|
||||
"reading-pane": 0,
|
||||
"compose": 0,
|
||||
"calendar": 0,
|
||||
"contacts": 0,
|
||||
"settings-accessibility": 0
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Accessibility budget for the main screens: axe-core's WCAG 2.2 A/AA rules
|
||||
// (serious and critical only) per screen, plus a few keyboard checks axe
|
||||
// can't make. Runs against the same production build as the perf guard
|
||||
// (`DEV_MOCK_JMAP=true npx next build --webpack`) and fails when a screen
|
||||
// gets more violating nodes than budget.json allows.
|
||||
//
|
||||
// DEV_MOCK_JMAP=true npx next build --webpack
|
||||
// npm run test:a11y-guard # check
|
||||
// A11Y_GUARD_UPDATE=1 npm run test:a11y-guard # rewrite budget.json
|
||||
//
|
||||
// axe.min.js is vendored next to this file (axe-core, MPL-2.0) so the check
|
||||
// needs no extra dependency.
|
||||
const port = Number(process.env.A11Y_GUARD_PORT || 3151);
|
||||
const externalBaseUrl = (process.env.A11Y_GUARD_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
const adminDir = mkdtempSync(join(tmpdir(), "a11y-guard-"));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: /a11y-guard\.spec\.ts$/,
|
||||
timeout: 300_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [["list"]] : "list",
|
||||
use: {
|
||||
baseURL: externalBaseUrl || `http://localhost:${port}`,
|
||||
locale: "en-US",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
|
||||
webServer: externalBaseUrl ? undefined : {
|
||||
command: `npx next start -p ${port}`,
|
||||
port,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.cwd(),
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DEV_MOCK_JMAP: "true",
|
||||
JMAP_SERVER_URL: `http://localhost:${port}/api/dev-jmap`,
|
||||
SESSION_SECRET: "a11y-guard-session-secret-0123456789abcdef",
|
||||
ADMIN_CONFIG_DIR: join(adminDir, "config"),
|
||||
ADMIN_STATE_DIR: join(adminDir, "state"),
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user