From 63a14775c06f4ecc433715a5a4799d96dbc99c62 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 17:13:25 -0500 Subject: [PATCH 01/25] test(a11y): add automated WCAG 2.2 AA scan harness Adds an axe-core + Playwright accessibility scan under e2e/a11y/ that reuses the existing e2e auth/seed/server wiring (no parallel setup, no app changes). It scans every app route as an authenticated user against seeded data, splits WCAG A/AA findings from best-practice, scans cheap interactive states (dialog/menu), and aggregates a deduped report grouped by WCAG success criterion for VPAT-style review. - routes.ts: manifest of all app routes with runtime-resolved seeded IDs - fixtures.setup.ts: seeds a richly-populated project + triggers ES reindex - scan.spec.ts: parameterized axe scan, per-route JSON output - aggregate.ts: deduped report.md/report.json grouped by success criterion - run.ts: a11y:scan runner (single-route, strict mode, exit-code propagation) - adds a11y:scan / a11y:report npm scripts, @axe-core/playwright dev dep, gitignore entries for results/fixtures, README, and a report-mode CI draft Report mode by default; A11Y_STRICT=on (or CI=strict) fails on serious/critical. --- pnpm-lock.yaml | 19 ++ testplanit/.gitignore | 6 + testplanit/e2e/a11y/README.md | 64 +++++ testplanit/e2e/a11y/aggregate.ts | 318 ++++++++++++++++++++++ testplanit/e2e/a11y/ci-workflow.draft.yml | 124 +++++++++ testplanit/e2e/a11y/fixtures.setup.ts | 230 ++++++++++++++++ testplanit/e2e/a11y/playwright.config.ts | 42 +++ testplanit/e2e/a11y/routes.ts | 192 +++++++++++++ testplanit/e2e/a11y/run.ts | 39 +++ testplanit/e2e/a11y/scan.spec.ts | 268 ++++++++++++++++++ testplanit/e2e/a11y/wcag.ts | 132 +++++++++ testplanit/package.json | 3 + 12 files changed, 1437 insertions(+) create mode 100644 testplanit/e2e/a11y/README.md create mode 100644 testplanit/e2e/a11y/aggregate.ts create mode 100644 testplanit/e2e/a11y/ci-workflow.draft.yml create mode 100644 testplanit/e2e/a11y/fixtures.setup.ts create mode 100644 testplanit/e2e/a11y/playwright.config.ts create mode 100644 testplanit/e2e/a11y/routes.ts create mode 100644 testplanit/e2e/a11y/run.ts create mode 100644 testplanit/e2e/a11y/scan.spec.ts create mode 100644 testplanit/e2e/a11y/wcag.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 566809169..a09347ce1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1017,6 +1017,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@axe-core/playwright': + specifier: ^4.11.3 + version: 4.11.3(playwright-core@1.60.0) '@babel/core': specifier: ^7.29.7 version: 7.29.7 @@ -3318,6 +3321,11 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@axe-core/playwright@4.11.3': + resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -10095,6 +10103,10 @@ packages: resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} engines: {node: '>=4'} + axe-core@4.11.4: + resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} + engines: {node: '>=4'} + axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} @@ -25600,6 +25612,11 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@axe-core/playwright@4.11.3(playwright-core@1.60.0)': + dependencies: + axe-core: 4.11.4 + playwright-core: 1.60.0 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -34715,6 +34732,8 @@ snapshots: axe-core@4.11.2: {} + axe-core@4.11.4: {} + axios@1.16.1: dependencies: follow-redirects: 1.16.0(debug@4.3.7) diff --git a/testplanit/.gitignore b/testplanit/.gitignore index e424fb52b..523eaf245 100644 --- a/testplanit/.gitignore +++ b/testplanit/.gitignore @@ -61,6 +61,12 @@ e2e/playwright-report/ e2e/test-results/ .env.e2e +# Accessibility scan (e2e/a11y) — never commit results, seeded fixtures, or reports +e2e/a11y/results/ +e2e/a11y/.a11y-fixtures.json +e2e/a11y/playwright-report/ +e2e/a11y/test-results/ + # DB backups backups/ diff --git a/testplanit/e2e/a11y/README.md b/testplanit/e2e/a11y/README.md new file mode 100644 index 000000000..2d2eaddb5 --- /dev/null +++ b/testplanit/e2e/a11y/README.md @@ -0,0 +1,64 @@ +# Accessibility scan (`e2e/a11y`) + +Automated WCAG 2.2 AA baseline scan across every app route, built on the +existing Playwright e2e setup. Produces a deduped, WCAG-success-criterion-grouped +report intended to feed a VPAT 2.5 (INT) conformance review. + +> Automated tooling (axe-core) reliably catches ~30–40% of WCAG issues. A green +> report is **not** a conformance claim — manual keyboard and screen-reader +> testing is still required. + +## What it does + +1. **`fixtures.setup.ts`** (Playwright `setup` project) seeds one richly + populated project (folders, 14 cases, a run with mixed results, a session, a + milestone, tags, a public share link) via the existing `ApiHelper`, writes + the resolved IDs to `.a11y-fixtures.json`, and triggers an Elasticsearch + reindex so search-driven views are populated. +2. **`scan.spec.ts`** iterates `routes.ts` (one test per route), waits for the + page to settle (bounded `networkidle` + a sanity selector), runs axe with + tags `wcag2a wcag2aa wcag21a wcag21aa wcag22aa best-practice`, and — on a few + routes — re-scans cheap interactive states (open the primary dialog, open a + row/action menu). Raw per-route JSON lands in `results/`. +3. **`aggregate.ts`** dedupes findings across routes, groups them by WCAG + success criterion, and writes `results/report.md` + `results/report.json`. + +## Running + +Requires the same infra as the e2e suite (`.env.e2e`: Postgres, Valkey, and +`ELASTICSEARCH_NODE`). Build first — the scan runs against a production build. + +```bash +cd testplanit +pnpm build # production build (needs ~16–24 GB RAM) +pnpm a11y:scan # full scan → results/report.md (report mode) +pnpm a11y:scan -- --route=admin-users # single route (fixtures still seed) +pnpm a11y:report # regenerate the report from existing results/ +``` + +The server, DB reseed, admin login, and worker startup are handled automatically +by the reused `globalSetup` (set `E2E_BASE_URL` / `E2E_PORT` to point at an +already-running server; `reuseExistingServer` is on). + +### Pass/fail modes + +- **Report mode (default):** never fails on violations; just writes the report. +- **Strict mode:** `A11Y_STRICT=on pnpm a11y:scan` (or `CI=strict`) fails any + route with a serious/critical WCAG violation, and the aggregator exits non-zero. + +## Outputs (all gitignored) + +| Path | Contents | +| --- | --- | +| `results/.json` | Raw axe result per route + interactive state | +| `results/report.md` | Deduped report grouped by WCAG success criterion | +| `results/report.json` | Machine-readable rollup | +| `.a11y-fixtures.json` | Seeded entity IDs for this run | + +## Adding / changing routes + +Edit `routes.ts`. Each entry: `name` (unique slug = result filename), `group`, +`path(fixtures)` (locale-less), `authRequired`, optional `sanity` selector, +`needs` (fixtures that must exist or the route is recorded as skipped), +`mayRedirect`, and `interactions` (`"dialog"` / `"menu"`). Routes that can't be +scanned are recorded in the report's **Coverage notes**, never silently dropped. diff --git a/testplanit/e2e/a11y/aggregate.ts b/testplanit/e2e/a11y/aggregate.ts new file mode 100644 index 000000000..337dc17ee --- /dev/null +++ b/testplanit/e2e/a11y/aggregate.ts @@ -0,0 +1,318 @@ +/** + * Reads the per-route axe JSON in results/ and produces: + * - results/report.md — human-readable, deduped, grouped by WCAG SC + * - results/report.json — machine-readable rollup (for CI / VPAT tooling) + * + * Run standalone with `pnpm a11y:report` or automatically after `pnpm a11y:scan`. + * In strict mode (A11Y_STRICT=on or CI=strict) it exits non-zero when any + * serious/critical WCAG finding exists. + */ +import fs from "fs"; +import path from "path"; +import { primaryCriterion, type WcagLevel } from "./wcag"; + +const RESULTS_DIR = path.join(__dirname, "results"); +const STRICT = process.env.A11Y_STRICT === "on" || process.env.CI === "strict"; + +const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"] as const; +type Impact = (typeof IMPACT_ORDER)[number] | "none"; + +interface ViolationNode { + target: string[]; + html: string; + failureSummary: string; +} +interface Violation { + id: string; + impact: string | null; + description: string; + help: string; + helpUrl: string; + tags: string[]; + criterion: string; + criterionKey: string; + nodeCount: number; + nodes: ViolationNode[]; +} +interface StateResult { + state: string; + reached: boolean; + url: string; + wcagViolations: Violation[]; + bestPracticeViolations: Violation[]; +} +interface RouteResult { + name: string; + group: string; + requestedPath: string; + finalUrl: string; + authRequired: boolean; + scannedAt: string; + status: "scanned" | "skipped" | "error"; + note?: string; + states: StateResult[]; +} + +/** A rule deduped across every route/state it appeared on. */ +interface Finding { + id: string; + impact: Impact; + criterionKey: string; + criterionLabel: string; // "1.4.3 Contrast (Minimum)" + scNum: string; + scLevel: WcagLevel; + help: string; + helpUrl: string; + description: string; + routes: Set; // "route" or "route (dialog)" + totalNodes: number; + sampleSelector: string; + sampleHtml: string; + sampleFailure: string; +} + +function impactRank(i: Impact): number { + const idx = (IMPACT_ORDER as readonly string[]).indexOf(i); + return idx === -1 ? 99 : idx; +} +function worseImpact(a: Impact, b: Impact): Impact { + return impactRank(a) <= impactRank(b) ? a : b; +} + +function loadResults(): RouteResult[] { + if (!fs.existsSync(RESULTS_DIR)) return []; + return fs + .readdirSync(RESULTS_DIR) + .filter((f) => f.endsWith(".json") && f !== "report.json") + .map((f) => JSON.parse(fs.readFileSync(path.join(RESULTS_DIR, f), "utf8")) as RouteResult); +} + +function dedupe(results: RouteResult[], kind: "wcag" | "best"): Map { + const findings = new Map(); + for (const r of results) { + if (r.status !== "scanned") continue; + for (const state of r.states) { + const label = state.state === "initial" ? r.name : `${r.name} (${state.state})`; + const vios = kind === "wcag" ? state.wcagViolations : state.bestPracticeViolations; + for (const v of vios) { + const sc = primaryCriterion(v.tags); + let f = findings.get(v.id); + if (!f) { + f = { + id: v.id, + impact: (v.impact as Impact) || "none", + criterionKey: sc.key, + criterionLabel: sc.num === "—" ? sc.name : `${sc.num} ${sc.name}`, + scNum: sc.num, + scLevel: sc.level, + help: v.help, + helpUrl: v.helpUrl, + description: v.description, + routes: new Set(), + totalNodes: 0, + sampleSelector: v.nodes[0]?.target?.join(" ") || "", + sampleHtml: v.nodes[0]?.html || "", + sampleFailure: v.nodes[0]?.failureSummary || "", + }; + findings.set(v.id, f); + } + f.impact = worseImpact(f.impact, (v.impact as Impact) || "none"); + f.routes.add(label); + f.totalNodes += v.nodeCount; + } + } + } + return findings; +} + +function esc(s: string): string { + return s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim(); +} +function codeFence(s: string): string { + return "`" + s.replace(/`/g, "ʼ").slice(0, 200) + "`"; +} + +function build(): { md: string; json: unknown; blockingCount: number } { + const results = loadResults(); + const scanned = results.filter((r) => r.status === "scanned"); + const skipped = results.filter((r) => r.status === "skipped"); + const errored = results.filter((r) => r.status === "error"); + + const wcag = [...dedupe(results, "wcag").values()]; + const best = [...dedupe(results, "best").values()]; + + // Impact tallies (unique rules + route occurrences). + const tally = (findings: Finding[]) => { + const byImpact: Record = {}; + for (const i of IMPACT_ORDER) byImpact[i] = { rules: 0, occurrences: 0 }; + for (const f of findings) { + const k = f.impact === "none" ? "minor" : f.impact; + if (!byImpact[k]) byImpact[k] = { rules: 0, occurrences: 0 }; + byImpact[k].rules += 1; + byImpact[k].occurrences += f.routes.size; + } + return byImpact; + }; + const wcagTally = tally(wcag); + + const blocking = wcag.filter((f) => f.impact === "serious" || f.impact === "critical"); + + // Sort: impact, then routes affected desc. + const bySpread = (a: Finding, b: Finding) => + impactRank(a.impact) - impactRank(b.impact) || b.routes.size - a.routes.size; + wcag.sort(bySpread); + best.sort(bySpread); + + const top5 = [...wcag].sort((a, b) => b.routes.size - a.routes.size || impactRank(a.impact) - impactRank(b.impact)).slice(0, 5); + + // Group WCAG findings by success criterion. + const byCriterion = new Map(); + for (const f of wcag) { + const arr = byCriterion.get(f.criterionLabel) || []; + arr.push(f); + byCriterion.set(f.criterionLabel, arr); + } + const criteria = [...byCriterion.entries()].sort((a, b) => { + const aw = Math.min(...a[1].map((f) => impactRank(f.impact))); + const bw = Math.min(...b[1].map((f) => impactRank(f.impact))); + return aw - bw || a[0].localeCompare(b[0], undefined, { numeric: true }); + }); + + const now = new Date().toISOString(); + const L: string[] = []; + L.push(`# Accessibility Scan Report`); + L.push(""); + L.push(`Generated ${now} · axe-core (\`wcag2a\`, \`wcag2aa\`, \`wcag21a\`, \`wcag21aa\`, \`wcag22aa\`, \`best-practice\`)`); + L.push(""); + L.push(`This is the **automated baseline** for a WCAG 2.2 AA audit feeding a VPAT 2.5 (INT) report. Automated scanning catches roughly a third of WCAG issues; manual keyboard/screen-reader testing is still required for full conformance claims.`); + L.push(""); + + // ---- Summary ---- + L.push(`## Summary`); + L.push(""); + L.push(`- **Routes scanned:** ${scanned.length} · **skipped:** ${skipped.length} · **errored:** ${errored.length}`); + L.push(`- **WCAG A/AA findings (unique rules):** ${wcag.length} · **best-practice findings:** ${best.length}`); + L.push(`- **Serious/critical WCAG findings:** ${blocking.length}`); + L.push(""); + L.push(`| Impact | Unique rules | Route occurrences |`); + L.push(`| --- | ---: | ---: |`); + for (const i of IMPACT_ORDER) { + L.push(`| ${i} | ${wcagTally[i].rules} | ${wcagTally[i].occurrences} |`); + } + L.push(""); + + // ---- Top 5 ---- + L.push(`### Top 5 most widespread WCAG issues`); + L.push(""); + if (top5.length === 0) { + L.push(`_No WCAG A/AA violations detected._`); + } else { + L.push(`| Rule | Criterion | Impact | Routes affected |`); + L.push(`| --- | --- | --- | ---: |`); + for (const f of top5) { + L.push(`| \`${f.id}\` | ${esc(f.criterionLabel)} | ${f.impact} | ${f.routes.size} |`); + } + } + L.push(""); + + // ---- Findings by criterion ---- + L.push(`## WCAG findings by success criterion`); + L.push(""); + if (criteria.length === 0) L.push(`_None._`); + for (const [label, findings] of criteria) { + const level = findings[0]?.scLevel ?? "AA"; + L.push(`### ${label} — Level ${level}`); + L.push(""); + for (const f of findings) { + L.push(`#### \`${f.id}\` — ${f.impact} · ${f.routes.size} route(s) · ${f.totalNodes} element(s)`); + L.push(""); + L.push(`${esc(f.help)}. [Reference](${f.helpUrl})`); + L.push(""); + if (f.sampleSelector) L.push(`- **Example selector:** ${codeFence(f.sampleSelector)}`); + if (f.sampleHtml) L.push(`- **Example element:** ${codeFence(f.sampleHtml)}`); + if (f.sampleFailure) L.push(`- **axe fix guidance:** ${esc(f.sampleFailure)}`); + L.push(`- **Affected routes:** ${[...f.routes].sort().map((r) => `\`${r}\``).join(", ")}`); + L.push(""); + } + } + + // ---- Best practice ---- + L.push(`## Best-practice (non-WCAG) findings`); + L.push(""); + L.push(`_Reported for awareness; not counted against WCAG 2.2 AA conformance._`); + L.push(""); + if (best.length === 0) { + L.push(`_None._`); + } else { + L.push(`| Rule | Impact | Routes | Guidance |`); + L.push(`| --- | --- | ---: | --- |`); + for (const f of best) { + L.push(`| \`${f.id}\` | ${f.impact} | ${f.routes.size} | ${esc(f.help)} |`); + } + } + L.push(""); + + // ---- Coverage notes ---- + L.push(`## Coverage notes`); + L.push(""); + if (skipped.length) { + L.push(`**Skipped routes (${skipped.length})** — recorded rather than silently dropped:`); + for (const r of skipped) L.push(`- \`${r.name}\` — ${esc(r.note || "skipped")}`); + L.push(""); + } + if (errored.length) { + L.push(`**Errored routes (${errored.length}):**`); + for (const r of errored) L.push(`- \`${r.name}\` (${esc(r.requestedPath)}) — ${esc(r.note || "error")}`); + L.push(""); + } + const redirected = scanned.filter((r) => r.note && /redirect/i.test(r.note)); + if (redirected.length) { + L.push(`**Redirected routes (${redirected.length})** — scanned at their landing page:`); + for (const r of redirected) L.push(`- \`${r.name}\` — ${esc(r.note!)}`); + L.push(""); + } + const interactionStates = scanned.flatMap((r) => + r.states.filter((s) => s.state !== "initial").map((s) => ({ r: r.name, s })) + ); + const unreached = interactionStates.filter((x) => !x.s.reached); + if (interactionStates.length) { + L.push( + `**Interactive-state scans:** ${interactionStates.length - unreached.length}/${interactionStates.length} reached (dialog/menu opened and re-scanned).` + ); + L.push(""); + } + + const json = { + generatedAt: now, + summary: { + scanned: scanned.length, + skipped: skipped.length, + errored: errored.length, + wcagUniqueRules: wcag.length, + bestPracticeRules: best.length, + blocking: blocking.length, + impact: wcagTally, + }, + wcagFindings: wcag.map((f) => ({ ...f, routes: [...f.routes].sort() })), + bestPracticeFindings: best.map((f) => ({ ...f, routes: [...f.routes].sort() })), + skipped: skipped.map((r) => ({ name: r.name, note: r.note })), + errored: errored.map((r) => ({ name: r.name, path: r.requestedPath, note: r.note })), + }; + + return { md: L.join("\n") + "\n", json, blockingCount: blocking.length }; +} + +function main(): void { + const { md, json, blockingCount } = build(); + fs.mkdirSync(RESULTS_DIR, { recursive: true }); + fs.writeFileSync(path.join(RESULTS_DIR, "report.md"), md); + fs.writeFileSync(path.join(RESULTS_DIR, "report.json"), JSON.stringify(json, null, 2)); + console.log(`[a11y] report written to ${path.relative(process.cwd(), path.join(RESULTS_DIR, "report.md"))}`); + console.log(`[a11y] ${blockingCount} serious/critical WCAG finding(s)`); + if (STRICT && blockingCount > 0) { + console.error(`[a11y] STRICT mode: failing because ${blockingCount} serious/critical WCAG finding(s) exist.`); + process.exit(1); + } +} + +main(); diff --git a/testplanit/e2e/a11y/ci-workflow.draft.yml b/testplanit/e2e/a11y/ci-workflow.draft.yml new file mode 100644 index 000000000..d56e77dbc --- /dev/null +++ b/testplanit/e2e/a11y/ci-workflow.draft.yml @@ -0,0 +1,124 @@ +# DRAFT — accessibility scan in CI (report mode). +# +# Not active: this lives in e2e/a11y/, not .github/workflows/. To enable, move it +# to .github/workflows/a11y-scan.yml and adjust secrets/paths. It starts in +# REPORT mode (never fails the build); flip to enforcement later by setting +# A11Y_STRICT=on on the scan step. +# +# Mirrors the local run: Postgres + Valkey + Elasticsearch services, a +# production build, then `pnpm a11y:scan`. In CI the base Playwright config sets +# webServer=undefined, so we start the server ourselves before scanning. + +name: Accessibility Scan + +on: + workflow_dispatch: + pull_request: + paths: + - "testplanit/app/**" + - "testplanit/components/**" + - "testplanit/e2e/a11y/**" + schedule: + - cron: "0 6 * * 1" # Monday 06:00 UTC weekly baseline + +defaults: + run: + working-directory: testplanit + +jobs: + a11y: + runs-on: ubuntu-latest + timeout-minutes: 45 + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: tpi_test + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U user" + --health-interval 10s --health-timeout 5s --health-retries 5 + valkey: + image: valkey/valkey:8-alpine + ports: + - 6377:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 10s --health-timeout 5s --health-retries 5 + elasticsearch: + image: elasticsearch:9.0.3 + env: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + ports: + - 9221:9200 + options: >- + --health-cmd "curl -f http://localhost:9200/_cluster/health || exit 1" + --health-interval 15s --health-timeout 10s --health-retries 8 + + env: + # Point the app at the service containers (mirrors .env.e2e). + DATABASE_URL: "postgresql://user:password@localhost:5433/tpi_test?schema=public" + VALKEY_URL: "redis://localhost:6377" + ELASTICSEARCH_NODE: "http://localhost:9221" + NEXTAUTH_URL: "http://localhost:3002" + NEXTAUTH_SECRET: "ci-a11y-secret-not-for-production" + ENCRYPTION_KEY: "ci-a11y-encryption-key-32-chars--" + ADMIN_EMAIL: "admin@example.com" + ADMIN_PASSWORD: "admin" + E2E_PROD: "on" + E2E_PORT: "3002" + NODE_OPTIONS: "--max-old-space-size=8192" + # Report mode: leave A11Y_STRICT unset. To enforce, set: A11Y_STRICT: "on" + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate ZenStack/Prisma client + run: pnpm generate + + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps chromium + + - name: Production build + run: pnpm build + + - name: Start app server + run: | + pnpm start --port 3002 & + pnpm exec wait-on -t 120000 http://localhost:3002 + + # globalSetup reseeds the DB, logs in (storageState), and spawns workers; + # the scan then seeds fixture data and runs axe across every route. + - name: Run accessibility scan (report mode) + run: pnpm a11y:scan + + - name: Upload accessibility report + if: always() + uses: actions/upload-artifact@v4 + with: + name: a11y-report + path: | + testplanit/e2e/a11y/results/report.md + testplanit/e2e/a11y/results/report.json + testplanit/e2e/a11y/results/*.json + retention-days: 30 + + - name: Summarize in job log + if: always() + run: cat e2e/a11y/results/report.md >> "$GITHUB_STEP_SUMMARY" || true diff --git a/testplanit/e2e/a11y/fixtures.setup.ts b/testplanit/e2e/a11y/fixtures.setup.ts new file mode 100644 index 000000000..1dbad4f15 --- /dev/null +++ b/testplanit/e2e/a11y/fixtures.setup.ts @@ -0,0 +1,230 @@ +import { test as setup, type APIRequestContext } from "@playwright/test"; +import fs from "fs"; +import path from "path"; +import { ApiHelper } from "../fixtures/api.fixture"; +import type { A11yFixtures } from "./routes"; + +/** + * Seeds a single richly-populated project so the scan hits real list/detail + * views instead of empty tables (empty tables hide most a11y issues). Resolved + * IDs are written to .a11y-fixtures.json, which routes.ts path builders read. + * + * Runs in the Playwright "setup" project; the "a11y" scan project depends on + * it. Uses the authenticated `request` context (admin storageState). We + * instantiate ApiHelper directly and never call cleanup() — this data must + * outlive the setup step so the scan can navigate to it. + */ + +const FIXTURES_FILE = path.join(__dirname, ".a11y-fixtures.json"); +const RESULTS_DIR = path.join(__dirname, "results"); + +setup("seed a11y fixture data", async ({ request, baseURL }) => { + setup.setTimeout(180_000); + const base = baseURL || "http://localhost:3002"; + const api = new ApiHelper(request, base); + + const userId = await api.getCurrentUserId(); + const stamp = Date.now(); + + // Project + folders + const projectId = await api.createProject(`A11y Audit ${stamp}`); + const rootFolderId = await api.getRootFolderId(projectId); + const folderId = await api.createFolder( + projectId, + "Authentication", + rootFolderId + ); + await api.createFolder(projectId, "Dashboard", rootFolderId); + + // Enough cases that the grid has real rows to render + const caseNames = Array.from( + { length: 14 }, + (_, i) => `A11y sample case ${i + 1}` + ); + const caseIds = await api.createTestCasesBatch( + projectId, + folderId, + caseNames + ); + const caseId = caseIds[0]; + const caseId2 = caseIds[1]; + + // Tag + link to a case so tag views are populated + let tagId = 0; + try { + tagId = await api.createTag(`a11y-tag-${stamp}`); + await api.addTagToTestCase(caseId, tagId); + } catch { + /* tagging is non-critical */ + } + + // Test run with cases + mixed statuses/results + const runId = await api.createTestRun(projectId, `A11y sample run ${stamp}`); + const trcIds = await api.addTestCasesToTestRun(runId, caseIds.slice(0, 8)); + const statusIds: number[] = []; + for (const t of ["passed", "failed", "blocked"] as const) { + try { + statusIds.push(await api.getStatusId(t)); + } catch { + /* status type may not be seeded */ + } + } + if (statusIds.length > 0) { + for (let i = 0; i < trcIds.length; i++) { + const statusId = statusIds[i % statusIds.length]; + await api.setTestRunCaseStatus(trcIds[i], statusId).catch(() => {}); + await api + .createTestResult(runId, trcIds[i], statusId, { + notes: `Result ${i + 1}`, + }) + .catch(() => {}); + } + } + + // Milestone + session + let milestoneId = 0; + let sessionId = 0; + try { + milestoneId = await api.createMilestone( + projectId, + `A11y milestone ${stamp}`, + { isStarted: true } + ); + } catch { + /* non-critical */ + } + try { + sessionId = await api.createSession( + projectId, + `A11y session ${stamp}`, + milestoneId ? { milestoneId } : undefined + ); + } catch { + /* non-critical */ + } + + // Dataset (drives the settings/datasets/[dataSetId] detail route). + let datasetId: number | undefined; + try { + const resp = await request.post(`${base}/api/model/dataSet/create`, { + data: { + data: { + name: `A11y dataset ${stamp}`, + isShared: true, + project: { connect: { id: projectId } }, + createdBy: { connect: { id: userId } }, + }, + }, + }); + if (resp.ok()) { + datasetId = (await resp.json()).data.id; + } else { + console.warn( + `[a11y] dataset create failed (${resp.status()}): ${await resp.text()}` + ); + } + } catch (e) { + console.warn(`[a11y] dataset create threw: ${String(e)}`); + } + + // Public share link for the case (best effort — drives /share/[shareKey]) + let shareKey: string | undefined = randomShareKey(); + try { + const resp = await request.post(`${base}/api/model/shareLink/create`, { + data: { + data: { + shareKey, + entityType: "TEST_CASE", + entityId: String(caseId), + mode: "PUBLIC", + isRevoked: false, + isDeleted: false, + createdBy: { connect: { id: userId } }, + project: { connect: { id: projectId } }, + }, + }, + }); + if (!resp.ok()) { + console.warn( + `[a11y] share link create failed (${resp.status()}): ${await resp.text()}` + ); + shareKey = undefined; + } + } catch (e) { + console.warn(`[a11y] share link create threw: ${String(e)}`); + shareKey = undefined; + } + + const fixtures: A11yFixtures = { + projectId, + caseId, + caseId2, + version: 1, + runId, + sessionId, + milestoneId, + folderId, + tagId, + userId, + shareKey, + datasetId, + }; + + fs.mkdirSync(RESULTS_DIR, { recursive: true }); + fs.writeFileSync(FIXTURES_FILE, JSON.stringify(fixtures, null, 2)); + console.log(`[a11y] fixtures written: ${JSON.stringify(fixtures)}`); + + // Populate Elasticsearch so search-driven UI has results. Best effort: the + // grids render from Postgres regardless, so a slow/absent ES never blocks. + await reindexAndWait(request, base); +}); + +function randomShareKey(): string { + // 40 hex chars — within ShareLink's @length(32, 64) constraint. + const chars = "0123456789abcdef"; + let key = ""; + for (let i = 0; i < 40; i++) + key += chars[Math.floor(Math.random() * chars.length)]; + return key; +} + +async function reindexAndWait( + request: APIRequestContext, + base: string +): Promise { + try { + const enqueue = await request.post( + `${base}/api/admin/elasticsearch/reindex`, + { + data: { entityType: "all" }, + } + ); + console.log(`[a11y] reindex enqueue: ${enqueue.status()}`); + if (!enqueue.ok()) return; + + const deadline = Date.now() + 45_000; + while (Date.now() < deadline) { + const status = await request.get( + `${base}/api/admin/elasticsearch/reindex` + ); + if (status.ok()) { + const json = await status.json(); + const cases = (json.indices || []).find((i: { name: string }) => + /repository-cases/.test(i.name) + ); + if (cases && cases.docs > 0) { + console.log( + `[a11y] ES reindex populated repository-cases (${cases.docs} docs)` + ); + return; + } + } + await new Promise((r) => setTimeout(r, 3000)); + } + console.log( + "[a11y] ES reindex did not confirm doc count within timeout — continuing" + ); + } catch (e) { + console.warn(`[a11y] reindex step skipped: ${String(e)}`); + } +} diff --git a/testplanit/e2e/a11y/playwright.config.ts b/testplanit/e2e/a11y/playwright.config.ts new file mode 100644 index 000000000..4dfbe40c1 --- /dev/null +++ b/testplanit/e2e/a11y/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "path"; +import baseConfig from "../playwright.config"; + +/** + * Accessibility-scan Playwright config. Reuses the base e2e config's wiring — + * `globalSetup` (reset+seed DB, log in as admin → storageState, spawn BullMQ + * workers), `globalTeardown` (stop workers), `webServer`, timeouts and `use` + * (those are absolute paths / plain values, safe to spread) — but swaps in: + * - testDir = this directory (so only the a11y specs run) + * - a `setup` project that seeds rich fixture data, gated before the scan + * - its own report/output dirs so it never clobbers the main e2e artifacts + */ + +const E2E_PORT = process.env.E2E_PORT || "3002"; +const baseURL = process.env.E2E_BASE_URL || `http://localhost:${E2E_PORT}`; +const authFile = path.join(__dirname, "..", ".auth", "admin.json"); + +export default defineConfig({ + ...baseConfig, + testDir: __dirname, + outputDir: path.join(__dirname, "test-results"), + + reporter: [ + ["list"], + ["html", { outputFolder: path.join(__dirname, "playwright-report"), open: "never" }], + ], + + projects: [ + { + name: "setup", + testMatch: /fixtures\.setup\.ts/, + use: { ...devices["Desktop Chrome"], baseURL, storageState: authFile }, + }, + { + name: "a11y", + testMatch: /scan\.spec\.ts/, + dependencies: ["setup"], + use: { ...devices["Desktop Chrome"], baseURL, storageState: authFile }, + }, + ], +}); diff --git a/testplanit/e2e/a11y/routes.ts b/testplanit/e2e/a11y/routes.ts new file mode 100644 index 000000000..329f7c7d8 --- /dev/null +++ b/testplanit/e2e/a11y/routes.ts @@ -0,0 +1,192 @@ +import type { Page } from "@playwright/test"; + +/** + * IDs resolved at runtime by fixtures.setup.ts and written to + * .a11y-fixtures.json. Route `path` builders read from this so dynamic-segment + * routes point at real, populated, seeded entities instead of guessed IDs. + */ +export interface A11yFixtures { + projectId: number; + caseId: number; + caseId2: number; + version: number; + runId: number; + sessionId: number; + milestoneId: number; + folderId: number; + tagId: number; + userId: string; + shareKey?: string; + datasetId?: number; + providerId?: string; +} + +/** + * An interactive state to scan in addition to the page's initial render. + * Implemented defensively in scan.spec.ts — a state that can't be reached is + * recorded as "not reachable", never a hard failure. + */ +export type InteractiveState = "dialog" | "menu"; + +export interface A11yRoute { + /** Unique kebab slug — also the per-route results filename. */ + name: string; + /** Feature area, used for grouping in the report. */ + group: string; + /** Locale-less path; the scanner prefixes the locale (e.g. "/en-US"). */ + path: (f: A11yFixtures) => string; + /** When false, the route is scanned in a fresh unauthenticated context. */ + authRequired: boolean; + /** CSS selector that must appear before scanning (proves the page rendered). */ + sanity?: string; + /** Fixture keys this route needs; if any is missing the route is skipped. */ + needs?: (keyof A11yFixtures)[]; + /** This page may redirect for a logged-in admin — record the landed URL. */ + mayRedirect?: boolean; + /** Cheap interactive states to additionally scan on this route. */ + interactions?: InteractiveState[]; + /** Optional extra wait (ms) for chart/editor-heavy pages to settle. */ + settleMs?: number; +} + +// Sanity selectors kept intentionally loose: a top-level landmark proves the +// shell rendered without coupling the scan to specific feature markup. +const APP_SHELL = "main, [role='main'], body"; +const FORM = "form, input, button"; + +export const routes: A11yRoute[] = [ + // ----- Public / auth (scanned logged-out) ----- + { name: "signin", group: "Auth", path: () => "/signin", authRequired: false, sanity: FORM }, + { name: "signup", group: "Auth", path: () => "/signup", authRequired: false, sanity: FORM }, + { + name: "verify-email", + group: "Auth", + path: () => "/verify-email", + authRequired: false, + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "share-public", + group: "Auth", + path: (f) => `/share/${f.shareKey}`, + authRequired: false, + needs: ["shareKey"], + sanity: APP_SHELL, + }, + + // ----- Special-state auth pages (may redirect for a normal admin session) ----- + { name: "two-factor-setup", group: "Auth (gated)", path: () => "/auth/two-factor-setup", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, + { name: "two-factor-verify", group: "Auth (gated)", path: () => "/auth/two-factor-verify", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, + { name: "force-change-password", group: "Auth (gated)", path: () => "/auth/force-change-password", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, + { name: "account-link-sso", group: "Auth (gated)", path: () => "/account/link-sso", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, + { name: "trial-expired", group: "Auth (gated)", path: () => "/trial-expired", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, + + // ----- Dashboard / global ----- + { name: "home", group: "Dashboard", path: () => "/", authRequired: true, sanity: APP_SHELL, mayRedirect: true }, + { name: "reviews", group: "Dashboard", path: () => "/reviews", authRequired: true, sanity: APP_SHELL }, + { name: "issues-global", group: "Dashboard", path: () => "/issues", authRequired: true, sanity: APP_SHELL }, + { name: "tags-global", group: "Dashboard", path: () => "/tags", authRequired: true, sanity: APP_SHELL }, + { name: "tag-global-detail", group: "Dashboard", path: (f) => `/tags/${f.tagId}`, authRequired: true, needs: ["tagId"], sanity: APP_SHELL, mayRedirect: true }, + { name: "users-global", group: "Dashboard", path: () => "/users", authRequired: true, sanity: APP_SHELL }, + { name: "user-profile", group: "Dashboard", path: (f) => `/users/profile/${f.userId}`, authRequired: true, needs: ["userId"], sanity: APP_SHELL }, + + // ----- Projects ----- + { name: "projects-list", group: "Projects", path: () => "/projects", authRequired: true, sanity: APP_SHELL, interactions: ["dialog"] }, + { name: "project-overview", group: "Projects", path: (f) => `/projects/overview/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "project-documentation", group: "Projects", path: (f) => `/projects/documentation/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "project-issues", group: "Projects", path: (f) => `/projects/issues/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + + // ----- Repository / test cases ----- + { name: "repository-list", group: "Repository", path: (f) => `/projects/repository/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog", "menu"] }, + { name: "case-detail", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/${f.caseId}`, authRequired: true, needs: ["projectId", "caseId"], sanity: APP_SHELL, settleMs: 1500, interactions: ["menu"] }, + { name: "case-detail-2", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/${f.caseId2}`, authRequired: true, needs: ["projectId", "caseId2"], sanity: APP_SHELL, settleMs: 1500 }, + { name: "case-version", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/${f.caseId}/${f.version}`, authRequired: true, needs: ["projectId", "caseId", "version"], sanity: APP_SHELL, mayRedirect: true }, + { name: "case-global", group: "Repository", path: (f) => `/case/${f.caseId}`, authRequired: true, needs: ["caseId"], sanity: APP_SHELL, mayRedirect: true }, + { name: "repository-duplicates", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/duplicates`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + + // ----- Test runs ----- + { name: "runs-list", group: "Test Runs", path: (f) => `/projects/runs/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog"] }, + { name: "run-detail", group: "Test Runs", path: (f) => `/projects/runs/${f.projectId}/${f.runId}`, authRequired: true, needs: ["projectId", "runId"], sanity: APP_SHELL, settleMs: 1000, interactions: ["menu"] }, + + // ----- Sessions ----- + { name: "sessions-list", group: "Sessions", path: (f) => `/projects/sessions/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog"] }, + { name: "session-detail", group: "Sessions", path: (f) => `/projects/sessions/${f.projectId}/${f.sessionId}`, authRequired: true, needs: ["projectId", "sessionId"], sanity: APP_SHELL, settleMs: 1000 }, + { name: "session-version", group: "Sessions", path: (f) => `/projects/sessions/${f.projectId}/${f.sessionId}/${f.version}`, authRequired: true, needs: ["projectId", "sessionId", "version"], sanity: APP_SHELL, mayRedirect: true }, + + // ----- Milestones ----- + { name: "milestones-list", group: "Milestones", path: (f) => `/projects/milestones/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog"] }, + { name: "milestone-detail", group: "Milestones", path: (f) => `/projects/milestones/${f.projectId}/${f.milestoneId}`, authRequired: true, needs: ["projectId", "milestoneId"], sanity: APP_SHELL }, + { name: "milestone-global", group: "Milestones", path: (f) => `/milestone/${f.milestoneId}`, authRequired: true, needs: ["milestoneId"], sanity: APP_SHELL, mayRedirect: true }, + + // ----- Project settings ----- + { name: "settings-integrations", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/integrations`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-ai-models", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/ai-models`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-parameters", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/parameters`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-datasets", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/datasets`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-dataset-detail", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/datasets/${f.datasetId}`, authRequired: true, needs: ["projectId", "datasetId"], sanity: APP_SHELL, mayRedirect: true }, + { name: "settings-junit", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/junit`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-quickscript", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/quickscript`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-webhooks", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/webhooks`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-shares", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/shares`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "settings-advanced", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/advanced`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + + // ----- Shared steps ----- + { name: "shared-steps", group: "Shared Steps", path: (f) => `/projects/shared-steps/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "shared-steps-duplicates", group: "Shared Steps", path: (f) => `/projects/shared-steps/${f.projectId}/step-duplicates`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + + // ----- Project tags / reports ----- + { name: "project-tags", group: "Tags & Reports", path: (f) => `/projects/tags/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { name: "project-tag-detail", group: "Tags & Reports", path: (f) => `/projects/tags/${f.projectId}/${f.tagId}`, authRequired: true, needs: ["projectId", "tagId"], sanity: APP_SHELL, mayRedirect: true }, + { name: "project-reports", group: "Tags & Reports", path: (f) => `/projects/reports/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, settleMs: 1500 }, + + // ----- Admin ----- + { name: "admin-home", group: "Admin", path: () => "/admin", authRequired: true, sanity: APP_SHELL, mayRedirect: true }, + { name: "admin-users", group: "Admin", path: () => "/admin/users", authRequired: true, sanity: APP_SHELL, interactions: ["dialog", "menu"] }, + { name: "admin-groups", group: "Admin", path: () => "/admin/groups", authRequired: true, sanity: APP_SHELL }, + { name: "admin-roles", group: "Admin", path: () => "/admin/roles", authRequired: true, sanity: APP_SHELL }, + { name: "admin-sso", group: "Admin", path: () => "/admin/sso", authRequired: true, sanity: APP_SHELL }, + { name: "admin-integrations", group: "Admin", path: () => "/admin/integrations", authRequired: true, sanity: APP_SHELL }, + { name: "admin-issues", group: "Admin", path: () => "/admin/issues", authRequired: true, sanity: APP_SHELL }, + { name: "admin-code-repositories", group: "Admin", path: () => "/admin/code-repositories", authRequired: true, sanity: APP_SHELL }, + { name: "admin-api-tokens", group: "Admin", path: () => "/admin/api-tokens", authRequired: true, sanity: APP_SHELL, interactions: ["dialog"] }, + { name: "admin-llm", group: "Admin", path: () => "/admin/llm", authRequired: true, sanity: APP_SHELL }, + { name: "admin-prompts", group: "Admin", path: () => "/admin/prompts", authRequired: true, sanity: APP_SHELL }, + { name: "admin-quickscripts", group: "Admin", path: () => "/admin/quickscripts", authRequired: true, sanity: APP_SHELL }, + { name: "admin-elasticsearch", group: "Admin", path: () => "/admin/elasticsearch", authRequired: true, sanity: APP_SHELL }, + { name: "admin-fields", group: "Admin", path: () => "/admin/fields", authRequired: true, sanity: APP_SHELL, interactions: ["dialog"] }, + { name: "admin-configurations", group: "Admin", path: () => "/admin/configurations", authRequired: true, sanity: APP_SHELL }, + { name: "admin-security", group: "Admin", path: () => "/admin/security", authRequired: true, sanity: APP_SHELL }, + { name: "admin-app-config", group: "Admin", path: () => "/admin/app-config", authRequired: true, sanity: APP_SHELL }, + { name: "admin-statuses", group: "Admin", path: () => "/admin/statuses", authRequired: true, sanity: APP_SHELL }, + { name: "admin-tags", group: "Admin", path: () => "/admin/tags", authRequired: true, sanity: APP_SHELL }, + { name: "admin-shares", group: "Admin", path: () => "/admin/shares", authRequired: true, sanity: APP_SHELL }, + { name: "admin-notifications", group: "Admin", path: () => "/admin/notifications", authRequired: true, sanity: APP_SHELL }, + { name: "admin-workflows", group: "Admin", path: () => "/admin/workflows", authRequired: true, sanity: APP_SHELL }, + { name: "admin-audit-logs", group: "Admin", path: () => "/admin/audit-logs", authRequired: true, sanity: APP_SHELL }, + { name: "admin-trash", group: "Admin", path: () => "/admin/trash", authRequired: true, sanity: APP_SHELL }, + { name: "admin-imports", group: "Admin", path: () => "/admin/imports", authRequired: true, sanity: APP_SHELL }, + { name: "admin-projects", group: "Admin", path: () => "/admin/projects", authRequired: true, sanity: APP_SHELL }, + { name: "admin-milestones", group: "Admin", path: () => "/admin/milestones", authRequired: true, sanity: APP_SHELL }, + { name: "admin-queues", group: "Admin", path: () => "/admin/queues", authRequired: true, sanity: APP_SHELL }, + { name: "admin-reports", group: "Admin", path: () => "/admin/reports", authRequired: true, sanity: APP_SHELL }, + { + name: "admin-sso-saml", + group: "Admin", + path: (f) => `/admin/sso/saml/${f.providerId}`, + authRequired: true, + needs: ["providerId"], + sanity: APP_SHELL, + mayRedirect: true, + }, + + // ----- Docs ----- + { name: "docs-api", group: "Docs", path: () => "/docs/api", authRequired: true, sanity: APP_SHELL }, +]; + +/** Helper for the spec/aggregator: which routes are interactive-state scanned. */ +export function hasInteractions(route: A11yRoute): boolean { + return !!route.interactions && route.interactions.length > 0; +} + +export type { Page }; diff --git a/testplanit/e2e/a11y/run.ts b/testplanit/e2e/a11y/run.ts new file mode 100644 index 000000000..9af6a354b --- /dev/null +++ b/testplanit/e2e/a11y/run.ts @@ -0,0 +1,39 @@ +/** + * Orchestrates an a11y scan: runs the Playwright scan project, then the + * aggregator, and propagates a non-zero exit when the scan or (in strict mode) + * the aggregator fails — so CI gets a real signal. + * + * pnpm a11y:scan # full scan, report mode + * pnpm a11y:scan -- --route=admin-users # single route (setup still runs) + * A11Y_STRICT=on pnpm a11y:scan # fail on serious/critical WCAG issues + * + * Spawns local binaries directly (not via `pnpm exec`) to avoid pnpm's + * run-time dependency pruning, and defaults E2E_PROD=on (production build) and + * the Elasticsearch node so search-driven views are populated. + */ +import { spawnSync } from "child_process"; +import path from "path"; + +const cwd = path.resolve(__dirname, "..", ".."); // -> testplanit/ +const bin = (name: string) => path.join(cwd, "node_modules", ".bin", name); + +const routeArg = process.argv.find((a) => a.startsWith("--route=")); +const route = routeArg ? routeArg.split("=")[1] : process.env.A11Y_ROUTE; + +const env: NodeJS.ProcessEnv = { ...process.env }; +env.E2E_PROD = env.E2E_PROD || "on"; +env.ELASTICSEARCH_NODE = env.ELASTICSEARCH_NODE || "http://192.168.1.8:9221"; +env.pnpm_config_verify_deps_before_run = "false"; + +const playwrightArgs = ["test", "--config", "e2e/a11y/playwright.config.ts"]; +if (route) { + // Keep the setup project's test in the selection so fixtures still seed. + playwrightArgs.push("-g", `(seed a11y fixture data|${route})`); + console.log(`[a11y] single-route run: ${route}`); +} + +const scan = spawnSync(bin("playwright"), playwrightArgs, { cwd, env, stdio: "inherit" }); +// Always aggregate, even if the scan reported failures (strict mode). +const agg = spawnSync(bin("tsx"), ["e2e/a11y/aggregate.ts"], { cwd, env, stdio: "inherit" }); + +process.exit((scan.status ?? 1) || (agg.status ?? 0)); diff --git a/testplanit/e2e/a11y/scan.spec.ts b/testplanit/e2e/a11y/scan.spec.ts new file mode 100644 index 000000000..a62df03c8 --- /dev/null +++ b/testplanit/e2e/a11y/scan.spec.ts @@ -0,0 +1,268 @@ +import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import fs from "fs"; +import path from "path"; +import { stubBellSSE } from "../fixtures"; +import { routes, type A11yRoute, type A11yFixtures, type InteractiveState } from "./routes"; +import { primaryCriterion, isWcagViolation } from "./wcag"; + +/** + * Parameterized accessibility scan. One test per route in routes.ts. Each test + * navigates, waits for the page to settle, runs axe-core against WCAG 2.0/2.1/ + * 2.2 A+AA (plus best-practice, reported separately), optionally re-scans a + * couple of cheap interactive states (open dialog / open menu), and writes a + * raw per-route JSON result to results/. aggregate.ts turns those into the + * WCAG-grouped Markdown report. + * + * Pass/fail: report-only by default (never fails on violations). Set + * A11Y_STRICT=on (or CI=strict) to fail a route on any serious/critical WCAG + * violation. + */ + +const RESULTS_DIR = path.join(__dirname, "results"); +const FIXTURES_FILE = path.join(__dirname, ".a11y-fixtures.json"); +const LOCALE = "en-US"; + +// The full WCAG 2.0/2.1/2.2 A + AA stack, plus best-practice (split out below). +const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]; + +const STRICT = process.env.A11Y_STRICT === "on" || process.env.CI === "strict"; + +const fixtures: A11yFixtures | null = fs.existsSync(FIXTURES_FILE) + ? JSON.parse(fs.readFileSync(FIXTURES_FILE, "utf8")) + : null; + +interface ViolationNode { + target: string[]; + html: string; + failureSummary: string; +} +interface Violation { + id: string; + impact: string | null; + description: string; + help: string; + helpUrl: string; + tags: string[]; + criterion: string; // "1.4.3 Contrast (Minimum)" + criterionKey: string; + nodeCount: number; + nodes: ViolationNode[]; +} +interface StateResult { + state: string; // "initial" | "dialog" | "menu" + reached: boolean; + url: string; + wcagViolations: Violation[]; + bestPracticeViolations: Violation[]; +} +interface RouteResult { + name: string; + group: string; + requestedPath: string; + finalUrl: string; + authRequired: boolean; + scannedAt: string; + status: "scanned" | "skipped" | "error"; + note?: string; + states: StateResult[]; +} + +fs.mkdirSync(RESULTS_DIR, { recursive: true }); + +function serialize(violations: AxeViolation[]): Violation[] { + return violations.map((v) => { + const sc = primaryCriterion(v.tags); + return { + id: v.id, + impact: v.impact ?? null, + description: v.description, + help: v.help, + helpUrl: v.helpUrl, + tags: v.tags, + criterion: sc.num === "—" ? sc.name : `${sc.num} ${sc.name}`, + criterionKey: sc.key, + nodeCount: v.nodes.length, + nodes: v.nodes.slice(0, 5).map((n) => ({ + target: (n.target as string[]).map(String), + html: (n.html || "").slice(0, 400), + failureSummary: n.failureSummary || "", + })), + }; + }); +} + +// Minimal structural types so we don't depend on axe's exported types. +type AxeNode = { target: unknown[]; html: string; failureSummary?: string }; +type AxeViolation = { + id: string; + impact: string | null; + description: string; + help: string; + helpUrl: string; + tags: string[]; + nodes: AxeNode[]; +}; + +async function runAxe(page: Page): Promise<{ wcag: Violation[]; best: Violation[] }> { + const results = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze(); + const all = results.violations as unknown as AxeViolation[]; + return { + wcag: serialize(all.filter((v) => isWcagViolation(v.tags))), + best: serialize(all.filter((v) => !isWcagViolation(v.tags))), + }; +} + +async function settle(page: Page, route: A11yRoute): Promise { + // Bounded networkidle (SSE is stubbed, so this resolves) + a sanity selector + // that proves the shell rendered. Neither is allowed to hang the scan. + await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {}); + if (route.sanity) { + await page.waitForSelector(route.sanity, { state: "attached", timeout: 12000 }).catch(() => {}); + } + await dismissOnboardingOverlay(page); + if (route.settleMs) await page.waitForTimeout(route.settleMs); +} + +async function dismissOnboardingOverlay(page: Page): Promise { + const overlay = page.locator('[data-name="nextstep-overlay"]'); + if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) { + await page.keyboard.press("Escape").catch(() => {}); + await page.waitForTimeout(200); + } +} + +/** Best-effort: open the page's primary dialog. Returns whether one opened. */ +async function openDialog(page: Page): Promise { + const trigger = page + .getByRole("button", { name: /add|new|create|invite|connect|upload|import|generate/i }) + .first(); + if (!(await trigger.isVisible({ timeout: 1500 }).catch(() => false))) return false; + await trigger.click({ timeout: 2000 }).catch(() => {}); + const dialog = page.locator('[role="dialog"]').first(); + return await dialog.isVisible({ timeout: 3000 }).catch(() => false); +} + +/** Best-effort: open a row/action menu. Returns whether one opened. */ +async function openMenu(page: Page): Promise { + const trigger = page + .locator( + 'button[aria-haspopup="menu"], [data-testid$="actions-menu"], [data-testid$="-menu-trigger"], button:has(svg.lucide-ellipsis-vertical), button:has(svg.lucide-ellipsis)' + ) + .first(); + if (!(await trigger.isVisible({ timeout: 1500 }).catch(() => false))) return false; + await trigger.click({ timeout: 2000 }).catch(() => {}); + const menu = page.locator('[role="menu"]').first(); + return await menu.isVisible({ timeout: 2500 }).catch(() => false); +} + +async function scanInteraction(page: Page, kind: InteractiveState): Promise { + const reached = kind === "dialog" ? await openDialog(page) : await openMenu(page); + if (!reached) { + return { state: kind, reached: false, url: page.url(), wcagViolations: [], bestPracticeViolations: [] }; + } + const { wcag, best } = await runAxe(page); + await page.keyboard.press("Escape").catch(() => {}); + await page.waitForTimeout(200); + return { state: kind, reached: true, url: page.url(), wcagViolations: wcag, bestPracticeViolations: best }; +} + +function missingFixture(route: A11yRoute): keyof A11yFixtures | null { + if (!route.needs) return null; + if (!fixtures) return route.needs[0] ?? null; + for (const key of route.needs) { + const val = fixtures[key]; + if (val === undefined || val === null || val === "" || val === 0) return key; + } + return null; +} + +function writeResult(result: RouteResult): void { + fs.writeFileSync(path.join(RESULTS_DIR, `${result.name}.json`), JSON.stringify(result, null, 2)); +} + +for (const route of routes) { + test(`a11y: ${route.group} › ${route.name}`, async ({ page, browser, baseURL }) => { + test.setTimeout(90_000); + + const result: RouteResult = { + name: route.name, + group: route.group, + requestedPath: "", + finalUrl: "", + authRequired: route.authRequired, + scannedAt: new Date().toISOString(), + status: "scanned", + states: [], + }; + + // Skip routes whose required seeded entity is unavailable — recorded, not silent. + const missing = missingFixture(route); + if (missing) { + result.status = "skipped"; + result.note = `Missing seeded fixture "${String(missing)}" — route not scanned.`; + writeResult(result); + test.skip(true, result.note); + return; + } + + const relPath = route.path(fixtures ?? ({} as A11yFixtures)); + const url = `/${LOCALE}${relPath}`; + result.requestedPath = url; + + // Unauthenticated routes scan in a throwaway context with no storageState. + let ctx: BrowserContext | null = null; + let scanPage: Page = page; + if (!route.authRequired) { + ctx = await browser.newContext({ storageState: undefined }); + scanPage = await ctx.newPage(); + } + await stubBellSSE(scanPage); + + try { + const resp = await scanPage.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await settle(scanPage, route); + result.finalUrl = scanPage.url(); + + if (resp && resp.status() >= 400) { + result.note = `HTTP ${resp.status()} on navigation`; + } + if (route.mayRedirect && !result.finalUrl.includes(relPath.split("?")[0])) { + result.note = `Redirected to ${new URL(result.finalUrl).pathname}`; + } + + const initial = await runAxe(scanPage); + result.states.push({ + state: "initial", + reached: true, + url: result.finalUrl, + wcagViolations: initial.wcag, + bestPracticeViolations: initial.best, + }); + + for (const kind of route.interactions ?? []) { + const s = await scanInteraction(scanPage, kind).catch(() => null); + if (s) result.states.push(s); + } + } catch (e) { + result.status = "error"; + result.note = `Scan error: ${String(e).slice(0, 300)}`; + } finally { + if (ctx) await ctx.close(); + } + + writeResult(result); + + if (STRICT && result.status === "scanned") { + const blocking = result.states + .flatMap((s) => s.wcagViolations) + .filter((v) => v.impact === "serious" || v.impact === "critical"); + expect( + blocking, + `${route.name}: ${blocking.length} serious/critical WCAG violation(s): ${[ + ...new Set(blocking.map((b) => `${b.id} (${b.criterion})`)), + ].join(", ")}` + ).toEqual([]); + } + }); +} diff --git a/testplanit/e2e/a11y/wcag.ts b/testplanit/e2e/a11y/wcag.ts new file mode 100644 index 000000000..8bd37ddd7 --- /dev/null +++ b/testplanit/e2e/a11y/wcag.ts @@ -0,0 +1,132 @@ +/** + * Maps axe-core success-criterion tags (e.g. "wcag143") to a human-readable + * WCAG success criterion ("1.4.3 Contrast (Minimum)") plus conformance level. + * + * This is what lets the aggregated report group findings by SC number, which + * is the shape a VPAT 2.5 conformance table needs. axe attaches one or more + * `wcag

` tags to each rule; we resolve the primary one here. + * + * Reference: https://www.w3.org/WAI/WCAG22/quickref/ and the axe-core tag list. + */ + +export type WcagLevel = "A" | "AA" | "AAA"; + +export interface SuccessCriterion { + /** Dotted SC number, e.g. "1.4.3" */ + num: string; + /** Official SC name, e.g. "Contrast (Minimum)" */ + name: string; + level: WcagLevel; +} + +/** Keyed by the exact axe tag string. */ +export const WCAG_TAG_TO_SC: Record = { + // 1. Perceivable + wcag111: { num: "1.1.1", name: "Non-text Content", level: "A" }, + wcag121: { num: "1.2.1", name: "Audio-only and Video-only (Prerecorded)", level: "A" }, + wcag122: { num: "1.2.2", name: "Captions (Prerecorded)", level: "A" }, + wcag123: { num: "1.2.3", name: "Audio Description or Media Alternative", level: "A" }, + wcag124: { num: "1.2.4", name: "Captions (Live)", level: "AA" }, + wcag125: { num: "1.2.5", name: "Audio Description (Prerecorded)", level: "AA" }, + wcag131: { num: "1.3.1", name: "Info and Relationships", level: "A" }, + wcag132: { num: "1.3.2", name: "Meaningful Sequence", level: "A" }, + wcag133: { num: "1.3.3", name: "Sensory Characteristics", level: "A" }, + wcag134: { num: "1.3.4", name: "Orientation", level: "AA" }, + wcag135: { num: "1.3.5", name: "Identify Input Purpose", level: "AA" }, + wcag141: { num: "1.4.1", name: "Use of Color", level: "A" }, + wcag142: { num: "1.4.2", name: "Audio Control", level: "A" }, + wcag143: { num: "1.4.3", name: "Contrast (Minimum)", level: "AA" }, + wcag144: { num: "1.4.4", name: "Resize Text", level: "AA" }, + wcag145: { num: "1.4.5", name: "Images of Text", level: "AA" }, + wcag1410: { num: "1.4.10", name: "Reflow", level: "AA" }, + wcag1411: { num: "1.4.11", name: "Non-text Contrast", level: "AA" }, + wcag1412: { num: "1.4.12", name: "Text Spacing", level: "AA" }, + wcag1413: { num: "1.4.13", name: "Content on Hover or Focus", level: "AA" }, + + // 2. Operable + wcag211: { num: "2.1.1", name: "Keyboard", level: "A" }, + wcag212: { num: "2.1.2", name: "No Keyboard Trap", level: "A" }, + wcag213: { num: "2.1.3", name: "Keyboard (No Exception)", level: "AAA" }, + wcag214: { num: "2.1.4", name: "Character Key Shortcuts", level: "A" }, + wcag221: { num: "2.2.1", name: "Timing Adjustable", level: "A" }, + wcag222: { num: "2.2.2", name: "Pause, Stop, Hide", level: "A" }, + wcag224: { num: "2.2.4", name: "Interruptions", level: "AAA" }, + wcag241: { num: "2.4.1", name: "Bypass Blocks", level: "A" }, + wcag242: { num: "2.4.2", name: "Page Titled", level: "A" }, + wcag243: { num: "2.4.3", name: "Focus Order", level: "A" }, + wcag244: { num: "2.4.4", name: "Link Purpose (In Context)", level: "A" }, + wcag245: { num: "2.4.5", name: "Multiple Ways", level: "AA" }, + wcag246: { num: "2.4.6", name: "Headings and Labels", level: "AA" }, + wcag247: { num: "2.4.7", name: "Focus Visible", level: "AA" }, + wcag2411: { num: "2.4.11", name: "Focus Not Obscured (Minimum)", level: "AA" }, + wcag251: { num: "2.5.1", name: "Pointer Gestures", level: "A" }, + wcag252: { num: "2.5.2", name: "Pointer Cancellation", level: "A" }, + wcag253: { num: "2.5.3", name: "Label in Name", level: "A" }, + wcag254: { num: "2.5.4", name: "Motion Actuation", level: "A" }, + wcag257: { num: "2.5.7", name: "Dragging Movements", level: "AA" }, + wcag258: { num: "2.5.8", name: "Target Size (Minimum)", level: "AA" }, + + // 3. Understandable + wcag311: { num: "3.1.1", name: "Language of Page", level: "A" }, + wcag312: { num: "3.1.2", name: "Language of Parts", level: "AA" }, + wcag321: { num: "3.2.1", name: "On Focus", level: "A" }, + wcag322: { num: "3.2.2", name: "On Input", level: "A" }, + wcag323: { num: "3.2.3", name: "Consistent Navigation", level: "AA" }, + wcag324: { num: "3.2.4", name: "Consistent Identification", level: "AA" }, + wcag325: { num: "3.2.6", name: "Consistent Help", level: "A" }, + wcag331: { num: "3.3.1", name: "Error Identification", level: "A" }, + wcag332: { num: "3.3.2", name: "Labels or Instructions", level: "A" }, + wcag333: { num: "3.3.3", name: "Error Suggestion", level: "AA" }, + wcag334: { num: "3.3.4", name: "Error Prevention (Legal, Financial, Data)", level: "AA" }, + wcag337: { num: "3.3.7", name: "Redundant Entry", level: "A" }, + wcag338: { num: "3.3.8", name: "Accessible Authentication (Minimum)", level: "AA" }, + + // 4. Robust + wcag411: { num: "4.1.1", name: "Parsing (obsolete)", level: "A" }, + wcag412: { num: "4.1.2", name: "Name, Role, Value", level: "A" }, + wcag413: { num: "4.1.3", name: "Status Messages", level: "AA" }, +}; + +/** Level/version tags that are not specific success criteria. */ +const NON_SC_TAGS = new Set([ + "wcag2a", + "wcag2aa", + "wcag2aaa", + "wcag21a", + "wcag21aa", + "wcag22aa", + "best-practice", + "ACT", + "experimental", + "review-item", +]); + +/** + * Resolve the primary success criterion for an axe violation from its tags. + * Returns a synthetic "best-practice" or "other" bucket when no SC tag exists. + */ +export function primaryCriterion(tags: string[]): SuccessCriterion & { key: string } { + for (const tag of tags) { + const sc = WCAG_TAG_TO_SC[tag]; + if (sc) return { ...sc, key: sc.num }; + } + // Unmapped wcag tag (new SC we don't have a name for yet) — derive a number. + const unmapped = tags.find((t) => /^wcag\d{3,4}$/.test(t) && !NON_SC_TAGS.has(t)); + if (unmapped) { + const digits = unmapped.replace("wcag", ""); + const num = + digits.length === 3 + ? `${digits[0]}.${digits[1]}.${digits[2]}` + : `${digits[0]}.${digits[1]}.${digits.slice(2)}`; + return { num, name: "(unmapped criterion)", level: "AA", key: num }; + } + if (tags.includes("best-practice")) { + return { num: "—", name: "Best Practice (non-WCAG)", level: "A", key: "best-practice" }; + } + return { num: "—", name: "Other", level: "A", key: "other" }; +} + +/** True when a violation maps to at least one real WCAG success criterion. */ +export function isWcagViolation(tags: string[]): boolean { + return tags.some((t) => WCAG_TAG_TO_SC[t] || (/^wcag\d{3,4}$/.test(t) && !NON_SC_TAGS.has(t))); +} diff --git a/testplanit/package.json b/testplanit/package.json index 1e7b6952d..a38693c53 100644 --- a/testplanit/package.json +++ b/testplanit/package.json @@ -29,6 +29,8 @@ "test:e2e:setup-db": "dotenv -e .env.e2e -- tsx e2e/setup-db.ts", "test:e2e:run": "pnpm test:e2e:setup-db && pnpm test:e2e", "test:e2e:prod": "pnpm build && dotenv -e .env.e2e -- cross-env E2E_PROD=on playwright test --config e2e/playwright.config.ts", + "a11y:scan": "dotenv -e .env.e2e -- tsx e2e/a11y/run.ts", + "a11y:report": "dotenv -e .env.e2e -- tsx e2e/a11y/aggregate.ts", "worker:notification": "dotenv -- tsx workers/notificationWorker.ts", "worker:email": "dotenv -- tsx workers/emailWorker.ts", "worker:forecast": "dotenv -- tsx workers/forecastWorker.ts", @@ -284,6 +286,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@babel/core": "^7.29.7", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", From 941fe5f737bdb4055d7f8739bca42a23d5aa0a2c Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 17:20:26 -0500 Subject: [PATCH 02/25] fix(a11y): resolve top shared-component WCAG violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the most widespread findings from the automated accessibility scan, fixing them at the shared-component root so the improvement applies across every page that uses them: - DataTable column-resize handle: add role="separator" + aria-orientation so its aria-label is valid ARIA (WCAG 4.1.2 aria-prohibited-attr) — clears 227 elements across 26 routes. - BreadcrumbComponent: make the tooltip trigger asChild so the folder link is no longer a focusable nested inside a @@ -724,6 +725,7 @@ const TipTapEditor: React.FC = ({ className="p-2" onClick={() => editor.chain().focus().toggleItalic().run()} data-testid="tiptap-italic" + aria-label={t("italic")} > @@ -734,6 +736,7 @@ const TipTapEditor: React.FC = ({ className="p-2" onClick={() => editor.chain().focus().toggleStrike().run()} data-testid="tiptap-strikethrough" + aria-label={t("strikethrough")} > @@ -744,6 +747,7 @@ const TipTapEditor: React.FC = ({ className="p-2" onClick={() => editor.chain().focus().toggleUnderline().run()} data-testid="tiptap-underline" + aria-label={t("underline")} > @@ -754,6 +758,7 @@ const TipTapEditor: React.FC = ({ className="p-2" onClick={() => editor.chain().focus().toggleCode().run()} data-testid="tiptap-code" + aria-label={t("code")} > @@ -767,6 +772,7 @@ const TipTapEditor: React.FC = ({ size="sm" className="p-2" data-testid="tiptap-heading-trigger" + aria-label={t("heading")} > {editor.isActive("heading", { level: 1 }) && } {editor.isActive("heading", { level: 2 }) && } diff --git a/testplanit/components/ui/slider.tsx b/testplanit/components/ui/slider.tsx index a0b44abd5..63d32b88b 100644 --- a/testplanit/components/ui/slider.tsx +++ b/testplanit/components/ui/slider.tsx @@ -6,21 +6,37 @@ import { cn } from "~/utils"; const Slider = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - - -)); +>( + ( + { + className, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + ...props + }, + ref + ) => ( + + + + + {/* Forward the accessible name onto the focusable thumb (role="slider"), + not just the root, so the control has a name for screen readers. */} + + + ) +); Slider.displayName = SliderPrimitive.Root.displayName; export { Slider }; diff --git a/testplanit/messages/en-US.json b/testplanit/messages/en-US.json index 70d6149ce..9f143f68c 100644 --- a/testplanit/messages/en-US.json +++ b/testplanit/messages/en-US.json @@ -690,6 +690,8 @@ }, "aria": { "userMenu": "User menu", + "selectProject": "Select project", + "togglePanel": "Toggle panel", "search": "Search", "helpMenu": "Help menu", "help": "Help", @@ -846,6 +848,12 @@ "noFilesDetected": "No files detected in drop" }, "editor": { + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "code": "Code", + "heading": "Heading", "setColor": "Set Color", "enterUrl": "Enter URL including https://", "uploadFile": "Upload File", From fb06ffaf48fe732aeffad0242a942e48fbefc13e Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 17:54:47 -0500 Subject: [PATCH 05/25] fix(a11y): name more icon controls and fix issue-title trigger semantics Continues labelling unnamed controls and fixes two structural ARIA issues: - TipTap toolbar: bullet/numbered list, blockquote, and table buttons get aria-labels (new common.editor keys). - UserNameCell: the name tooltip trigger is now asChild, so it no longer renders a

@@ -214,13 +215,14 @@ export function useIssueColumns({ return ( -
{plainText} -
+
diff --git a/testplanit/components/BreadcrumbComponent.tsx b/testplanit/components/BreadcrumbComponent.tsx index 64760aec0..048a07dcc 100644 --- a/testplanit/components/BreadcrumbComponent.tsx +++ b/testplanit/components/BreadcrumbComponent.tsx @@ -12,6 +12,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { Folders } from "lucide-react"; +import { useTranslations } from "next-intl"; import React from "react"; import { Link } from "~/lib/navigation"; @@ -34,6 +35,7 @@ const BreadcrumbComponent: React.FC = ({ onClick, isLastClickable = true, }) => { + const t = useTranslations("common.aria"); return ( @@ -69,6 +71,9 @@ const BreadcrumbComponent: React.FC = ({ href={`/projects/repository/${projectId}/?node=${folder.id}`} className="text-primary/50 cursor-pointer inline-flex items-center p-0 m-0 max-w-xs compact-button hover:underline" onClick={() => onClick && onClick(folder.id)} + aria-label={ + folder.text?.trim() ? folder.text : t("folder") + } > {folder.text} diff --git a/testplanit/components/tables/UserNameCell.tsx b/testplanit/components/tables/UserNameCell.tsx index 7d3e0f268..33c09fc0d 100644 --- a/testplanit/components/tables/UserNameCell.tsx +++ b/testplanit/components/tables/UserNameCell.tsx @@ -65,13 +65,10 @@ export const UserNameCell: React.FC = ({ image={user?.image ?? ""} /> - +
= ({ className="p-2" onClick={() => editor.chain().focus().toggleBulletList().run()} data-testid="tiptap-bullet-list" + aria-label={t("bulletList")} > @@ -867,6 +868,7 @@ const TipTapEditor: React.FC = ({ className="p-2" onClick={() => editor.chain().focus().toggleOrderedList().run()} data-testid="tiptap-ordered-list" + aria-label={t("orderedList")} > @@ -877,6 +879,7 @@ const TipTapEditor: React.FC = ({ className="p-2" onClick={() => editor.chain().focus().toggleBlockquote().run()} data-testid="tiptap-blockquote" + aria-label={t("blockquote")} > @@ -888,6 +891,7 @@ const TipTapEditor: React.FC = ({ size="sm" className="p-2" data-testid="tiptap-table-trigger" + aria-label={t("table.insertTable")} > diff --git a/testplanit/messages/en-US.json b/testplanit/messages/en-US.json index 9f143f68c..04d3bf439 100644 --- a/testplanit/messages/en-US.json +++ b/testplanit/messages/en-US.json @@ -692,6 +692,7 @@ "userMenu": "User menu", "selectProject": "Select project", "togglePanel": "Toggle panel", + "folder": "Folder", "search": "Search", "helpMenu": "Help menu", "help": "Help", @@ -854,6 +855,9 @@ "strikethrough": "Strikethrough", "code": "Code", "heading": "Heading", + "bulletList": "Bullet list", + "orderedList": "Numbered list", + "blockquote": "Blockquote", "setColor": "Set Color", "enterUrl": "Enter URL including https://", "uploadFile": "Upload File", From 5693b7a0da614322651e686e76083b704062374c Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 22:10:58 -0500 Subject: [PATCH 06/25] feat(theme): add opt-in Accessible (WCAG 2.2 AA) theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a selectable "Accessible" theme alongside the existing five. It pairs a high-contrast, light-based palette with a small scoped override layer that enforces the presentation success criteria app-wide — without touching any component or the other themes (the rules only apply under .accessible): - 1.4.3 Contrast: neutralizes text-muted-foreground/* and text-primary/* opacity modifiers (the main cause of sub-4.5:1 text) and uses a darker muted token (~8:1). - 1.4.11 Non-text Contrast: darker borders/inputs (>=3:1). - 2.4.7 Focus Visible: a consistent high-contrast focus ring. - 2.5.8 Target Size: 24px minimum hit area for controls and links. Wired through next-themes (themes list), the Theme enum, the user-menu picker (persists like the other themes), and i18n. Scanning with this theme active drops color-contrast from 58 routes to 13 and target-size from 26 to 15; the remainder is data-driven badge colors and exempt disabled controls. --- testplanit/components/UserDropdownMenu.tsx | 7 +++ testplanit/components/theme-provider.tsx | 2 +- testplanit/lib/openapi/zenstack-openapi.json | 3 +- testplanit/messages/en-US.json | 3 +- testplanit/prisma/schema.prisma | 1 + testplanit/schema.zmodel | 1 + testplanit/styles/globals.css | 62 ++++++++++++++++++++ 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/testplanit/components/UserDropdownMenu.tsx b/testplanit/components/UserDropdownMenu.tsx index 857027b72..c7acf40de 100644 --- a/testplanit/components/UserDropdownMenu.tsx +++ b/testplanit/components/UserDropdownMenu.tsx @@ -1,5 +1,6 @@ import { Locale, Theme } from "@prisma/client"; import { + Accessibility, Check, Circle, Globe, @@ -173,6 +174,7 @@ export function UserDropdownMenu() { green: "rgba(34, 197, 94, 1)", // Green primary color orange: "rgba(251, 146, 60, 1)", // Orange primary color purple: "rgba(147, 51, 234, 1)", // Purple primary color + accessible: "rgba(29, 78, 216, 1)", // Strong blue for the accessible theme }; wipeOverlay.style.backgroundColor = @@ -352,6 +354,11 @@ export function UserDropdownMenu() { , "text-purple-500" )} + {renderThemeOption( + "Accessible", + , + "text-blue-700" + )} diff --git a/testplanit/components/theme-provider.tsx b/testplanit/components/theme-provider.tsx index cb9ebd056..ce0a772a0 100644 --- a/testplanit/components/theme-provider.tsx +++ b/testplanit/components/theme-provider.tsx @@ -16,7 +16,7 @@ type ThemeProviderProps = { export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return ( button.absolute.right-4.top-4 { background-color: hsl(var(--primary-foreground)); From 220dfc2be46fe9de153e12ec5f90e84d68066148 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 22:10:58 -0500 Subject: [PATCH 07/25] test(a11y): let the scan force a theme (A11Y_THEME) Adds A11Y_THEME so the scan can measure a specific theme regardless of the seeded user preference (e.g. A11Y_THEME=accessible). The forced theme class is applied before axe runs and recorded in the report header. --- testplanit/e2e/a11y/aggregate.ts | 125 ++++++++++++++++++++++++------- testplanit/e2e/a11y/scan.spec.ts | 97 ++++++++++++++++++++---- 2 files changed, 178 insertions(+), 44 deletions(-) diff --git a/testplanit/e2e/a11y/aggregate.ts b/testplanit/e2e/a11y/aggregate.ts index 337dc17ee..612ab6ebe 100644 --- a/testplanit/e2e/a11y/aggregate.ts +++ b/testplanit/e2e/a11y/aggregate.ts @@ -84,16 +84,26 @@ function loadResults(): RouteResult[] { return fs .readdirSync(RESULTS_DIR) .filter((f) => f.endsWith(".json") && f !== "report.json") - .map((f) => JSON.parse(fs.readFileSync(path.join(RESULTS_DIR, f), "utf8")) as RouteResult); + .map( + (f) => + JSON.parse( + fs.readFileSync(path.join(RESULTS_DIR, f), "utf8") + ) as RouteResult + ); } -function dedupe(results: RouteResult[], kind: "wcag" | "best"): Map { +function dedupe( + results: RouteResult[], + kind: "wcag" | "best" +): Map { const findings = new Map(); for (const r of results) { if (r.status !== "scanned") continue; for (const state of r.states) { - const label = state.state === "initial" ? r.name : `${r.name} (${state.state})`; - const vios = kind === "wcag" ? state.wcagViolations : state.bestPracticeViolations; + const label = + state.state === "initial" ? r.name : `${r.name} (${state.state})`; + const vios = + kind === "wcag" ? state.wcagViolations : state.bestPracticeViolations; for (const v of vios) { const sc = primaryCriterion(v.tags); let f = findings.get(v.id); @@ -155,15 +165,24 @@ function build(): { md: string; json: unknown; blockingCount: number } { }; const wcagTally = tally(wcag); - const blocking = wcag.filter((f) => f.impact === "serious" || f.impact === "critical"); + const blocking = wcag.filter( + (f) => f.impact === "serious" || f.impact === "critical" + ); // Sort: impact, then routes affected desc. const bySpread = (a: Finding, b: Finding) => - impactRank(a.impact) - impactRank(b.impact) || b.routes.size - a.routes.size; + impactRank(a.impact) - impactRank(b.impact) || + b.routes.size - a.routes.size; wcag.sort(bySpread); best.sort(bySpread); - const top5 = [...wcag].sort((a, b) => b.routes.size - a.routes.size || impactRank(a.impact) - impactRank(b.impact)).slice(0, 5); + const top5 = [...wcag] + .sort( + (a, b) => + b.routes.size - a.routes.size || + impactRank(a.impact) - impactRank(b.impact) + ) + .slice(0, 5); // Group WCAG findings by success criterion. const byCriterion = new Map(); @@ -182,16 +201,28 @@ function build(): { md: string; json: unknown; blockingCount: number } { const L: string[] = []; L.push(`# Accessibility Scan Report`); L.push(""); - L.push(`Generated ${now} · axe-core (\`wcag2a\`, \`wcag2aa\`, \`wcag21a\`, \`wcag21aa\`, \`wcag22aa\`, \`best-practice\`)`); + L.push( + `Generated ${now} · axe-core (\`wcag2a\`, \`wcag2aa\`, \`wcag21a\`, \`wcag21aa\`, \`wcag22aa\`, \`best-practice\`)` + ); + L.push(""); + L.push( + `**Theme scanned:** ${process.env.A11Y_THEME || "default (seeded user preference)"}` + ); L.push(""); - L.push(`This is the **automated baseline** for a WCAG 2.2 AA audit feeding a VPAT 2.5 (INT) report. Automated scanning catches roughly a third of WCAG issues; manual keyboard/screen-reader testing is still required for full conformance claims.`); + L.push( + `This is the **automated baseline** for a WCAG 2.2 AA audit feeding a VPAT 2.5 (INT) report. Automated scanning catches roughly a third of WCAG issues; manual keyboard/screen-reader testing is still required for full conformance claims.` + ); L.push(""); // ---- Summary ---- L.push(`## Summary`); L.push(""); - L.push(`- **Routes scanned:** ${scanned.length} · **skipped:** ${skipped.length} · **errored:** ${errored.length}`); - L.push(`- **WCAG A/AA findings (unique rules):** ${wcag.length} · **best-practice findings:** ${best.length}`); + L.push( + `- **Routes scanned:** ${scanned.length} · **skipped:** ${skipped.length} · **errored:** ${errored.length}` + ); + L.push( + `- **WCAG A/AA findings (unique rules):** ${wcag.length} · **best-practice findings:** ${best.length}` + ); L.push(`- **Serious/critical WCAG findings:** ${blocking.length}`); L.push(""); L.push(`| Impact | Unique rules | Route occurrences |`); @@ -210,7 +241,9 @@ function build(): { md: string; json: unknown; blockingCount: number } { L.push(`| Rule | Criterion | Impact | Routes affected |`); L.push(`| --- | --- | --- | ---: |`); for (const f of top5) { - L.push(`| \`${f.id}\` | ${esc(f.criterionLabel)} | ${f.impact} | ${f.routes.size} |`); + L.push( + `| \`${f.id}\` | ${esc(f.criterionLabel)} | ${f.impact} | ${f.routes.size} |` + ); } } L.push(""); @@ -224,14 +257,24 @@ function build(): { md: string; json: unknown; blockingCount: number } { L.push(`### ${label} — Level ${level}`); L.push(""); for (const f of findings) { - L.push(`#### \`${f.id}\` — ${f.impact} · ${f.routes.size} route(s) · ${f.totalNodes} element(s)`); + L.push( + `#### \`${f.id}\` — ${f.impact} · ${f.routes.size} route(s) · ${f.totalNodes} element(s)` + ); L.push(""); L.push(`${esc(f.help)}. [Reference](${f.helpUrl})`); L.push(""); - if (f.sampleSelector) L.push(`- **Example selector:** ${codeFence(f.sampleSelector)}`); - if (f.sampleHtml) L.push(`- **Example element:** ${codeFence(f.sampleHtml)}`); - if (f.sampleFailure) L.push(`- **axe fix guidance:** ${esc(f.sampleFailure)}`); - L.push(`- **Affected routes:** ${[...f.routes].sort().map((r) => `\`${r}\``).join(", ")}`); + if (f.sampleSelector) + L.push(`- **Example selector:** ${codeFence(f.sampleSelector)}`); + if (f.sampleHtml) + L.push(`- **Example element:** ${codeFence(f.sampleHtml)}`); + if (f.sampleFailure) + L.push(`- **axe fix guidance:** ${esc(f.sampleFailure)}`); + L.push( + `- **Affected routes:** ${[...f.routes] + .sort() + .map((r) => `\`${r}\``) + .join(", ")}` + ); L.push(""); } } @@ -239,7 +282,9 @@ function build(): { md: string; json: unknown; blockingCount: number } { // ---- Best practice ---- L.push(`## Best-practice (non-WCAG) findings`); L.push(""); - L.push(`_Reported for awareness; not counted against WCAG 2.2 AA conformance._`); + L.push( + `_Reported for awareness; not counted against WCAG 2.2 AA conformance._` + ); L.push(""); if (best.length === 0) { L.push(`_None._`); @@ -247,7 +292,9 @@ function build(): { md: string; json: unknown; blockingCount: number } { L.push(`| Rule | Impact | Routes | Guidance |`); L.push(`| --- | --- | ---: | --- |`); for (const f of best) { - L.push(`| \`${f.id}\` | ${f.impact} | ${f.routes.size} | ${esc(f.help)} |`); + L.push( + `| \`${f.id}\` | ${f.impact} | ${f.routes.size} | ${esc(f.help)} |` + ); } } L.push(""); @@ -256,18 +303,26 @@ function build(): { md: string; json: unknown; blockingCount: number } { L.push(`## Coverage notes`); L.push(""); if (skipped.length) { - L.push(`**Skipped routes (${skipped.length})** — recorded rather than silently dropped:`); - for (const r of skipped) L.push(`- \`${r.name}\` — ${esc(r.note || "skipped")}`); + L.push( + `**Skipped routes (${skipped.length})** — recorded rather than silently dropped:` + ); + for (const r of skipped) + L.push(`- \`${r.name}\` — ${esc(r.note || "skipped")}`); L.push(""); } if (errored.length) { L.push(`**Errored routes (${errored.length}):**`); - for (const r of errored) L.push(`- \`${r.name}\` (${esc(r.requestedPath)}) — ${esc(r.note || "error")}`); + for (const r of errored) + L.push( + `- \`${r.name}\` (${esc(r.requestedPath)}) — ${esc(r.note || "error")}` + ); L.push(""); } const redirected = scanned.filter((r) => r.note && /redirect/i.test(r.note)); if (redirected.length) { - L.push(`**Redirected routes (${redirected.length})** — scanned at their landing page:`); + L.push( + `**Redirected routes (${redirected.length})** — scanned at their landing page:` + ); for (const r of redirected) L.push(`- \`${r.name}\` — ${esc(r.note!)}`); L.push(""); } @@ -294,9 +349,16 @@ function build(): { md: string; json: unknown; blockingCount: number } { impact: wcagTally, }, wcagFindings: wcag.map((f) => ({ ...f, routes: [...f.routes].sort() })), - bestPracticeFindings: best.map((f) => ({ ...f, routes: [...f.routes].sort() })), + bestPracticeFindings: best.map((f) => ({ + ...f, + routes: [...f.routes].sort(), + })), skipped: skipped.map((r) => ({ name: r.name, note: r.note })), - errored: errored.map((r) => ({ name: r.name, path: r.requestedPath, note: r.note })), + errored: errored.map((r) => ({ + name: r.name, + path: r.requestedPath, + note: r.note, + })), }; return { md: L.join("\n") + "\n", json, blockingCount: blocking.length }; @@ -306,11 +368,18 @@ function main(): void { const { md, json, blockingCount } = build(); fs.mkdirSync(RESULTS_DIR, { recursive: true }); fs.writeFileSync(path.join(RESULTS_DIR, "report.md"), md); - fs.writeFileSync(path.join(RESULTS_DIR, "report.json"), JSON.stringify(json, null, 2)); - console.log(`[a11y] report written to ${path.relative(process.cwd(), path.join(RESULTS_DIR, "report.md"))}`); + fs.writeFileSync( + path.join(RESULTS_DIR, "report.json"), + JSON.stringify(json, null, 2) + ); + console.log( + `[a11y] report written to ${path.relative(process.cwd(), path.join(RESULTS_DIR, "report.md"))}` + ); console.log(`[a11y] ${blockingCount} serious/critical WCAG finding(s)`); if (STRICT && blockingCount > 0) { - console.error(`[a11y] STRICT mode: failing because ${blockingCount} serious/critical WCAG finding(s) exist.`); + console.error( + `[a11y] STRICT mode: failing because ${blockingCount} serious/critical WCAG finding(s) exist.` + ); process.exit(1); } } diff --git a/testplanit/e2e/a11y/scan.spec.ts b/testplanit/e2e/a11y/scan.spec.ts index a62df03c8..f211c6128 100644 --- a/testplanit/e2e/a11y/scan.spec.ts +++ b/testplanit/e2e/a11y/scan.spec.ts @@ -3,7 +3,12 @@ import AxeBuilder from "@axe-core/playwright"; import fs from "fs"; import path from "path"; import { stubBellSSE } from "../fixtures"; -import { routes, type A11yRoute, type A11yFixtures, type InteractiveState } from "./routes"; +import { + routes, + type A11yRoute, + type A11yFixtures, + type InteractiveState, +} from "./routes"; import { primaryCriterion, isWcagViolation } from "./wcag"; /** @@ -24,9 +29,19 @@ const FIXTURES_FILE = path.join(__dirname, ".a11y-fixtures.json"); const LOCALE = "en-US"; // The full WCAG 2.0/2.1/2.2 A + AA stack, plus best-practice (split out below). -const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"]; +const AXE_TAGS = [ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa", + "best-practice", +]; const STRICT = process.env.A11Y_STRICT === "on" || process.env.CI === "strict"; +// Optionally force a theme class before axe runs (e.g. A11Y_THEME=accessible), +// so the scan measures a specific theme regardless of the seeded user preference. +const FORCE_THEME = process.env.A11Y_THEME; const fixtures: A11yFixtures | null = fs.existsSync(FIXTURES_FILE) ? JSON.parse(fs.readFileSync(FIXTURES_FILE, "utf8")) @@ -104,7 +119,9 @@ type AxeViolation = { nodes: AxeNode[]; }; -async function runAxe(page: Page): Promise<{ wcag: Violation[]; best: Violation[] }> { +async function runAxe( + page: Page +): Promise<{ wcag: Violation[]; best: Violation[] }> { const results = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze(); const all = results.violations as unknown as AxeViolation[]; return { @@ -118,12 +135,26 @@ async function settle(page: Page, route: A11yRoute): Promise { // that proves the shell rendered. Neither is allowed to hang the scan. await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {}); if (route.sanity) { - await page.waitForSelector(route.sanity, { state: "attached", timeout: 12000 }).catch(() => {}); + await page + .waitForSelector(route.sanity, { state: "attached", timeout: 12000 }) + .catch(() => {}); } await dismissOnboardingOverlay(page); + if (FORCE_THEME) await applyTheme(page, FORCE_THEME); if (route.settleMs) await page.waitForTimeout(route.settleMs); } +async function applyTheme(page: Page, theme: string): Promise { + await page + .evaluate((t) => { + const all = ["light", "dark", "green", "orange", "purple", "accessible"]; + document.documentElement.classList.remove(...all); + document.documentElement.classList.add(t); + }, theme) + .catch(() => {}); + await page.waitForTimeout(150); +} + async function dismissOnboardingOverlay(page: Page): Promise { const overlay = page.locator('[data-name="nextstep-overlay"]'); if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) { @@ -135,9 +166,12 @@ async function dismissOnboardingOverlay(page: Page): Promise { /** Best-effort: open the page's primary dialog. Returns whether one opened. */ async function openDialog(page: Page): Promise { const trigger = page - .getByRole("button", { name: /add|new|create|invite|connect|upload|import|generate/i }) + .getByRole("button", { + name: /add|new|create|invite|connect|upload|import|generate/i, + }) .first(); - if (!(await trigger.isVisible({ timeout: 1500 }).catch(() => false))) return false; + if (!(await trigger.isVisible({ timeout: 1500 }).catch(() => false))) + return false; await trigger.click({ timeout: 2000 }).catch(() => {}); const dialog = page.locator('[role="dialog"]').first(); return await dialog.isVisible({ timeout: 3000 }).catch(() => false); @@ -150,21 +184,38 @@ async function openMenu(page: Page): Promise { 'button[aria-haspopup="menu"], [data-testid$="actions-menu"], [data-testid$="-menu-trigger"], button:has(svg.lucide-ellipsis-vertical), button:has(svg.lucide-ellipsis)' ) .first(); - if (!(await trigger.isVisible({ timeout: 1500 }).catch(() => false))) return false; + if (!(await trigger.isVisible({ timeout: 1500 }).catch(() => false))) + return false; await trigger.click({ timeout: 2000 }).catch(() => {}); const menu = page.locator('[role="menu"]').first(); return await menu.isVisible({ timeout: 2500 }).catch(() => false); } -async function scanInteraction(page: Page, kind: InteractiveState): Promise { - const reached = kind === "dialog" ? await openDialog(page) : await openMenu(page); +async function scanInteraction( + page: Page, + kind: InteractiveState +): Promise { + const reached = + kind === "dialog" ? await openDialog(page) : await openMenu(page); if (!reached) { - return { state: kind, reached: false, url: page.url(), wcagViolations: [], bestPracticeViolations: [] }; + return { + state: kind, + reached: false, + url: page.url(), + wcagViolations: [], + bestPracticeViolations: [], + }; } const { wcag, best } = await runAxe(page); await page.keyboard.press("Escape").catch(() => {}); await page.waitForTimeout(200); - return { state: kind, reached: true, url: page.url(), wcagViolations: wcag, bestPracticeViolations: best }; + return { + state: kind, + reached: true, + url: page.url(), + wcagViolations: wcag, + bestPracticeViolations: best, + }; } function missingFixture(route: A11yRoute): keyof A11yFixtures | null { @@ -172,17 +223,25 @@ function missingFixture(route: A11yRoute): keyof A11yFixtures | null { if (!fixtures) return route.needs[0] ?? null; for (const key of route.needs) { const val = fixtures[key]; - if (val === undefined || val === null || val === "" || val === 0) return key; + if (val === undefined || val === null || val === "" || val === 0) + return key; } return null; } function writeResult(result: RouteResult): void { - fs.writeFileSync(path.join(RESULTS_DIR, `${result.name}.json`), JSON.stringify(result, null, 2)); + fs.writeFileSync( + path.join(RESULTS_DIR, `${result.name}.json`), + JSON.stringify(result, null, 2) + ); } for (const route of routes) { - test(`a11y: ${route.group} › ${route.name}`, async ({ page, browser, baseURL }) => { + test(`a11y: ${route.group} › ${route.name}`, async ({ + page, + browser, + baseURL, + }) => { test.setTimeout(90_000); const result: RouteResult = { @@ -220,14 +279,20 @@ for (const route of routes) { await stubBellSSE(scanPage); try { - const resp = await scanPage.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + const resp = await scanPage.goto(url, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); await settle(scanPage, route); result.finalUrl = scanPage.url(); if (resp && resp.status() >= 400) { result.note = `HTTP ${resp.status()} on navigation`; } - if (route.mayRedirect && !result.finalUrl.includes(relPath.split("?")[0])) { + if ( + route.mayRedirect && + !result.finalUrl.includes(relPath.split("?")[0]) + ) { result.note = `Redirected to ${new URL(result.finalUrl).pathname}`; } From ee165564d6677d54f93649dc9ff54cd6768cfc19 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 22:21:13 -0500 Subject: [PATCH 08/25] fix(a11y): make the avatar tooltip non-interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar rendered its tooltip trigger as a nameless diff --git a/testplanit/app/[locale]/projects/milestones/[projectId]/MilestoneItemCard.tsx b/testplanit/app/[locale]/projects/milestones/[projectId]/MilestoneItemCard.tsx index e90a161ed..9c7d6839b 100644 --- a/testplanit/app/[locale]/projects/milestones/[projectId]/MilestoneItemCard.tsx +++ b/testplanit/app/[locale]/projects/milestones/[projectId]/MilestoneItemCard.tsx @@ -202,6 +202,7 @@ const MilestoneItemCard: React.FC = ({ variant="secondary" size="icon" className="p-0 m-0 h-7 w-7" + aria-label={tCommon("actions.actionsLabel")} > diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/TreeView.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/TreeView.tsx index 7f90213c1..c5ab22cfd 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/TreeView.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/TreeView.tsx @@ -1181,6 +1181,7 @@ const TreeView: React.FC<{ size="icon" className="h-7 w-7 p-0" data-testid={`folder-actions-trigger-${data?.folderId ?? 0}`} + aria-label={t("common.actions.actionsLabel")} > diff --git a/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx b/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx index 619e8b44b..4c39ddfb2 100644 --- a/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx +++ b/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx @@ -406,7 +406,12 @@ const TestRunItem: React.FC = ({ {showMoreMenu && ( - diff --git a/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx b/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx index e96c00204..7da2a7fd9 100644 --- a/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx +++ b/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx @@ -262,7 +262,12 @@ const SessionItem: React.FC = ({ {showMoreMenu && ( - diff --git a/testplanit/components/comments/CommentItem.tsx b/testplanit/components/comments/CommentItem.tsx index 3dea204f5..eaef091c8 100644 --- a/testplanit/components/comments/CommentItem.tsx +++ b/testplanit/components/comments/CommentItem.tsx @@ -261,6 +261,7 @@ export function CommentItem({ size="sm" className="h-8 w-8 p-0" disabled={isDeleting} + aria-label={t("common.actions.actionsLabel")} > From bd14dd52c8cc2821ec62135994c3039cf8de918a Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 22:34:04 -0500 Subject: [PATCH 10/25] fix(a11y): name admin toggles, selects, and edit buttons (button-name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds aria-labels to controls that had no accessible name: the user active toggle, the project review-workflow switch, the result-editing-policy select, and the app-config / template edit buttons. aria-label only — no visual change. --- testplanit/app/[locale]/admin/app-config/columns.tsx | 1 + testplanit/app/[locale]/admin/fields/templateColumns.tsx | 1 + .../app/[locale]/admin/statuses/ResultEditingPolicyCard.tsx | 1 + testplanit/app/[locale]/admin/users/columns.tsx | 1 + .../app/[locale]/admin/workflows/ProjectReviewToggleList.tsx | 1 + testplanit/messages/en-US.json | 3 +++ 6 files changed, 8 insertions(+) diff --git a/testplanit/app/[locale]/admin/app-config/columns.tsx b/testplanit/app/[locale]/admin/app-config/columns.tsx index 58d2f063b..6266720f1 100644 --- a/testplanit/app/[locale]/admin/app-config/columns.tsx +++ b/testplanit/app/[locale]/admin/app-config/columns.tsx @@ -84,6 +84,7 @@ export function getColumns( variant="ghost" className="px-2 py-1 h-auto" data-testid="edit-config-button" + aria-label={t("actions.edit")} onClick={() => onEditConfig?.(row.original)} > diff --git a/testplanit/app/[locale]/admin/fields/templateColumns.tsx b/testplanit/app/[locale]/admin/fields/templateColumns.tsx index 255207b48..3440a71cf 100644 --- a/testplanit/app/[locale]/admin/fields/templateColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/templateColumns.tsx @@ -132,6 +132,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" data-testid="edit-template-button" + aria-label={tCommon("actions.edit")} onClick={() => onEditTemplate?.(row.original)} > diff --git a/testplanit/app/[locale]/admin/statuses/ResultEditingPolicyCard.tsx b/testplanit/app/[locale]/admin/statuses/ResultEditingPolicyCard.tsx index c4471c1ba..7f1239949 100644 --- a/testplanit/app/[locale]/admin/statuses/ResultEditingPolicyCard.tsx +++ b/testplanit/app/[locale]/admin/statuses/ResultEditingPolicyCard.tsx @@ -108,6 +108,7 @@ export function ResultEditingPolicyCard() { diff --git a/testplanit/app/[locale]/admin/users/columns.tsx b/testplanit/app/[locale]/admin/users/columns.tsx index 57dae5462..4b8cffa3f 100644 --- a/testplanit/app/[locale]/admin/users/columns.tsx +++ b/testplanit/app/[locale]/admin/users/columns.tsx @@ -115,6 +115,7 @@ export const useColumns = (
diff --git a/testplanit/app/[locale]/admin/workflows/ProjectReviewToggleList.tsx b/testplanit/app/[locale]/admin/workflows/ProjectReviewToggleList.tsx index 2a53f6436..349830d9d 100644 --- a/testplanit/app/[locale]/admin/workflows/ProjectReviewToggleList.tsx +++ b/testplanit/app/[locale]/admin/workflows/ProjectReviewToggleList.tsx @@ -174,6 +174,7 @@ export function ProjectReviewToggleList() {
handleToggle(id, name, checked)} /> diff --git a/testplanit/messages/en-US.json b/testplanit/messages/en-US.json index ea922c619..425e88ed9 100644 --- a/testplanit/messages/en-US.json +++ b/testplanit/messages/en-US.json @@ -693,6 +693,7 @@ "selectProject": "Select project", "togglePanel": "Toggle panel", "folder": "Folder", + "toggleActive": "Toggle active status", "search": "Search", "helpMenu": "Help menu", "help": "Help", @@ -4431,6 +4432,7 @@ "thresholdUpdateError": "Failed to update reminder settings. Try again in a moment." }, "projectReviewToggleList": { + "toggleAria": "Toggle review workflow for {name}", "columnHeader": "Review workflow", "filterPlaceholder": "Filter projects...", "enabledToast": "Review workflow enabled for {name}", @@ -4500,6 +4502,7 @@ "statuses": { "description": "Manage test statuses", "editPolicy": { + "modeAria": "Result editing policy mode", "title": "Result editing policy", "description": "Control how long after a result is recorded it can still be edited in place, across all projects. Projects can tighten this in their Advanced settings but never loosen it. System administrators can always edit.", "modeNone": "No restriction (projects decide)", From d9e9ef23a08aa3805194b7de80cc47536470136d Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 23:16:12 -0500 Subject: [PATCH 11/25] fix(a11y): name edit/delete icon buttons in admin tables (button-name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds aria-labels (existing Edit/Delete strings) to the icon-only edit and delete buttons (enabled and disabled variants) in the app-config, template, and status admin tables. aria-label only — no visual change. --- testplanit/app/[locale]/admin/app-config/columns.tsx | 1 + testplanit/app/[locale]/admin/fields/templateColumns.tsx | 1 + testplanit/app/[locale]/admin/statuses/columns.tsx | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/testplanit/app/[locale]/admin/app-config/columns.tsx b/testplanit/app/[locale]/admin/app-config/columns.tsx index 6266720f1..a8fac1195 100644 --- a/testplanit/app/[locale]/admin/app-config/columns.tsx +++ b/testplanit/app/[locale]/admin/app-config/columns.tsx @@ -93,6 +93,7 @@ export function getColumns( variant="destructive" className="px-2 py-1 h-auto" data-testid="delete-config" + aria-label={t("actions.delete")} onClick={() => onDeleteConfig?.(row.original)} > diff --git a/testplanit/app/[locale]/admin/fields/templateColumns.tsx b/testplanit/app/[locale]/admin/fields/templateColumns.tsx index 3440a71cf..4f2ef2d40 100644 --- a/testplanit/app/[locale]/admin/fields/templateColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/templateColumns.tsx @@ -142,6 +142,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/statuses/columns.tsx b/testplanit/app/[locale]/admin/statuses/columns.tsx index c97f1ba70..1931e29e8 100644 --- a/testplanit/app/[locale]/admin/statuses/columns.tsx +++ b/testplanit/app/[locale]/admin/statuses/columns.tsx @@ -244,6 +244,7 @@ export const getColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.edit")} > @@ -252,6 +253,7 @@ export const getColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditStatus?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -261,6 +263,7 @@ export const getColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteStatus?.(row.original)} + aria-label={tCommon("actions.delete")} > @@ -269,6 +272,7 @@ export const getColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.delete")} > From 0c18f0af0e28e7c59f6272a1229233bd5164050a Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 23:18:10 -0500 Subject: [PATCH 12/25] fix(a11y): name edit/delete buttons in config, llm, projects, roles tables (button-name) --- testplanit/app/[locale]/admin/configurations/configColumns.tsx | 2 ++ testplanit/app/[locale]/admin/llm/columns.tsx | 2 ++ testplanit/app/[locale]/admin/projects/columns.tsx | 2 ++ testplanit/app/[locale]/admin/roles/columns.tsx | 3 +++ 4 files changed, 9 insertions(+) diff --git a/testplanit/app/[locale]/admin/configurations/configColumns.tsx b/testplanit/app/[locale]/admin/configurations/configColumns.tsx index df0da3472..2b2afd2e0 100644 --- a/testplanit/app/[locale]/admin/configurations/configColumns.tsx +++ b/testplanit/app/[locale]/admin/configurations/configColumns.tsx @@ -329,6 +329,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditConfiguration?.(row.original)} + aria-label={t("actions.edit")} > @@ -336,6 +337,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteConfiguration?.(row.original)} + aria-label={t("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/llm/columns.tsx b/testplanit/app/[locale]/admin/llm/columns.tsx index 183abcb54..5adab3a0b 100644 --- a/testplanit/app/[locale]/admin/llm/columns.tsx +++ b/testplanit/app/[locale]/admin/llm/columns.tsx @@ -279,6 +279,7 @@ export const useColumns = ( onClick={() => onEditIntegration?.(row.original)} className="px-2 py-1 h-auto" data-testid="llm-edit-button" + aria-label={tCommon("actions.edit")} > @@ -298,6 +299,7 @@ export const useColumns = ( : undefined } data-testid="llm-delete-button" + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/projects/columns.tsx b/testplanit/app/[locale]/admin/projects/columns.tsx index c4f82ad40..2adbb4410 100644 --- a/testplanit/app/[locale]/admin/projects/columns.tsx +++ b/testplanit/app/[locale]/admin/projects/columns.tsx @@ -312,6 +312,7 @@ export const useColumns = ( size="icon" onClick={() => handleOpenEditModal(row.original)} className="px-2 py-1 h-auto" + aria-label={tCommon("actions.edit")} > @@ -320,6 +321,7 @@ export const useColumns = ( size="icon" className="px-2 py-1 h-auto" onClick={() => onDeleteProject?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/roles/columns.tsx b/testplanit/app/[locale]/admin/roles/columns.tsx index 9e1770d40..2c69787c4 100644 --- a/testplanit/app/[locale]/admin/roles/columns.tsx +++ b/testplanit/app/[locale]/admin/roles/columns.tsx @@ -82,6 +82,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditRole?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -90,6 +91,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.delete")} > @@ -98,6 +100,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteRole?.(row.original)} + aria-label={tCommon("actions.delete")} > From 028f6ec6a60dd1404f032f149d0de83d6462007e Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 23:20:37 -0500 Subject: [PATCH 13/25] fix(a11y): name edit/delete buttons in remaining admin tables (button-name) --- testplanit/app/[locale]/admin/code-repositories/columns.tsx | 2 ++ .../app/[locale]/admin/configurations/categoryColumns.tsx | 2 ++ testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx | 2 ++ testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx | 2 ++ testplanit/app/[locale]/admin/issues/columns.tsx | 2 ++ .../[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx | 3 +++ testplanit/app/[locale]/admin/tags/columns.tsx | 2 ++ 7 files changed, 15 insertions(+) diff --git a/testplanit/app/[locale]/admin/code-repositories/columns.tsx b/testplanit/app/[locale]/admin/code-repositories/columns.tsx index 70fc9204c..5d8fe8551 100644 --- a/testplanit/app/[locale]/admin/code-repositories/columns.tsx +++ b/testplanit/app/[locale]/admin/code-repositories/columns.tsx @@ -168,6 +168,7 @@ export function getColumns({ size="icon" className="px-2 py-1 h-auto" onClick={() => onEdit(row.original)} + aria-label={tCommon("actions.edit")} > @@ -176,6 +177,7 @@ export function getColumns({ size="icon" className="px-2 py-1 h-auto" onClick={() => onDelete(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/configurations/categoryColumns.tsx b/testplanit/app/[locale]/admin/configurations/categoryColumns.tsx index a400d349a..3d388441c 100644 --- a/testplanit/app/[locale]/admin/configurations/categoryColumns.tsx +++ b/testplanit/app/[locale]/admin/configurations/categoryColumns.tsx @@ -135,6 +135,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditCategory?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -142,6 +143,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteCategory?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx b/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx index 778dd626f..1a82a5b50 100644 --- a/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx @@ -155,6 +155,7 @@ export const useColumns = ( className="px-2 py-1 h-auto" data-testid="edit-case-field-button" onClick={() => onEditCaseField?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -163,6 +164,7 @@ export const useColumns = ( className="px-2 py-1 h-auto" data-testid="delete-case-field-button" onClick={() => onDeleteCaseField?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx b/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx index 0e1eef60a..898d2f98d 100644 --- a/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx @@ -154,6 +154,7 @@ export const useColumns = ( className="px-2 py-1 h-auto" data-testid="edit-result-field-button" onClick={() => onEditResultField?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -162,6 +163,7 @@ export const useColumns = ( className="px-2 py-1 h-auto" data-testid="delete-result-field-button" onClick={() => onDeleteResultField?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/issues/columns.tsx b/testplanit/app/[locale]/admin/issues/columns.tsx index 49f4efa45..e53bf52ab 100644 --- a/testplanit/app/[locale]/admin/issues/columns.tsx +++ b/testplanit/app/[locale]/admin/issues/columns.tsx @@ -449,6 +449,7 @@ export function useIssueColumns({ variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditIssue?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -456,6 +457,7 @@ export function useIssueColumns({ variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteIssue?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx b/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx index 7697f8bfe..47f41afb7 100644 --- a/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx +++ b/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx @@ -109,6 +109,7 @@ export const useColumns = ( className="px-2 py-1 h-auto" data-testid="edit-export-template-button" onClick={() => onEditTemplate?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -117,6 +118,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.delete")} > @@ -126,6 +128,7 @@ export const useColumns = ( className="px-2 py-1 h-auto" data-testid="delete-export-template-button" onClick={() => onDeleteTemplate?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/tags/columns.tsx b/testplanit/app/[locale]/admin/tags/columns.tsx index e71fbf2cf..78a51dec8 100644 --- a/testplanit/app/[locale]/admin/tags/columns.tsx +++ b/testplanit/app/[locale]/admin/tags/columns.tsx @@ -154,6 +154,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditTag?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -161,6 +162,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteTag?.(row.original)} + aria-label={tCommon("actions.delete")} > From b454173a09cf5197b0f185faf457ffe90ff79f46 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 23:28:06 -0500 Subject: [PATCH 14/25] fix(a11y): name edit/delete buttons in workflow/group/milestone/prompt tables (button-name) --- testplanit/app/[locale]/admin/groups/columns.tsx | 2 ++ testplanit/app/[locale]/admin/milestones/columns.tsx | 3 +++ testplanit/app/[locale]/admin/prompts/columns.tsx | 2 ++ testplanit/app/[locale]/admin/workflows/columns.tsx | 3 +++ 4 files changed, 10 insertions(+) diff --git a/testplanit/app/[locale]/admin/groups/columns.tsx b/testplanit/app/[locale]/admin/groups/columns.tsx index fc1253aa0..322eeff2a 100644 --- a/testplanit/app/[locale]/admin/groups/columns.tsx +++ b/testplanit/app/[locale]/admin/groups/columns.tsx @@ -80,6 +80,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditGroup?.(row.original)} + aria-label={t("actions.edit")} > @@ -87,6 +88,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteGroup?.(row.original)} + aria-label={t("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/milestones/columns.tsx b/testplanit/app/[locale]/admin/milestones/columns.tsx index da0f951d8..1ef490957 100644 --- a/testplanit/app/[locale]/admin/milestones/columns.tsx +++ b/testplanit/app/[locale]/admin/milestones/columns.tsx @@ -90,6 +90,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditMilestoneType?.(row.original)} + aria-label={tCommon("actions.edit")} > @@ -98,6 +99,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.delete")} > @@ -106,6 +108,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteMilestoneType?.(row.original)} + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/prompts/columns.tsx b/testplanit/app/[locale]/admin/prompts/columns.tsx index f4311c6e1..286cac033 100644 --- a/testplanit/app/[locale]/admin/prompts/columns.tsx +++ b/testplanit/app/[locale]/admin/prompts/columns.tsx @@ -233,6 +233,7 @@ export const useColumns = ( size="icon" onClick={() => onEditConfig?.(row.original)} className="px-2 py-1 h-auto" + aria-label={tCommon("actions.edit")} > @@ -245,6 +246,7 @@ export const useColumns = ( title={ row.original.isDefault ? t("cannotDeleteDefault") : undefined } + aria-label={tCommon("actions.delete")} > diff --git a/testplanit/app/[locale]/admin/workflows/columns.tsx b/testplanit/app/[locale]/admin/workflows/columns.tsx index 2c74d1dca..663ed8d6f 100644 --- a/testplanit/app/[locale]/admin/workflows/columns.tsx +++ b/testplanit/app/[locale]/admin/workflows/columns.tsx @@ -189,6 +189,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto" onClick={() => onEditWorkflow?.(workflow)} + aria-label={tCommon("actions.edit")} > @@ -197,6 +198,7 @@ export const useColumns = ( variant="destructive" className="px-2 py-1 h-auto" onClick={() => onDeleteWorkflow?.(workflow)} + aria-label={tCommon("actions.delete")} > @@ -205,6 +207,7 @@ export const useColumns = ( variant="ghost" className="px-2 py-1 h-auto text-muted-foreground cursor-not-allowed" disabled + aria-label={tCommon("actions.delete")} > From b0921311d29efd4be5e1f7fc6493438e5357d3dc Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Thu, 4 Jun 2026 23:36:12 -0500 Subject: [PATCH 15/25] fix(a11y): name the project sidebar toggle and case back button (button-name) --- testplanit/app/[locale]/projects/layout.tsx | 3 +++ .../[locale]/projects/repository/[projectId]/[caseId]/page.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/testplanit/app/[locale]/projects/layout.tsx b/testplanit/app/[locale]/projects/layout.tsx index 5d0bc5b91..c11ef0573 100644 --- a/testplanit/app/[locale]/projects/layout.tsx +++ b/testplanit/app/[locale]/projects/layout.tsx @@ -3,9 +3,11 @@ import ProjectMenu from "@/components/ProjectMenu"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; export default function ProjectsLayout(props: any) { + const t = useTranslations("common.aria"); const [isCollapsed, setIsCollapsed] = useState(false); // Load initial state from localStorage @@ -39,6 +41,7 @@ export default function ProjectsLayout(props: any) { onClick={handleToggleCollapse} variant="secondary" className="hidden md:flex absolute -right-4 top-12 z-20 p-0 rounded-l-none" + aria-label={t("togglePanel")} > {isCollapsed ? : } diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/page.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/page.tsx index 83d8d162a..4facb062b 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/page.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/page.tsx @@ -1913,6 +1913,7 @@ export default function TestCaseDetails() { variant="outline" size="icon" className="mr-2" + aria-label={t("common.aria.backToTestCase")} > From 465a9675f598a0b153215e20cb29653244755de7 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Fri, 5 Jun 2026 00:01:40 -0500 Subject: [PATCH 16/25] fix(a11y): label enabled/status toggle switches in admin tables (button-name) --- testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx | 3 +++ testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx | 3 +++ testplanit/app/[locale]/admin/fields/templateColumns.tsx | 2 ++ testplanit/app/[locale]/admin/milestones/columns.tsx | 1 + testplanit/app/[locale]/admin/projects/columns.tsx | 1 + testplanit/app/[locale]/admin/prompts/columns.tsx | 1 + .../admin/quickscripts/quickScriptTemplateColumns.tsx | 2 ++ testplanit/app/[locale]/admin/roles/columns.tsx | 1 + testplanit/app/[locale]/admin/statuses/columns.tsx | 4 ++++ testplanit/app/[locale]/admin/workflows/columns.tsx | 2 ++ 10 files changed, 20 insertions(+) diff --git a/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx b/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx index 1a82a5b50..e18d42226 100644 --- a/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/caseFieldColumns.tsx @@ -96,6 +96,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggle(row.original.id, "isEnabled", checked) @@ -114,6 +115,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggle(row.original.id, "isRequired", checked) @@ -132,6 +134,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggle(row.original.id, "isRestricted", checked) diff --git a/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx b/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx index 898d2f98d..19ff9d3d3 100644 --- a/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/resultFieldColumns.tsx @@ -95,6 +95,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggle(row.original.id, "isEnabled", checked) @@ -113,6 +114,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggle(row.original.id, "isRequired", checked) @@ -131,6 +133,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggle(row.original.id, "isRestricted", checked) diff --git a/testplanit/app/[locale]/admin/fields/templateColumns.tsx b/testplanit/app/[locale]/admin/fields/templateColumns.tsx index 4f2ef2d40..e1e8629cf 100644 --- a/testplanit/app/[locale]/admin/fields/templateColumns.tsx +++ b/testplanit/app/[locale]/admin/fields/templateColumns.tsx @@ -90,6 +90,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggleEnabled(row.original.id, checked) @@ -109,6 +110,7 @@ export const useColumns = ( cell: ({ row }) => (
diff --git a/testplanit/app/[locale]/admin/milestones/columns.tsx b/testplanit/app/[locale]/admin/milestones/columns.tsx index 1ef490957..fb46d03b4 100644 --- a/testplanit/app/[locale]/admin/milestones/columns.tsx +++ b/testplanit/app/[locale]/admin/milestones/columns.tsx @@ -67,6 +67,7 @@ export const useColumns = ( cell: ({ row }) => (
diff --git a/testplanit/app/[locale]/admin/projects/columns.tsx b/testplanit/app/[locale]/admin/projects/columns.tsx index 2adbb4410..2c912d559 100644 --- a/testplanit/app/[locale]/admin/projects/columns.tsx +++ b/testplanit/app/[locale]/admin/projects/columns.tsx @@ -260,6 +260,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggleCompleted(row.original.id, checked) diff --git a/testplanit/app/[locale]/admin/prompts/columns.tsx b/testplanit/app/[locale]/admin/prompts/columns.tsx index 286cac033..53aa2b9ad 100644 --- a/testplanit/app/[locale]/admin/prompts/columns.tsx +++ b/testplanit/app/[locale]/admin/prompts/columns.tsx @@ -158,6 +158,7 @@ export const useColumns = ( cell: ({ row }) => (
diff --git a/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx b/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx index 47f41afb7..f7f2905cd 100644 --- a/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx +++ b/testplanit/app/[locale]/admin/quickscripts/quickScriptTemplateColumns.tsx @@ -66,6 +66,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggleEnabled(row.original.id, checked) @@ -85,6 +86,7 @@ export const useColumns = ( cell: ({ row }) => (
diff --git a/testplanit/app/[locale]/admin/roles/columns.tsx b/testplanit/app/[locale]/admin/roles/columns.tsx index 2c69787c4..09068621a 100644 --- a/testplanit/app/[locale]/admin/roles/columns.tsx +++ b/testplanit/app/[locale]/admin/roles/columns.tsx @@ -45,6 +45,7 @@ export const useColumns = ( cell: ({ row }) => (
diff --git a/testplanit/app/[locale]/admin/statuses/columns.tsx b/testplanit/app/[locale]/admin/statuses/columns.tsx index 1931e29e8..48b11b0ad 100644 --- a/testplanit/app/[locale]/admin/statuses/columns.tsx +++ b/testplanit/app/[locale]/admin/statuses/columns.tsx @@ -117,6 +117,7 @@ export const getColumns = ( cell: ({ row }) => (
handleToggleEnabled(row.original.id, checked) @@ -137,6 +138,7 @@ export const getColumns = ( cell: ({ row }) => (
handleToggleSuccess(row.original.id, checked) @@ -157,6 +159,7 @@ export const getColumns = ( cell: ({ row }) => (
handleToggleFailure(row.original.id, checked) @@ -177,6 +180,7 @@ export const getColumns = ( cell: ({ row }) => (
handleToggleCompleted(row.original.id, checked) diff --git a/testplanit/app/[locale]/admin/workflows/columns.tsx b/testplanit/app/[locale]/admin/workflows/columns.tsx index 663ed8d6f..91aa43a6b 100644 --- a/testplanit/app/[locale]/admin/workflows/columns.tsx +++ b/testplanit/app/[locale]/admin/workflows/columns.tsx @@ -85,6 +85,7 @@ export const useColumns = ( cell: ({ row }) => (
@@ -108,6 +109,7 @@ export const useColumns = ( cell: ({ row }) => (
handleToggleEnabled(row.original.id, checked) From 823d518161e39451b1c9dc0f42de36d121bff404 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Fri, 5 Jun 2026 00:08:45 -0500 Subject: [PATCH 17/25] fix(a11y): name result-expand, remove-parent-folder, requires-review controls (button-name) --- testplanit/app/[locale]/admin/workflows/columns.tsx | 1 + .../app/[locale]/projects/repository/[projectId]/AddFolder.tsx | 1 + testplanit/components/TestResultHistory.tsx | 1 + testplanit/messages/en-US.json | 2 ++ 4 files changed, 5 insertions(+) diff --git a/testplanit/app/[locale]/admin/workflows/columns.tsx b/testplanit/app/[locale]/admin/workflows/columns.tsx index 91aa43a6b..8784da90b 100644 --- a/testplanit/app/[locale]/admin/workflows/columns.tsx +++ b/testplanit/app/[locale]/admin/workflows/columns.tsx @@ -142,6 +142,7 @@ export const useColumns = ( > setEffectiveParentId(null)} data-testid="remove-parent-folder-button" + aria-label={t("repository.removeParentFolder")} > diff --git a/testplanit/components/TestResultHistory.tsx b/testplanit/components/TestResultHistory.tsx index aee4f5fc5..81cf941a3 100644 --- a/testplanit/components/TestResultHistory.tsx +++ b/testplanit/components/TestResultHistory.tsx @@ -1420,6 +1420,7 @@ export default function TestResultHistory({ className="h-6 w-6" data-testid={`expand-result-${result.displayId}`} onClick={() => toggleExpanded(result.displayId)} + aria-label={tCommon("aria.toggleDetails")} > {isExpanded ? ( diff --git a/testplanit/messages/en-US.json b/testplanit/messages/en-US.json index 425e88ed9..145278974 100644 --- a/testplanit/messages/en-US.json +++ b/testplanit/messages/en-US.json @@ -694,6 +694,8 @@ "togglePanel": "Toggle panel", "folder": "Folder", "toggleActive": "Toggle active status", + "toggleDetails": "Toggle details", + "requiresReview": "Requires review", "search": "Search", "helpMenu": "Help menu", "help": "Help", From fd008e1960a281c2bb3b0031a9fa0c27dd6e70b4 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Fri, 5 Jun 2026 00:10:28 -0500 Subject: [PATCH 18/25] fix(a11y): name the shared column-filter operator selects (button-name) --- testplanit/components/DateFilterInput.tsx | 5 ++++- testplanit/components/LinkFilterInput.tsx | 5 ++++- testplanit/components/NumericFilterInput.tsx | 5 ++++- testplanit/components/StepsFilterInput.tsx | 5 ++++- testplanit/components/TextFilterInput.tsx | 5 ++++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/testplanit/components/DateFilterInput.tsx b/testplanit/components/DateFilterInput.tsx index 3f8576cbc..4f7c2f630 100644 --- a/testplanit/components/DateFilterInput.tsx +++ b/testplanit/components/DateFilterInput.tsx @@ -220,7 +220,10 @@ export function DateFilterInput({ value={operator} onValueChange={(val) => setOperator(val as DateOperator)} > - + diff --git a/testplanit/components/LinkFilterInput.tsx b/testplanit/components/LinkFilterInput.tsx index 3e4044db8..c37611b26 100644 --- a/testplanit/components/LinkFilterInput.tsx +++ b/testplanit/components/LinkFilterInput.tsx @@ -123,7 +123,10 @@ export function LinkFilterInput({ value={operator} onValueChange={(val) => setOperator(val as LinkOperator)} > - + diff --git a/testplanit/components/NumericFilterInput.tsx b/testplanit/components/NumericFilterInput.tsx index f9f08faa8..b774b2360 100644 --- a/testplanit/components/NumericFilterInput.tsx +++ b/testplanit/components/NumericFilterInput.tsx @@ -150,7 +150,10 @@ export function NumericFilterInput({ value={operator} onValueChange={(val) => setOperator(val as NumericOperator)} > - + diff --git a/testplanit/components/StepsFilterInput.tsx b/testplanit/components/StepsFilterInput.tsx index c7f57fb69..41cb9ef00 100644 --- a/testplanit/components/StepsFilterInput.tsx +++ b/testplanit/components/StepsFilterInput.tsx @@ -146,7 +146,10 @@ export function StepsFilterInput({ value={operator} onValueChange={(val) => setOperator(val as StepsOperator)} > - + diff --git a/testplanit/components/TextFilterInput.tsx b/testplanit/components/TextFilterInput.tsx index a209c0e8f..2ed34f1f8 100644 --- a/testplanit/components/TextFilterInput.tsx +++ b/testplanit/components/TextFilterInput.tsx @@ -123,7 +123,10 @@ export function TextFilterInput({ value={operator} onValueChange={(val) => setOperator(val as TextOperator)} > - + From 50070509c6a5eaa63b0c74e62f50f0d832b29db3 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Fri, 5 Jun 2026 07:12:57 -0500 Subject: [PATCH 19/25] docs(a11y): add WCAG 2.2 AA conformance report (ACR / VPAT draft) --- testplanit/e2e/a11y/VPAT-WCAG22-AA.md | 132 ++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 testplanit/e2e/a11y/VPAT-WCAG22-AA.md diff --git a/testplanit/e2e/a11y/VPAT-WCAG22-AA.md b/testplanit/e2e/a11y/VPAT-WCAG22-AA.md new file mode 100644 index 000000000..171374283 --- /dev/null +++ b/testplanit/e2e/a11y/VPAT-WCAG22-AA.md @@ -0,0 +1,132 @@ +# Accessibility Conformance Report — TestPlanIt +### WCAG 2.2 Level A & AA (VPAT® 2.5 INT — WCAG chapter) + +**Status: DRAFT — automated baseline only. NOT a conformance claim.** + +| | | +|---|---| +| **Product** | TestPlanIt (web application) | +| **Report date** | 2026-06-05 | +| **Evaluated against** | WCAG 2.2 Levels A and AA | +| **Evaluation methods** | Automated: axe-core via the `e2e/a11y` Playwright harness, **79 of 80 application routes** scanned as an authenticated admin against seeded data, in the **"Accessible" theme**. Manual testing: **not yet performed.** | +| **Configuration note** | Results reflect the opt-in **Accessible theme**. Other themes show additional 1.4.3/2.5.8 issues. | + +--- + +## How to read this report + +Automated tooling (axe-core) reliably evaluates only ~30–40% of WCAG success criteria. **This report is a floor, not a conformance determination.** + +| Term | Meaning here | +|---|---| +| **Supports** | No automated failures detected. **¹ Pending manual confirmation** — not a verified pass. | +| **Partially Supports** | Automated testing found failures (evidence in Remarks). | +| **Does Not Support** | Majority of functionality fails (none currently). | +| **Not Applicable** | The criterion does not apply to this product. | +| **Not Evaluated** | Requires manual testing (keyboard / screen reader / zoom) **that has not been done.** | + +> **A conformance *claim* requires every A/AA criterion to be `Supports` after manual testing.** Today, several rows are `Partially Supports` and the majority are `Not Evaluated`, so **TestPlanIt cannot yet claim WCAG 2.2 AA conformance.** This document is a truthful support inventory + remediation roadmap. + +--- + +## Table 1 — WCAG 2.2 Level A + +| Criterion | Level | Conformance | Remarks | +|---|---|---|---| +| 1.1.1 Non-text Content | A | Supports¹ | No `image-alt` / `input-image-alt` failures. Manual review needed for icon-only and complex images. | +| 1.2.1 Audio/Video-only (Prerecorded) | A | Not Applicable | No prerecorded media as core content. Confirm scope of user-uploaded attachments. | +| 1.2.2 Captions (Prerecorded) | A | Not Applicable | As above. | +| 1.2.3 Audio Description / Alternative | A | Not Applicable | As above. | +| 1.3.1 Info and Relationships | A | Not Evaluated | Automated table/list/form-label checks pass, but landmark and heading structure is incomplete (best-practice flags: missing `
` on 45 routes, no `

` on 77). Manual review required. | +| 1.3.2 Meaningful Sequence | A | Not Evaluated | Manual (DOM/reading order). | +| 1.3.3 Sensory Characteristics | A | Not Evaluated | Manual. | +| 1.4.1 Use of Color | A | Not Evaluated | Manual (status colors, link distinction). | +| 1.4.2 Audio Control | A | Not Applicable | No auto-playing audio. | +| **2.1.1 Keyboard** | A | **Partially Supports** | `scrollable-region-focusable`: 2 elements / 2 routes (scrollable table region not keyboard-focusable). Full keyboard operability otherwise **unverified** (manual). | +| 2.1.2 No Keyboard Trap | A | Not Evaluated | Manual (modals, editors). | +| 2.1.4 Character Key Shortcuts | A | Not Evaluated | Manual. | +| 2.2.1 Timing Adjustable | A | Not Evaluated | Manual (session timeout, toasts). | +| 2.2.2 Pause, Stop, Hide | A | Not Evaluated | Manual (any auto-updating content). | +| 2.3.1 Three Flashes | A | Supports¹ | No flashing content observed. | +| 2.4.1 Bypass Blocks | A | Not Evaluated | Landmark coverage incomplete (best-practice). Skip mechanism needs manual check. | +| 2.4.2 Page Titled | A | Supports¹ | All scanned routes have a ``. | +| 2.4.3 Focus Order | A | Not Evaluated | Manual. | +| **2.4.4 Link Purpose (In Context)** | A | **Partially Supports** | `link-name`: 7 elements / 7 routes (empty folder-path links). | +| 2.5.1 Pointer Gestures | A | Not Evaluated | Manual. | +| 2.5.2 Pointer Cancellation | A | Not Evaluated | Manual. | +| 2.5.3 Label in Name | A | Supports¹ | No `label-in-name` failures. | +| 2.5.4 Motion Actuation | A | Not Applicable | No motion-actuated functionality. | +| 3.1.1 Language of Page | A | Supports¹ | `<html lang>` present on all routes. | +| 3.2.1 On Focus | A | Not Evaluated | Manual. | +| 3.2.2 On Input | A | Not Evaluated | Manual. | +| 3.3.1 Error Identification | A | Not Evaluated | Manual (form validation). | +| **3.3.2 Labels or Instructions** | A | **Partially Supports** | Form inputs without programmatic labels — `label` rule: 9 elements / 6 routes (react-select inputs, OTP fields). Also counts under 4.1.2. | +| 3.3.7 Redundant Entry | A | Not Evaluated | Manual (multi-step flows). | +| 4.1.1 Parsing | A | Supports | **Obsolete/removed in WCAG 2.2** — always satisfied. | +| **4.1.2 Name, Role, Value** | A | **Partially Supports** | The primary remaining gap. `button-name` 400/36 routes, `label` 9/6, `aria-allowed-attr` 7/2, `aria-hidden-focus` 8/4, `nested-interactive` 7/4, `aria-valid-attr-value` 1/1. See roadmap. | + +## Table 2 — WCAG 2.2 Level AA + +| Criterion | Level | Conformance | Remarks | +|---|---|---|---| +| 1.2.4 Captions (Live) | AA | Not Applicable | No live media. | +| 1.2.5 Audio Description (Prerecorded) | AA | Not Applicable | No prerecorded media as core content. | +| 1.3.4 Orientation | AA | Not Evaluated | Manual (no orientation lock expected). | +| 1.3.5 Identify Input Purpose | AA | Supports¹ | No `autocomplete-valid` failures. | +| **1.4.3 Contrast (Minimum)** | AA | **Partially Supports** | `color-contrast`: 48 elements / 12 routes in the Accessible theme. Residual = **data-colored badges/tags** (contrast depends on the user-chosen color) and **disabled controls** (WCAG-exempt; axe still flags). Non-Accessible themes have substantially more. | +| 1.4.4 Resize Text | AA | Not Evaluated | Manual (200% zoom). | +| 1.4.5 Images of Text | AA | Not Evaluated | Manual. | +| 1.4.10 Reflow | AA | Not Evaluated | Manual (400% / 320 CSS px). Wide data tables are a likely risk. | +| 1.4.11 Non-text Contrast | AA | Supports¹ | No failures; Accessible theme strengthens borders, inputs, and focus ring. | +| 1.4.12 Text Spacing | AA | Not Evaluated | Manual. | +| 1.4.13 Content on Hover or Focus | AA | Not Evaluated | Manual (tooltips, popovers). | +| 2.4.5 Multiple Ways | AA | Not Evaluated | Search + navigation present — confirm manually. | +| 2.4.6 Headings and Labels | AA | Not Evaluated | Heading order / missing `<h1>` flagged (best-practice). Manual. | +| 2.4.7 Focus Visible | AA | Not Evaluated | Accessible theme adds a high-contrast focus ring; not axe-verifiable. Manual. | +| 2.4.11 Focus Not Obscured (Min) | AA | Not Evaluated | Manual (sticky headers/toolbars). | +| 2.5.7 Dragging Movements | AA | Not Evaluated | **Likely gap** — the repository folder tree uses drag-and-drop; needs a single-pointer alternative. | +| **2.5.8 Target Size (Minimum)** | AA | **Partially Supports** | `target-size`: 33 elements / 15 routes in the Accessible theme (color-picker swatch, a few compact icon controls). Non-Accessible themes have more. | +| 3.1.2 Language of Parts | AA | Supports¹ | No `valid-lang` failures. | +| 3.2.3 Consistent Navigation | AA | Not Evaluated | Manual. | +| 3.2.4 Consistent Identification | AA | Not Evaluated | Manual. | +| 3.2.6 Consistent Help | AA | Not Evaluated | Manual. | +| 3.3.3 Error Suggestion | AA | Not Evaluated | Manual. | +| 3.3.4 Error Prevention (Legal/Fin/Data) | AA | Not Evaluated | Manual (destructive actions). | +| 3.3.8 Accessible Authentication (Min) | AA | Not Evaluated | **Review needed** — login / SSO / magic-link flows. | +| 4.1.3 Status Messages | AA | Not Evaluated | Manual (toast `role="status"`, live regions). | + +--- + +## Remediation roadmap (to clear the `Partially Supports` rows) + +**4.1.2 Name, Role, Value** — *largest remaining; all shared sources already fixed* +- ✅ Done: avatars, row-action menus, the project sidebar toggle, all 18 admin tables (edit/delete buttons + status toggle switches), shared column-filter operator selects, result-expand, and more. +- ⬜ Remaining (`button-name` 400 els): per-page **visible Selects** (status/page-size filters on ~15 list pages) and **generic icon buttons** on ~21 feature pages — each a one-off `aria-label`. +- ⬜ `aria-allowed-attr` (issue-title trigger), `nested-interactive` (tags page), `aria-hidden-focus` (Radix menu state), `aria-valid-attr-value` (Radix tab) — **structural changes**, held pending design review (they render in all themes). + +**1.4.3 Contrast** — fixed app-wide via the Accessible theme; residual is **data-driven badge colors** (consider enforcing a readable foreground per badge) + exempt disabled controls (document as exception). + +**2.5.8 Target Size** — color-picker swatch + a few compact controls; bump to ≥24px in the Accessible theme override. + +**2.4.4 / 3.3.2** — folder-path link names (2.4.4) and react-select/OTP input labels (3.3.2). + +**2.1.1** — make the scrollable table region keyboard-focusable (`tabindex="0"`). + +--- + +## Manual audit required (to convert `Not Evaluated` → a conformance claim) + +Automated scanning cannot verify these — they gate any AA claim: + +1. **Keyboard-only** operation of every interactive flow (2.1.1, 2.1.2, 2.4.3, 2.1.4). +2. **Screen reader** pass — NVDA + VoiceOver — for names/roles/states, live regions (4.1.3), and reading order (1.3.1, 1.3.2). +3. **Focus visible / not obscured** (2.4.7, 2.4.11) across components. +4. **Reflow & resize** at 400% / 320px and 200% text (1.4.10, 1.4.4, 1.4.12) — wide tables especially. +5. **Drag alternative** for the repository folder tree (2.5.7). +6. **Authentication** flows (3.3.8) — login, SSO, magic link. +7. **Forms & errors** (3.3.1, 3.3.3, 3.3.4) and **on-focus/on-input** behavior (3.2.1, 3.2.2). +8. **Landmarks & headings** — add a single `<main>` and an `<h1>` per page (1.3.1, 2.4.1, 2.4.6). + +--- + +*Generated from the `e2e/a11y` automated baseline (Accessible theme, 79 routes). Re-run `pnpm a11y:scan` and `A11Y_THEME=accessible pnpm a11y:scan` to refresh evidence. This is a working document, not a published ACR — a published VPAT requires the manual audit above and legal/accessibility sign-off.* From 19ac2e0b2bd046208c1f643fabcd94ee9ba513b1 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian <bderman@gmail.com> Date: Fri, 5 Jun 2026 08:12:20 -0500 Subject: [PATCH 20/25] fix(a11y): add accessible names for various UI elements in multiple languages This commit enhances accessibility by adding aria-labels for project selection, panel toggles, folder management, and various text formatting options across multiple language files. These changes ensure that users relying on assistive technologies can better navigate and interact with the application. --- testplanit/messages/de-DE.json | 20 +++++++++++++++++++- testplanit/messages/es-ES.json | 20 +++++++++++++++++++- testplanit/messages/fr-FR.json | 20 +++++++++++++++++++- testplanit/messages/it-IT.json | 20 +++++++++++++++++++- testplanit/messages/ja-JP.json | 20 +++++++++++++++++++- testplanit/messages/ko-KR.json | 20 +++++++++++++++++++- testplanit/messages/nl-NL.json | 20 +++++++++++++++++++- testplanit/messages/pl-PL.json | 20 +++++++++++++++++++- testplanit/messages/pt-BR.json | 20 +++++++++++++++++++- testplanit/messages/ru-RU.json | 20 +++++++++++++++++++- testplanit/messages/tr-TR.json | 20 +++++++++++++++++++- testplanit/messages/vi-VN.json | 20 +++++++++++++++++++- testplanit/messages/zh-CN.json | 20 +++++++++++++++++++- testplanit/messages/zh-TW.json | 20 +++++++++++++++++++- 14 files changed, 266 insertions(+), 14 deletions(-) diff --git a/testplanit/messages/de-DE.json b/testplanit/messages/de-DE.json index 60ee5e960..f26ad35c7 100644 --- a/testplanit/messages/de-DE.json +++ b/testplanit/messages/de-DE.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Benutzermenü", + "selectProject": "Projekt auswählen", + "togglePanel": "Umschaltfeld", + "folder": "Ordner", + "toggleActive": "Aktiven Status umschalten", + "toggleDetails": "Details ein-/ausblenden", + "requiresReview": "Überprüfung erforderlich", "search": "Suchen", "helpMenu": "Hilfemenü", "help": "Helfen", @@ -846,6 +852,15 @@ "noFilesDetected": "Im Ordner wurden keine Dateien gefunden." }, "editor": { + "bold": "Deutlich", + "italic": "Kursiv", + "underline": "Unterstreichen", + "strikethrough": "Durchgestrichen", + "code": "Code", + "heading": "Überschrift", + "bulletList": "Stichpunktliste", + "orderedList": "Nummerierte Liste", + "blockquote": "Zitat", "setColor": "Farbe einstellen", "enterUrl": "Geben Sie die URL einschließlich https:// ein", "uploadFile": "Datei hochladen", @@ -2368,7 +2383,8 @@ "system": "System", "green": "Grün", "purple": "Lila", - "orange": "Orange" + "orange": "Orange", + "accessible": "Zugänglich" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Die Aktualisierung der Erinnerungseinstellungen ist fehlgeschlagen. Bitte versuchen Sie es in Kürze erneut." }, "projectReviewToggleList": { + "toggleAria": "Prüfworkflow für {name} umschalten", "columnHeader": "Arbeitsablauf prüfen", "filterPlaceholder": "Filterprojekte...", "enabledToast": "Prüf-Workflow für {name} aktiviert", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Teststatus verwalten", "editPolicy": { + "modeAria": "Richtlinienmodus für die Ergebnisbearbeitung", "title": "Richtlinie zur Ergebnisbearbeitung", "description": "Legen Sie fest, wie lange nach der Ergebnisspeicherung ein Ergebnis projektübergreifend noch bearbeitet werden kann. Projekte können diese Frist in ihren erweiterten Einstellungen verkürzen, aber nicht verlängern. Systemadministratoren können die Frist jederzeit anpassen.", "modeNone": "Keine Einschränkung (Projekte entscheiden)", diff --git a/testplanit/messages/es-ES.json b/testplanit/messages/es-ES.json index 9322b892b..019d75260 100644 --- a/testplanit/messages/es-ES.json +++ b/testplanit/messages/es-ES.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Menú de usuario", + "selectProject": "Seleccionar proyecto", + "togglePanel": "Panel de alternancia", + "folder": "Carpeta", + "toggleActive": "Alternar estado activo", + "toggleDetails": "Mostrar detalles", + "requiresReview": "Requiere revisión", "search": "Buscar", "helpMenu": "Menú de ayuda", "help": "Ayuda", @@ -846,6 +852,15 @@ "noFilesDetected": "No se detectaron archivos en el drop" }, "editor": { + "bold": "Atrevido", + "italic": "Itálico", + "underline": "Subrayar", + "strikethrough": "Tachado", + "code": "Código", + "heading": "Título", + "bulletList": "Lista con viñetas", + "orderedList": "Lista numerada", + "blockquote": "cita en bloque", "setColor": "Establecer color", "enterUrl": "Introducir URL incluyendo https://", "uploadFile": "Subir archivo", @@ -2368,7 +2383,8 @@ "system": "Sistema", "green": "Verde", "purple": "Morado", - "orange": "Naranja" + "orange": "Naranja", + "accessible": "Accesible" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "No se pudieron actualizar los ajustes de recordatorios. Inténtalo de nuevo en un momento." }, "projectReviewToggleList": { + "toggleAria": "Alternar flujo de trabajo de revisión para {name}", "columnHeader": "Revisar el flujo de trabajo", "filterPlaceholder": "Filtrar proyectos...", "enabledToast": "Flujo de trabajo de revisión habilitado para {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Administrar estados de prueba", "editPolicy": { + "modeAria": "Modo de política de edición de resultados", "title": "Política de edición de resultados", "description": "Controla cuánto tiempo después de registrar un resultado se puede editar directamente en todos los proyectos. Los proyectos pueden ajustar este límite en su configuración avanzada, pero nunca modificarlo. Los administradores del sistema siempre pueden editarlo.", "modeNone": "Sin restricciones (los proyectos deciden)", diff --git a/testplanit/messages/fr-FR.json b/testplanit/messages/fr-FR.json index 018388961..e5b6d3ac4 100644 --- a/testplanit/messages/fr-FR.json +++ b/testplanit/messages/fr-FR.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Menu utilisateur", + "selectProject": "Sélectionner un projet", + "togglePanel": "Basculer le panneau", + "folder": "Dossier", + "toggleActive": "Basculer l'état actif", + "toggleDetails": "Afficher les détails", + "requiresReview": "Nécessite une révision", "search": "Recherche", "helpMenu": "Menu d'aide", "help": "Aide", @@ -846,6 +852,15 @@ "noFilesDetected": "Aucun fichier détecté dans le dossier drop" }, "editor": { + "bold": "Audacieux", + "italic": "Italique", + "underline": "Souligner", + "strikethrough": "barré", + "code": "Code", + "heading": "Titre", + "bulletList": "Liste à puces", + "orderedList": "Liste numérotée", + "blockquote": "Citation", "setColor": "Définir la couleur", "enterUrl": "Saisissez l'URL, y compris https://", "uploadFile": "Téléverser un fichier", @@ -2368,7 +2383,8 @@ "system": "Système", "green": "Vert", "purple": "Violet", - "orange": "Orange" + "orange": "Orange", + "accessible": "Accessible" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Impossible de mettre à jour les paramètres de rappel. Veuillez réessayer dans quelques instants." }, "projectReviewToggleList": { + "toggleAria": "Activer/désactiver le flux de travail de révision pour {name}", "columnHeader": "Flux de travail de révision", "filterPlaceholder": "Filtrer les projets...", "enabledToast": "Flux de travail de révision activé pour {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Gérer les statuts des tests", "editPolicy": { + "modeAria": "Mode de politique de modification des résultats", "title": "Politique de modification des résultats", "description": "Contrôlez la durée pendant laquelle un résultat peut encore être modifié après son enregistrement, pour tous les projets. Les projets peuvent restreindre ce délai dans leurs paramètres avancés, mais ne peuvent jamais l'assouplir. Les administrateurs système peuvent toujours le modifier.", "modeNone": "Aucune restriction (les projets décident)", diff --git a/testplanit/messages/it-IT.json b/testplanit/messages/it-IT.json index dd74ffbbb..5b9af2f5d 100644 --- a/testplanit/messages/it-IT.json +++ b/testplanit/messages/it-IT.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Menu utente", + "selectProject": "Seleziona il progetto", + "togglePanel": "Pannello di attivazione/disattivazione", + "folder": "Cartella", + "toggleActive": "Attiva/disattiva lo stato attivo", + "toggleDetails": "Mostra i dettagli", + "requiresReview": "Richiede revisione", "search": "Ricerca", "helpMenu": "Menu Aiuto", "help": "Aiuto", @@ -846,6 +852,15 @@ "noFilesDetected": "Nessun file rilevato nel drop" }, "editor": { + "bold": "Grassetto", + "italic": "Corsivo", + "underline": "Sottolineare", + "strikethrough": "Barrato", + "code": "Codice", + "heading": "Intestazione", + "bulletList": "Elenco puntato", + "orderedList": "Elenco numerato", + "blockquote": "citazione", "setColor": "Imposta colore", "enterUrl": "Inserisci l'URL incluso https://", "uploadFile": "Carica file", @@ -2368,7 +2383,8 @@ "system": "Sistema", "green": "Verde", "purple": "Viola", - "orange": "Arancia" + "orange": "Arancia", + "accessible": "Accessibile" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Impossibile aggiornare le impostazioni del promemoria. Riprova tra un attimo." }, "projectReviewToggleList": { + "toggleAria": "Attiva/disattiva il flusso di lavoro di revisione per {name}", "columnHeader": "Revisione del flusso di lavoro", "filterPlaceholder": "Filtra i progetti...", "enabledToast": "Revisione del flusso di lavoro abilitata per {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Gestire gli stati dei test", "editPolicy": { + "modeAria": "modalità di modifica dei risultati", "title": "Politica di modifica dei risultati", "description": "Controlla per quanto tempo dopo la registrazione di un risultato è ancora possibile modificarlo, in tutti i progetti. I progetti possono restringere questo limite nelle impostazioni avanzate, ma non possono mai allentarlo. Gli amministratori di sistema possono sempre apportare modifiche.", "modeNone": "Nessuna restrizione (decidono i progetti)", diff --git a/testplanit/messages/ja-JP.json b/testplanit/messages/ja-JP.json index c8e731841..d84d02450 100644 --- a/testplanit/messages/ja-JP.json +++ b/testplanit/messages/ja-JP.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "ユーザーメニュー", + "selectProject": "プロジェクトを選択", + "togglePanel": "パネルの切り替え", + "folder": "フォルダ", + "toggleActive": "アクティブ状態を切り替える", + "toggleDetails": "詳細を表示/非表示にする", + "requiresReview": "審査が必要", "search": "検索", "helpMenu": "ヘルプメニュー", "help": "ヘルプ", @@ -846,6 +852,15 @@ "noFilesDetected": "ドロップでファイルが検出されませんでした" }, "editor": { + "bold": "大胆な", + "italic": "イタリック", + "underline": "下線", + "strikethrough": "取り消し線", + "code": "コード", + "heading": "見出し", + "bulletList": "箇条書き", + "orderedList": "番号付きリスト", + "blockquote": "引用文", "setColor": "色を設定する", "enterUrl": "https://を含むURLを入力してください", "uploadFile": "ファイルをアップロード", @@ -2368,7 +2383,8 @@ "system": "システム", "green": "緑", "purple": "紫", - "orange": "オレンジ" + "orange": "オレンジ", + "accessible": "アクセス可能" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "リマインダー設定の更新に失敗しました。しばらくしてからもう一度お試しください。" }, "projectReviewToggleList": { + "toggleAria": "{name} のレビューワークフローを切り替える", "columnHeader": "レビューワークフロー", "filterPlaceholder": "プロジェクトを絞り込む...", "enabledToast": "レビューワークフローが有効になりました {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "テストステータスの管理", "editPolicy": { + "modeAria": "結果編集ポリシーモード", "title": "結果編集ポリシー", "description": "結果が記録された後、その場で編集できる期間を、すべてのプロジェクトで制御できます。プロジェクトの詳細設定でこの制限を厳しくすることはできますが、緩めることはできません。システム管理者はいつでも編集できます。", "modeNone": "制限なし(プロジェクト次第)", diff --git a/testplanit/messages/ko-KR.json b/testplanit/messages/ko-KR.json index 43297ea2d..43a2f302e 100644 --- a/testplanit/messages/ko-KR.json +++ b/testplanit/messages/ko-KR.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "사용자 메뉴", + "selectProject": "프로젝트를 선택하세요", + "togglePanel": "패널 토글", + "folder": "접는 사람", + "toggleActive": "활성 상태 전환", + "toggleDetails": "세부 정보 토글", + "requiresReview": "검토가 필요합니다", "search": "찾다", "helpMenu": "도움말 메뉴", "help": "돕다", @@ -846,6 +852,15 @@ "noFilesDetected": "드롭에서 파일이 감지되지 않았습니다." }, "editor": { + "bold": "용감한", + "italic": "이탤릭체", + "underline": "밑줄", + "strikethrough": "취소선", + "code": "암호", + "heading": "표제", + "bulletList": "글머리 기호 목록", + "orderedList": "번호가 매겨진 목록", + "blockquote": "인용구", "setColor": "색상 설정", "enterUrl": "https://를 포함한 URL을 입력하세요.", "uploadFile": "파일 업로드", @@ -2368,7 +2383,8 @@ "system": "체계", "green": "녹색", "purple": "보라", - "orange": "주황색" + "orange": "주황색", + "accessible": "얻기 쉬운" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "알림 설정 업데이트에 실패했습니다. 잠시 후 다시 시도해 주세요." }, "projectReviewToggleList": { + "toggleAria": "{name}에 대한 검토 워크플로를 토글합니다.", "columnHeader": "워크플로 검토", "filterPlaceholder": "프로젝트 필터링...", "enabledToast": "{name}에 대한 검토 워크플로가 활성화되었습니다.", @@ -4487,6 +4504,7 @@ "statuses": { "description": "테스트 상태 관리", "editPolicy": { + "modeAria": "결과 편집 정책 모드", "title": "결과 편집 정책", "description": "프로젝트 전체에 걸쳐 결과가 기록된 후 해당 결과를 제자리에서 편집할 수 있는 시간을 제어합니다. 프로젝트는 고급 설정에서 이 시간을 단축할 수 있지만, 단축할 수는 없습니다. 시스템 관리자는 언제든지 이 설정을 편집할 수 있습니다.", "modeNone": "제한 없음 (프로젝트에서 결정)", diff --git a/testplanit/messages/nl-NL.json b/testplanit/messages/nl-NL.json index abe1ef2a8..1a841df88 100644 --- a/testplanit/messages/nl-NL.json +++ b/testplanit/messages/nl-NL.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Gebruikersmenu", + "selectProject": "Selecteer project", + "togglePanel": "Schakelpaneel", + "folder": "Map", + "toggleActive": "Actieve status in-/uitschakelen", + "toggleDetails": "Details weergeven/verbergen", + "requiresReview": "Vereist beoordeling", "search": "Zoekopdracht", "helpMenu": "Helpmenu", "help": "Hulp", @@ -846,6 +852,15 @@ "noFilesDetected": "Geen bestanden gevonden in de drop." }, "editor": { + "bold": "Vetgedrukt", + "italic": "Cursief", + "underline": "Onderstrepen", + "strikethrough": "Doorhalen", + "code": "Code", + "heading": "Koptekst", + "bulletList": "Opsomming", + "orderedList": "Genummerde lijst", + "blockquote": "Blokcitaat", "setColor": "Kleur instellen", "enterUrl": "Voer de URL in, inclusief https://", "uploadFile": "Bestand uploaden", @@ -2368,7 +2383,8 @@ "system": "Systeem", "green": "Groente", "purple": "Paars", - "orange": "Oranje" + "orange": "Oranje", + "accessible": "Toegankelijk" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Het bijwerken van de herinneringsinstellingen is mislukt. Probeer het over een moment opnieuw." }, "projectReviewToggleList": { + "toggleAria": "Schakel de beoordelingsworkflow voor {name} in/uit", "columnHeader": "Werkproces beoordelen", "filterPlaceholder": "Filterprojecten...", "enabledToast": "Review-workflow ingeschakeld voor {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Teststatussen beheren", "editPolicy": { + "modeAria": "Beleidsmodus voor resultaatbewerking", "title": "Beleid voor het bewerken van resultaten", "description": "Bepaal hoe lang na het vastleggen van een resultaat dit nog ter plekke kan worden bewerkt, voor alle projecten. Projecten kunnen deze instelling in de geavanceerde instellingen aanscherpen, maar nooit versoepelen. Systeembeheerders kunnen de instelling altijd wijzigen.", "modeNone": "Geen beperkingen (projecten bepalen dit)", diff --git a/testplanit/messages/pl-PL.json b/testplanit/messages/pl-PL.json index 2670da446..70f060a9a 100644 --- a/testplanit/messages/pl-PL.json +++ b/testplanit/messages/pl-PL.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Menu użytkownika", + "selectProject": "Wybierz projekt", + "togglePanel": "Przełącz panel", + "folder": "Falcówka", + "toggleActive": "Przełącz aktywny status", + "toggleDetails": "Przełącz szczegóły", + "requiresReview": "Wymaga przeglądu", "search": "Szukaj", "helpMenu": "Menu pomocy", "help": "Pomoc", @@ -846,6 +852,15 @@ "noFilesDetected": "Nie wykryto plików podczas upuszczania" }, "editor": { + "bold": "Pogrubiony", + "italic": "italski", + "underline": "Podkreślać", + "strikethrough": "Przekreślenie", + "code": "Kod", + "heading": "Nagłówek", + "bulletList": "Lista punktowana", + "orderedList": "Lista numerowana", + "blockquote": "Cytat blokowy", "setColor": "Ustaw kolor", "enterUrl": "Wprowadź adres URL zawierający https://", "uploadFile": "Prześlij plik", @@ -2368,7 +2383,8 @@ "system": "System", "green": "Zielony", "purple": "Fioletowy", - "orange": "Pomarańczowy" + "orange": "Pomarańczowy", + "accessible": "Dostępny" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Nie udało się zaktualizować ustawień przypomnień. Spróbuj ponownie za chwilę." }, "projectReviewToggleList": { + "toggleAria": "Przełącz przepływ pracy recenzji dla {name}", "columnHeader": "Przejrzyj przepływ pracy", "filterPlaceholder": "Filtruj projekty...", "enabledToast": "Przepływ pracy przeglądu włączony dla {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Zarządzaj statusami testów", "editPolicy": { + "modeAria": "Tryb polityki edycji wyników", "title": "Zasady edycji wyników", "description": "Kontroluj, jak długo po zarejestrowaniu wyniku można go nadal edytować na miejscu, we wszystkich projektach. Projekty mogą zaostrzyć to ustawienie w ustawieniach zaawansowanych, ale nigdy go nie poluzować. Administratorzy systemu zawsze mogą je edytować.", "modeNone": "Brak ograniczeń (decydują projekty)", diff --git a/testplanit/messages/pt-BR.json b/testplanit/messages/pt-BR.json index b487929d7..fbc45ebb3 100644 --- a/testplanit/messages/pt-BR.json +++ b/testplanit/messages/pt-BR.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Menu do usuário", + "selectProject": "Selecione o projeto", + "togglePanel": "Alternar painel", + "folder": "Pasta", + "toggleActive": "Alternar estado ativo", + "toggleDetails": "Mostrar detalhes", + "requiresReview": "Requer revisão", "search": "Procurar", "helpMenu": "Menu de ajuda", "help": "Ajuda", @@ -846,6 +852,15 @@ "noFilesDetected": "Nenhum arquivo detectado na pasta drop" }, "editor": { + "bold": "Audacioso", + "italic": "itálico", + "underline": "Sublinhado", + "strikethrough": "Riscado", + "code": "Código", + "heading": "Cabeçalho", + "bulletList": "Lista com marcadores", + "orderedList": "Lista numerada", + "blockquote": "Citação em bloco", "setColor": "Definir cor", "enterUrl": "Insira o URL incluindo https://", "uploadFile": "Carregar arquivo", @@ -2368,7 +2383,8 @@ "system": "Sistema", "green": "Verde", "purple": "Roxo", - "orange": "Laranja" + "orange": "Laranja", + "accessible": "Acessível" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Falha ao atualizar as configurações de lembrete. Tente novamente em instantes." }, "projectReviewToggleList": { + "toggleAria": "Alternar fluxo de trabalho de revisão para {name}", "columnHeader": "Fluxo de trabalho de revisão", "filterPlaceholder": "Filtrar projetos...", "enabledToast": "Fluxo de trabalho de revisão ativado para {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Gerenciar status de testes", "editPolicy": { + "modeAria": "Modo de política de edição de resultados", "title": "Política de edição de resultados", "description": "Controle por quanto tempo, após o registro de um resultado, ele ainda poderá ser editado, em todos os projetos. Os projetos podem restringir esse limite em suas configurações avançadas, mas nunca o flexibilizar. Os administradores do sistema sempre poderão editar.", "modeNone": "Sem restrições (os projetos decidem)", diff --git a/testplanit/messages/ru-RU.json b/testplanit/messages/ru-RU.json index 95d23d08e..c1a0506bc 100644 --- a/testplanit/messages/ru-RU.json +++ b/testplanit/messages/ru-RU.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Меню пользователя", + "selectProject": "Выберите проект", + "togglePanel": "Переключить панель", + "folder": "Папка", + "toggleActive": "Переключить активный статус", + "toggleDetails": "Показать подробности", + "requiresReview": "Требуется проверка", "search": "Поиск", "helpMenu": "меню справки", "help": "Помощь", @@ -846,6 +852,15 @@ "noFilesDetected": "Файлы в папке не обнаружены." }, "editor": { + "bold": "Смелый", + "italic": "Курсив", + "underline": "Подчеркнуть", + "strikethrough": "Зачеркнуто", + "code": "Код", + "heading": "Заголовок", + "bulletList": "Маркированный список", + "orderedList": "Пронумерованный список", + "blockquote": "Блокцит", "setColor": "Установить цвет", "enterUrl": "Введите URL-адрес, включая https://", "uploadFile": "Загрузить файл", @@ -2368,7 +2383,8 @@ "system": "Система", "green": "Зеленый", "purple": "Фиолетовый", - "orange": "Апельсин" + "orange": "Апельсин", + "accessible": "Доступно" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Не удалось обновить настройки напоминаний. Попробуйте еще раз через минуту." }, "projectReviewToggleList": { + "toggleAria": "Переключить режим проверки для {name}", "columnHeader": "Процесс проверки", "filterPlaceholder": "Фильтр проектов...", "enabledToast": "Включен рабочий процесс проверки для {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Управление статусами тестов", "editPolicy": { + "modeAria": "Режим политики редактирования результатов", "title": "Политика редактирования результатов", "description": "Настройте время, в течение которого результат можно редактировать на месте во всех проектах после его записи. В расширенных настройках проекта этот параметр можно ужесточить, но ослабить его невозможно. Системные администраторы всегда могут редактировать результат.", "modeNone": "Без ограничений (решение принимают сами проекты)", diff --git a/testplanit/messages/tr-TR.json b/testplanit/messages/tr-TR.json index 3e2080e57..9dbe7db77 100644 --- a/testplanit/messages/tr-TR.json +++ b/testplanit/messages/tr-TR.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Kullanıcı menüsü", + "selectProject": "Proje seçin", + "togglePanel": "Paneli açıp kapat", + "folder": "Dosya", + "toggleActive": "Etkin durumu değiştir", + "toggleDetails": "Ayrıntıları göster/gizle", + "requiresReview": "İnceleme gerektiriyor", "search": "Aramak", "helpMenu": "Yardım menüsü", "help": "Yardım", @@ -846,6 +852,15 @@ "noFilesDetected": "Sürükleme işleminde dosya tespit edilmedi." }, "editor": { + "bold": "Gözü pek", + "italic": "İtalik", + "underline": "Altını çiz", + "strikethrough": "Üstü çizili", + "code": "Kod", + "heading": "Başlık", + "bulletList": "Madde işaretli liste", + "orderedList": "Numaralı liste", + "blockquote": "Blok alıntı", "setColor": "Renk Ayarla", "enterUrl": "https:// içeren bir URL girin.", "uploadFile": "Dosya Yükle", @@ -2368,7 +2383,8 @@ "system": "Sistem", "green": "Yeşil", "purple": "Mor", - "orange": "Turuncu" + "orange": "Turuncu", + "accessible": "Erişilebilir" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Hatırlatma ayarları güncellenemedi. Lütfen birazdan tekrar deneyin." }, "projectReviewToggleList": { + "toggleAria": "{name} için inceleme iş akışını aç/kapat", "columnHeader": "İş akışını gözden geçirin", "filterPlaceholder": "Filtreleme projeleri...", "enabledToast": "{name} için inceleme iş akışı etkinleştirildi.", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Test durumlarını yönetin", "editPolicy": { + "modeAria": "Sonuç düzenleme politikası modu", "title": "Sonuç düzenleme politikası", "description": "Sonuç kaydedildikten sonra ne kadar süre geçtiğini kontrol edin; sonuç tüm projelerde yerinde düzenlenebilir. Projeler bunu Gelişmiş ayarlarından sıkılaştırabilir ancak asla gevşetemezler. Sistem yöneticileri her zaman düzenleme yapabilir.", "modeNone": "Herhangi bir kısıtlama yok (projeler karar verir)", diff --git a/testplanit/messages/vi-VN.json b/testplanit/messages/vi-VN.json index 5e5866be9..286e8c8a2 100644 --- a/testplanit/messages/vi-VN.json +++ b/testplanit/messages/vi-VN.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "Menu người dùng", + "selectProject": "Chọn dự án", + "togglePanel": "Bảng chuyển đổi", + "folder": "Thư mục", + "toggleActive": "Bật/tắt trạng thái hoạt động", + "toggleDetails": "Ẩn/Hiện chi tiết", + "requiresReview": "Cần xem xét lại", "search": "Tìm kiếm", "helpMenu": "Menu trợ giúp", "help": "Giúp đỡ", @@ -846,6 +852,15 @@ "noFilesDetected": "Không tìm thấy tệp nào trong thư mục drop" }, "editor": { + "bold": "In đậm", + "italic": "Chữ nghiêng", + "underline": "Gạch chân", + "strikethrough": "Gạch ngang", + "code": "Mã số", + "heading": "Tiêu đề", + "bulletList": "Danh sách gạch đầu dòng", + "orderedList": "Danh sách được đánh số", + "blockquote": "Trích dẫn", "setColor": "Đặt màu", "enterUrl": "Nhập URL bao gồm https://", "uploadFile": "Tải lên tệp", @@ -2368,7 +2383,8 @@ "system": "Hệ thống", "green": "Màu xanh lá", "purple": "Màu tím", - "orange": "Quả cam" + "orange": "Quả cam", + "accessible": "Có thể truy cập" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "Không thể cập nhật cài đặt nhắc nhở. Vui lòng thử lại sau." }, "projectReviewToggleList": { + "toggleAria": "Bật/tắt quy trình xem xét cho {name}", "columnHeader": "Quy trình xem xét", "filterPlaceholder": "Lọc các dự án...", "enabledToast": "Quy trình xem xét đã được bật cho {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "Quản lý trạng thái kiểm thử", "editPolicy": { + "modeAria": "Chế độ chính sách chỉnh sửa kết quả", "title": "Chính sách chỉnh sửa kết quả", "description": "Kiểm soát khoảng thời gian sau khi kết quả được ghi lại mà vẫn có thể chỉnh sửa trực tiếp, trên tất cả các dự án. Các dự án có thể điều chỉnh thời gian này trong cài đặt Nâng cao nhưng không thể nới lỏng. Quản trị viên hệ thống luôn có thể chỉnh sửa.", "modeNone": "Không có hạn chế (dự án tự quyết định)", diff --git a/testplanit/messages/zh-CN.json b/testplanit/messages/zh-CN.json index 5169b1b2d..3da0d3660 100644 --- a/testplanit/messages/zh-CN.json +++ b/testplanit/messages/zh-CN.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "用户菜单", + "selectProject": "选择项目", + "togglePanel": "切换面板", + "folder": "文件夹", + "toggleActive": "切换激活状态", + "toggleDetails": "切换详情", + "requiresReview": "需要审核", "search": "搜索", "helpMenu": "帮助菜单", "help": "帮助", @@ -846,6 +852,15 @@ "noFilesDetected": "未检测到 Drop 文件夹中的任何文件。" }, "editor": { + "bold": "大胆的", + "italic": "斜体", + "underline": "强调", + "strikethrough": "删除线", + "code": "代码", + "heading": "标题", + "bulletList": "要点列表", + "orderedList": "编号列表", + "blockquote": "引用", "setColor": "设置颜色", "enterUrl": "请输入包含 https:// 的网址。", "uploadFile": "上传文件", @@ -2368,7 +2383,8 @@ "system": "系统", "green": "绿色的", "purple": "紫色的", - "orange": "橙子" + "orange": "橙子", + "accessible": "无障碍" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "更新提醒设置失败,请稍后再试。" }, "projectReviewToggleList": { + "toggleAria": "切换 {name} 的审核工作流程", "columnHeader": "审核工作流程", "filterPlaceholder": "筛选项目...", "enabledToast": "已启用审核工作流程 {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "管理测试状态", "editPolicy": { + "modeAria": "结果编辑策略模式", "title": "结果编辑政策", "description": "控制结果记录后多长时间内仍可进行原地编辑,此限制适用于所有项目。项目可以在其高级设置中收紧此限制,但无法放宽。系统管理员始终可以进行编辑。", "modeNone": "无限制(项目决定)", diff --git a/testplanit/messages/zh-TW.json b/testplanit/messages/zh-TW.json index 58b3e94bb..9fc6c0b42 100644 --- a/testplanit/messages/zh-TW.json +++ b/testplanit/messages/zh-TW.json @@ -690,6 +690,12 @@ }, "aria": { "userMenu": "使用者選單", + "selectProject": "選擇項目", + "togglePanel": "切換面板", + "folder": "資料夾", + "toggleActive": "切換啟動狀態", + "toggleDetails": "切換詳情", + "requiresReview": "需要審核", "search": "搜尋", "helpMenu": "幫助菜單", "help": "幫助", @@ -846,6 +852,15 @@ "noFilesDetected": "未偵測到 Drop 資料夾中的任何檔案。" }, "editor": { + "bold": "大膽的", + "italic": "斜體", + "underline": "強調", + "strikethrough": "刪除線", + "code": "程式碼", + "heading": "標題", + "bulletList": "重點清單", + "orderedList": "編號列表", + "blockquote": "引用", "setColor": "設定顏色", "enterUrl": "請輸入包含 https:// 的網址。", "uploadFile": "上傳文件", @@ -2368,7 +2383,8 @@ "system": "系統", "green": "綠色的", "purple": "紫色的", - "orange": "橘子" + "orange": "橘子", + "accessible": "無障礙" } }, "repository": { @@ -4418,6 +4434,7 @@ "thresholdUpdateError": "更新提醒設定失敗,請稍後再試。" }, "projectReviewToggleList": { + "toggleAria": "切換 {name} 的審核工作流程", "columnHeader": "審核工作流程", "filterPlaceholder": "篩選項目...", "enabledToast": "已啟用審核工作流程 {name}", @@ -4487,6 +4504,7 @@ "statuses": { "description": "管理測試狀態", "editPolicy": { + "modeAria": "結果編輯策略模式", "title": "結果編輯政策", "description": "控制結果記錄後多長時間內仍可進行原地編輯,此限制適用於所有項目。項目可以在其高級設定中收緊此限制,但無法放寬。系統管理員始終可以進行編輯。", "modeNone": "無限制(專案決定)", From 78776f0487d86bf8de8a799befa72f33333554f6 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian <bderman@gmail.com> Date: Fri, 5 Jun 2026 08:19:56 -0500 Subject: [PATCH 21/25] fix(a11y): show Accessible theme icon in profile and onboarding pickers The Accessible theme option appeared in the profile Preferences page and the initial-preferences onboarding dialog but rendered without an icon, since both getThemeIcon/getThemeColor switches lacked an Accessible case. Add the Accessibility icon (matching the user menu) and clear the accessible class in the onboarding theme-preview fallback. --- testplanit/app/[locale]/users/profile/[userId]/page.tsx | 5 +++++ .../components/onboarding/InitialPreferencesDialog.tsx | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/testplanit/app/[locale]/users/profile/[userId]/page.tsx b/testplanit/app/[locale]/users/profile/[userId]/page.tsx index 4ab988daf..3be9dcde8 100644 --- a/testplanit/app/[locale]/users/profile/[userId]/page.tsx +++ b/testplanit/app/[locale]/users/profile/[userId]/page.tsx @@ -56,6 +56,7 @@ import { } from "@prisma/client"; import { useQueryClient } from "@tanstack/react-query"; import { + Accessibility, Check, Circle, Moon, @@ -376,6 +377,8 @@ const UserProfile: React.FC<UserProfileProps> = ({ return <Circle className="h-4 w-4 fill-orange-500" />; case "Purple": return <Circle className="h-4 w-4 fill-purple-500" />; + case "Accessible": + return <Accessibility className="h-4 w-4 text-blue-700" />; default: return <Circle className="h-4 w-4" />; } @@ -395,6 +398,8 @@ const UserProfile: React.FC<UserProfileProps> = ({ return "text-orange-500"; case "Purple": return "text-purple-500"; + case "Accessible": + return "text-blue-700"; default: return ""; } diff --git a/testplanit/components/onboarding/InitialPreferencesDialog.tsx b/testplanit/components/onboarding/InitialPreferencesDialog.tsx index 98abb48d5..530a3a822 100644 --- a/testplanit/components/onboarding/InitialPreferencesDialog.tsx +++ b/testplanit/components/onboarding/InitialPreferencesDialog.tsx @@ -36,7 +36,7 @@ import { Theme, TimeFormat, } from "@prisma/client"; -import { Circle, Moon, Sun, SunMoon } from "lucide-react"; +import { Accessibility, Circle, Moon, Sun, SunMoon } from "lucide-react"; import { useSession } from "next-auth/react"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -205,6 +205,8 @@ export function InitialPreferencesDialog() { return <Circle className="h-4 w-4 fill-orange-500" />; case "Purple": return <Circle className="h-4 w-4 fill-purple-500" />; + case "Accessible": + return <Accessibility className="h-4 w-4 text-blue-700" />; default: return <Circle className="h-4 w-4" />; } @@ -224,6 +226,8 @@ export function InitialPreferencesDialog() { return "text-orange-500"; case "Purple": return "text-purple-500"; + case "Accessible": + return "text-blue-700"; default: return ""; } @@ -248,7 +252,8 @@ export function InitialPreferencesDialog() { "system", "green", "orange", - "purple" + "purple", + "accessible" ); // Add the new theme class html.classList.add(themeLower); From 03086b1f6a8eb7dcb5c384f91c5c8b18ab3e7bc1 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian <bderman@gmail.com> Date: Fri, 5 Jun 2026 08:28:20 -0500 Subject: [PATCH 22/25] fix(a11y): escape backslashes in report Markdown cells The report cell escaper handled the pipe delimiter but not the backslash itself, so a backslash in axe output could corrupt table rendering. Escape backslashes first, then pipes, and normalize CRLF newlines. --- testplanit/e2e/a11y/aggregate.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testplanit/e2e/a11y/aggregate.ts b/testplanit/e2e/a11y/aggregate.ts index 612ab6ebe..6b40cdd4c 100644 --- a/testplanit/e2e/a11y/aggregate.ts +++ b/testplanit/e2e/a11y/aggregate.ts @@ -136,7 +136,11 @@ function dedupe( } function esc(s: string): string { - return s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim(); + return s + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim(); } function codeFence(s: string): string { return "`" + s.replace(/`/g, "ʼ").slice(0, 200) + "`"; From 3315283dcd33f4eed21106aae031f9647b5a146d Mon Sep 17 00:00:00 2001 From: Brad DerManouelian <bderman@gmail.com> Date: Fri, 5 Jun 2026 09:15:59 -0500 Subject: [PATCH 23/25] test(a11y): update Avatar test for the asChild tooltip trigger The avatar tooltip now uses asChild, so the avatar element is the trigger itself rather than being wrapped in a nameless button. Assert the accessible contract (no button ancestor) instead of the old wrapper-button DOM shape. --- testplanit/components/Avatar.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/testplanit/components/Avatar.test.tsx b/testplanit/components/Avatar.test.tsx index bf8f9110c..267f446ad 100644 --- a/testplanit/components/Avatar.test.tsx +++ b/testplanit/components/Avatar.test.tsx @@ -47,17 +47,21 @@ describe("Avatar Component", () => { expect(textElement).toHaveStyle({ fontSize: expectedFontSize }); }); - it("should render with a tooltip by default", () => { + it("should render the avatar as the tooltip trigger without a nameless button", () => { render(<Avatar image={defaultImage} alt={defaultAlt} />); const img = screen.getByRole("img"); - expect(img.parentElement?.parentElement?.tagName).toBe("BUTTON"); + // a11y: the avatar element itself is the tooltip trigger (asChild), so it is + // never wrapped in a nameless <button> that would nest inside links/cells. + expect(img).toBeInTheDocument(); + expect(img.closest("button")).toBeNull(); }); - it("should render without a tooltip if showTooltip is false", () => { + it("should render the bare avatar when showTooltip is false", () => { render( <Avatar image={defaultImage} alt={defaultAlt} showTooltip={false} /> ); const img = screen.getByRole("img"); - expect(img.parentElement?.parentElement?.tagName).not.toBe("BUTTON"); + expect(img).toBeInTheDocument(); + expect(img.closest("button")).toBeNull(); }); }); From f102795851453d5c3e313b12c551280ce45e449d Mon Sep 17 00:00:00 2001 From: Brad DerManouelian <bderman@gmail.com> Date: Fri, 5 Jun 2026 09:15:59 -0500 Subject: [PATCH 24/25] chore(a11y): satisfy lint and prettier on the scan harness Drop the unused baseURL test arg (no-unused-vars) and apply Prettier formatting to the harness files flagged by format:check. --- testplanit/e2e/a11y/README.md | 12 +- testplanit/e2e/a11y/VPAT-WCAG22-AA.md | 154 ++--- testplanit/e2e/a11y/playwright.config.ts | 8 +- testplanit/e2e/a11y/routes.ts | 680 ++++++++++++++++++++--- testplanit/e2e/a11y/run.ts | 12 +- testplanit/e2e/a11y/scan.spec.ts | 6 +- testplanit/e2e/a11y/wcag.ts | 55 +- 7 files changed, 750 insertions(+), 177 deletions(-) diff --git a/testplanit/e2e/a11y/README.md b/testplanit/e2e/a11y/README.md index 2d2eaddb5..b1fd605f4 100644 --- a/testplanit/e2e/a11y/README.md +++ b/testplanit/e2e/a11y/README.md @@ -48,12 +48,12 @@ already-running server; `reuseExistingServer` is on). ## Outputs (all gitignored) -| Path | Contents | -| --- | --- | -| `results/<route>.json` | Raw axe result per route + interactive state | -| `results/report.md` | Deduped report grouped by WCAG success criterion | -| `results/report.json` | Machine-readable rollup | -| `.a11y-fixtures.json` | Seeded entity IDs for this run | +| Path | Contents | +| ---------------------- | ------------------------------------------------ | +| `results/<route>.json` | Raw axe result per route + interactive state | +| `results/report.md` | Deduped report grouped by WCAG success criterion | +| `results/report.json` | Machine-readable rollup | +| `.a11y-fixtures.json` | Seeded entity IDs for this run | ## Adding / changing routes diff --git a/testplanit/e2e/a11y/VPAT-WCAG22-AA.md b/testplanit/e2e/a11y/VPAT-WCAG22-AA.md index 171374283..a4612a06f 100644 --- a/testplanit/e2e/a11y/VPAT-WCAG22-AA.md +++ b/testplanit/e2e/a11y/VPAT-WCAG22-AA.md @@ -1,15 +1,16 @@ # Accessibility Conformance Report — TestPlanIt + ### WCAG 2.2 Level A & AA (VPAT® 2.5 INT — WCAG chapter) **Status: DRAFT — automated baseline only. NOT a conformance claim.** -| | | -|---|---| -| **Product** | TestPlanIt (web application) | -| **Report date** | 2026-06-05 | -| **Evaluated against** | WCAG 2.2 Levels A and AA | +| | | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Product** | TestPlanIt (web application) | +| **Report date** | 2026-06-05 | +| **Evaluated against** | WCAG 2.2 Levels A and AA | | **Evaluation methods** | Automated: axe-core via the `e2e/a11y` Playwright harness, **79 of 80 application routes** scanned as an authenticated admin against seeded data, in the **"Accessible" theme**. Manual testing: **not yet performed.** | -| **Configuration note** | Results reflect the opt-in **Accessible theme**. Other themes show additional 1.4.3/2.5.8 issues. | +| **Configuration note** | Results reflect the opt-in **Accessible theme**. Other themes show additional 1.4.3/2.5.8 issues. | --- @@ -17,89 +18,90 @@ Automated tooling (axe-core) reliably evaluates only ~30–40% of WCAG success criteria. **This report is a floor, not a conformance determination.** -| Term | Meaning here | -|---|---| -| **Supports** | No automated failures detected. **¹ Pending manual confirmation** — not a verified pass. | -| **Partially Supports** | Automated testing found failures (evidence in Remarks). | -| **Does Not Support** | Majority of functionality fails (none currently). | -| **Not Applicable** | The criterion does not apply to this product. | -| **Not Evaluated** | Requires manual testing (keyboard / screen reader / zoom) **that has not been done.** | +| Term | Meaning here | +| ---------------------- | ---------------------------------------------------------------------------------------- | +| **Supports** | No automated failures detected. **¹ Pending manual confirmation** — not a verified pass. | +| **Partially Supports** | Automated testing found failures (evidence in Remarks). | +| **Does Not Support** | Majority of functionality fails (none currently). | +| **Not Applicable** | The criterion does not apply to this product. | +| **Not Evaluated** | Requires manual testing (keyboard / screen reader / zoom) **that has not been done.** | -> **A conformance *claim* requires every A/AA criterion to be `Supports` after manual testing.** Today, several rows are `Partially Supports` and the majority are `Not Evaluated`, so **TestPlanIt cannot yet claim WCAG 2.2 AA conformance.** This document is a truthful support inventory + remediation roadmap. +> **A conformance _claim_ requires every A/AA criterion to be `Supports` after manual testing.** Today, several rows are `Partially Supports` and the majority are `Not Evaluated`, so **TestPlanIt cannot yet claim WCAG 2.2 AA conformance.** This document is a truthful support inventory + remediation roadmap. --- ## Table 1 — WCAG 2.2 Level A -| Criterion | Level | Conformance | Remarks | -|---|---|---|---| -| 1.1.1 Non-text Content | A | Supports¹ | No `image-alt` / `input-image-alt` failures. Manual review needed for icon-only and complex images. | -| 1.2.1 Audio/Video-only (Prerecorded) | A | Not Applicable | No prerecorded media as core content. Confirm scope of user-uploaded attachments. | -| 1.2.2 Captions (Prerecorded) | A | Not Applicable | As above. | -| 1.2.3 Audio Description / Alternative | A | Not Applicable | As above. | -| 1.3.1 Info and Relationships | A | Not Evaluated | Automated table/list/form-label checks pass, but landmark and heading structure is incomplete (best-practice flags: missing `<main>` on 45 routes, no `<h1>` on 77). Manual review required. | -| 1.3.2 Meaningful Sequence | A | Not Evaluated | Manual (DOM/reading order). | -| 1.3.3 Sensory Characteristics | A | Not Evaluated | Manual. | -| 1.4.1 Use of Color | A | Not Evaluated | Manual (status colors, link distinction). | -| 1.4.2 Audio Control | A | Not Applicable | No auto-playing audio. | -| **2.1.1 Keyboard** | A | **Partially Supports** | `scrollable-region-focusable`: 2 elements / 2 routes (scrollable table region not keyboard-focusable). Full keyboard operability otherwise **unverified** (manual). | -| 2.1.2 No Keyboard Trap | A | Not Evaluated | Manual (modals, editors). | -| 2.1.4 Character Key Shortcuts | A | Not Evaluated | Manual. | -| 2.2.1 Timing Adjustable | A | Not Evaluated | Manual (session timeout, toasts). | -| 2.2.2 Pause, Stop, Hide | A | Not Evaluated | Manual (any auto-updating content). | -| 2.3.1 Three Flashes | A | Supports¹ | No flashing content observed. | -| 2.4.1 Bypass Blocks | A | Not Evaluated | Landmark coverage incomplete (best-practice). Skip mechanism needs manual check. | -| 2.4.2 Page Titled | A | Supports¹ | All scanned routes have a `<title>`. | -| 2.4.3 Focus Order | A | Not Evaluated | Manual. | -| **2.4.4 Link Purpose (In Context)** | A | **Partially Supports** | `link-name`: 7 elements / 7 routes (empty folder-path links). | -| 2.5.1 Pointer Gestures | A | Not Evaluated | Manual. | -| 2.5.2 Pointer Cancellation | A | Not Evaluated | Manual. | -| 2.5.3 Label in Name | A | Supports¹ | No `label-in-name` failures. | -| 2.5.4 Motion Actuation | A | Not Applicable | No motion-actuated functionality. | -| 3.1.1 Language of Page | A | Supports¹ | `<html lang>` present on all routes. | -| 3.2.1 On Focus | A | Not Evaluated | Manual. | -| 3.2.2 On Input | A | Not Evaluated | Manual. | -| 3.3.1 Error Identification | A | Not Evaluated | Manual (form validation). | -| **3.3.2 Labels or Instructions** | A | **Partially Supports** | Form inputs without programmatic labels — `label` rule: 9 elements / 6 routes (react-select inputs, OTP fields). Also counts under 4.1.2. | -| 3.3.7 Redundant Entry | A | Not Evaluated | Manual (multi-step flows). | -| 4.1.1 Parsing | A | Supports | **Obsolete/removed in WCAG 2.2** — always satisfied. | -| **4.1.2 Name, Role, Value** | A | **Partially Supports** | The primary remaining gap. `button-name` 400/36 routes, `label` 9/6, `aria-allowed-attr` 7/2, `aria-hidden-focus` 8/4, `nested-interactive` 7/4, `aria-valid-attr-value` 1/1. See roadmap. | +| Criterion | Level | Conformance | Remarks | +| ------------------------------------- | ----- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1.1 Non-text Content | A | Supports¹ | No `image-alt` / `input-image-alt` failures. Manual review needed for icon-only and complex images. | +| 1.2.1 Audio/Video-only (Prerecorded) | A | Not Applicable | No prerecorded media as core content. Confirm scope of user-uploaded attachments. | +| 1.2.2 Captions (Prerecorded) | A | Not Applicable | As above. | +| 1.2.3 Audio Description / Alternative | A | Not Applicable | As above. | +| 1.3.1 Info and Relationships | A | Not Evaluated | Automated table/list/form-label checks pass, but landmark and heading structure is incomplete (best-practice flags: missing `<main>` on 45 routes, no `<h1>` on 77). Manual review required. | +| 1.3.2 Meaningful Sequence | A | Not Evaluated | Manual (DOM/reading order). | +| 1.3.3 Sensory Characteristics | A | Not Evaluated | Manual. | +| 1.4.1 Use of Color | A | Not Evaluated | Manual (status colors, link distinction). | +| 1.4.2 Audio Control | A | Not Applicable | No auto-playing audio. | +| **2.1.1 Keyboard** | A | **Partially Supports** | `scrollable-region-focusable`: 2 elements / 2 routes (scrollable table region not keyboard-focusable). Full keyboard operability otherwise **unverified** (manual). | +| 2.1.2 No Keyboard Trap | A | Not Evaluated | Manual (modals, editors). | +| 2.1.4 Character Key Shortcuts | A | Not Evaluated | Manual. | +| 2.2.1 Timing Adjustable | A | Not Evaluated | Manual (session timeout, toasts). | +| 2.2.2 Pause, Stop, Hide | A | Not Evaluated | Manual (any auto-updating content). | +| 2.3.1 Three Flashes | A | Supports¹ | No flashing content observed. | +| 2.4.1 Bypass Blocks | A | Not Evaluated | Landmark coverage incomplete (best-practice). Skip mechanism needs manual check. | +| 2.4.2 Page Titled | A | Supports¹ | All scanned routes have a `<title>`. | +| 2.4.3 Focus Order | A | Not Evaluated | Manual. | +| **2.4.4 Link Purpose (In Context)** | A | **Partially Supports** | `link-name`: 7 elements / 7 routes (empty folder-path links). | +| 2.5.1 Pointer Gestures | A | Not Evaluated | Manual. | +| 2.5.2 Pointer Cancellation | A | Not Evaluated | Manual. | +| 2.5.3 Label in Name | A | Supports¹ | No `label-in-name` failures. | +| 2.5.4 Motion Actuation | A | Not Applicable | No motion-actuated functionality. | +| 3.1.1 Language of Page | A | Supports¹ | `<html lang>` present on all routes. | +| 3.2.1 On Focus | A | Not Evaluated | Manual. | +| 3.2.2 On Input | A | Not Evaluated | Manual. | +| 3.3.1 Error Identification | A | Not Evaluated | Manual (form validation). | +| **3.3.2 Labels or Instructions** | A | **Partially Supports** | Form inputs without programmatic labels — `label` rule: 9 elements / 6 routes (react-select inputs, OTP fields). Also counts under 4.1.2. | +| 3.3.7 Redundant Entry | A | Not Evaluated | Manual (multi-step flows). | +| 4.1.1 Parsing | A | Supports | **Obsolete/removed in WCAG 2.2** — always satisfied. | +| **4.1.2 Name, Role, Value** | A | **Partially Supports** | The primary remaining gap. `button-name` 400/36 routes, `label` 9/6, `aria-allowed-attr` 7/2, `aria-hidden-focus` 8/4, `nested-interactive` 7/4, `aria-valid-attr-value` 1/1. See roadmap. | ## Table 2 — WCAG 2.2 Level AA -| Criterion | Level | Conformance | Remarks | -|---|---|---|---| -| 1.2.4 Captions (Live) | AA | Not Applicable | No live media. | -| 1.2.5 Audio Description (Prerecorded) | AA | Not Applicable | No prerecorded media as core content. | -| 1.3.4 Orientation | AA | Not Evaluated | Manual (no orientation lock expected). | -| 1.3.5 Identify Input Purpose | AA | Supports¹ | No `autocomplete-valid` failures. | -| **1.4.3 Contrast (Minimum)** | AA | **Partially Supports** | `color-contrast`: 48 elements / 12 routes in the Accessible theme. Residual = **data-colored badges/tags** (contrast depends on the user-chosen color) and **disabled controls** (WCAG-exempt; axe still flags). Non-Accessible themes have substantially more. | -| 1.4.4 Resize Text | AA | Not Evaluated | Manual (200% zoom). | -| 1.4.5 Images of Text | AA | Not Evaluated | Manual. | -| 1.4.10 Reflow | AA | Not Evaluated | Manual (400% / 320 CSS px). Wide data tables are a likely risk. | -| 1.4.11 Non-text Contrast | AA | Supports¹ | No failures; Accessible theme strengthens borders, inputs, and focus ring. | -| 1.4.12 Text Spacing | AA | Not Evaluated | Manual. | -| 1.4.13 Content on Hover or Focus | AA | Not Evaluated | Manual (tooltips, popovers). | -| 2.4.5 Multiple Ways | AA | Not Evaluated | Search + navigation present — confirm manually. | -| 2.4.6 Headings and Labels | AA | Not Evaluated | Heading order / missing `<h1>` flagged (best-practice). Manual. | -| 2.4.7 Focus Visible | AA | Not Evaluated | Accessible theme adds a high-contrast focus ring; not axe-verifiable. Manual. | -| 2.4.11 Focus Not Obscured (Min) | AA | Not Evaluated | Manual (sticky headers/toolbars). | -| 2.5.7 Dragging Movements | AA | Not Evaluated | **Likely gap** — the repository folder tree uses drag-and-drop; needs a single-pointer alternative. | -| **2.5.8 Target Size (Minimum)** | AA | **Partially Supports** | `target-size`: 33 elements / 15 routes in the Accessible theme (color-picker swatch, a few compact icon controls). Non-Accessible themes have more. | -| 3.1.2 Language of Parts | AA | Supports¹ | No `valid-lang` failures. | -| 3.2.3 Consistent Navigation | AA | Not Evaluated | Manual. | -| 3.2.4 Consistent Identification | AA | Not Evaluated | Manual. | -| 3.2.6 Consistent Help | AA | Not Evaluated | Manual. | -| 3.3.3 Error Suggestion | AA | Not Evaluated | Manual. | -| 3.3.4 Error Prevention (Legal/Fin/Data) | AA | Not Evaluated | Manual (destructive actions). | -| 3.3.8 Accessible Authentication (Min) | AA | Not Evaluated | **Review needed** — login / SSO / magic-link flows. | -| 4.1.3 Status Messages | AA | Not Evaluated | Manual (toast `role="status"`, live regions). | +| Criterion | Level | Conformance | Remarks | +| --------------------------------------- | ----- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.2.4 Captions (Live) | AA | Not Applicable | No live media. | +| 1.2.5 Audio Description (Prerecorded) | AA | Not Applicable | No prerecorded media as core content. | +| 1.3.4 Orientation | AA | Not Evaluated | Manual (no orientation lock expected). | +| 1.3.5 Identify Input Purpose | AA | Supports¹ | No `autocomplete-valid` failures. | +| **1.4.3 Contrast (Minimum)** | AA | **Partially Supports** | `color-contrast`: 48 elements / 12 routes in the Accessible theme. Residual = **data-colored badges/tags** (contrast depends on the user-chosen color) and **disabled controls** (WCAG-exempt; axe still flags). Non-Accessible themes have substantially more. | +| 1.4.4 Resize Text | AA | Not Evaluated | Manual (200% zoom). | +| 1.4.5 Images of Text | AA | Not Evaluated | Manual. | +| 1.4.10 Reflow | AA | Not Evaluated | Manual (400% / 320 CSS px). Wide data tables are a likely risk. | +| 1.4.11 Non-text Contrast | AA | Supports¹ | No failures; Accessible theme strengthens borders, inputs, and focus ring. | +| 1.4.12 Text Spacing | AA | Not Evaluated | Manual. | +| 1.4.13 Content on Hover or Focus | AA | Not Evaluated | Manual (tooltips, popovers). | +| 2.4.5 Multiple Ways | AA | Not Evaluated | Search + navigation present — confirm manually. | +| 2.4.6 Headings and Labels | AA | Not Evaluated | Heading order / missing `<h1>` flagged (best-practice). Manual. | +| 2.4.7 Focus Visible | AA | Not Evaluated | Accessible theme adds a high-contrast focus ring; not axe-verifiable. Manual. | +| 2.4.11 Focus Not Obscured (Min) | AA | Not Evaluated | Manual (sticky headers/toolbars). | +| 2.5.7 Dragging Movements | AA | Not Evaluated | **Likely gap** — the repository folder tree uses drag-and-drop; needs a single-pointer alternative. | +| **2.5.8 Target Size (Minimum)** | AA | **Partially Supports** | `target-size`: 33 elements / 15 routes in the Accessible theme (color-picker swatch, a few compact icon controls). Non-Accessible themes have more. | +| 3.1.2 Language of Parts | AA | Supports¹ | No `valid-lang` failures. | +| 3.2.3 Consistent Navigation | AA | Not Evaluated | Manual. | +| 3.2.4 Consistent Identification | AA | Not Evaluated | Manual. | +| 3.2.6 Consistent Help | AA | Not Evaluated | Manual. | +| 3.3.3 Error Suggestion | AA | Not Evaluated | Manual. | +| 3.3.4 Error Prevention (Legal/Fin/Data) | AA | Not Evaluated | Manual (destructive actions). | +| 3.3.8 Accessible Authentication (Min) | AA | Not Evaluated | **Review needed** — login / SSO / magic-link flows. | +| 4.1.3 Status Messages | AA | Not Evaluated | Manual (toast `role="status"`, live regions). | --- ## Remediation roadmap (to clear the `Partially Supports` rows) -**4.1.2 Name, Role, Value** — *largest remaining; all shared sources already fixed* +**4.1.2 Name, Role, Value** — _largest remaining; all shared sources already fixed_ + - ✅ Done: avatars, row-action menus, the project sidebar toggle, all 18 admin tables (edit/delete buttons + status toggle switches), shared column-filter operator selects, result-expand, and more. - ⬜ Remaining (`button-name` 400 els): per-page **visible Selects** (status/page-size filters on ~15 list pages) and **generic icon buttons** on ~21 feature pages — each a one-off `aria-label`. - ⬜ `aria-allowed-attr` (issue-title trigger), `nested-interactive` (tags page), `aria-hidden-focus` (Radix menu state), `aria-valid-attr-value` (Radix tab) — **structural changes**, held pending design review (they render in all themes). @@ -129,4 +131,4 @@ Automated scanning cannot verify these — they gate any AA claim: --- -*Generated from the `e2e/a11y` automated baseline (Accessible theme, 79 routes). Re-run `pnpm a11y:scan` and `A11Y_THEME=accessible pnpm a11y:scan` to refresh evidence. This is a working document, not a published ACR — a published VPAT requires the manual audit above and legal/accessibility sign-off.* +_Generated from the `e2e/a11y` automated baseline (Accessible theme, 79 routes). Re-run `pnpm a11y:scan` and `A11Y_THEME=accessible pnpm a11y:scan` to refresh evidence. This is a working document, not a published ACR — a published VPAT requires the manual audit above and legal/accessibility sign-off._ diff --git a/testplanit/e2e/a11y/playwright.config.ts b/testplanit/e2e/a11y/playwright.config.ts index 4dfbe40c1..e25c3dc67 100644 --- a/testplanit/e2e/a11y/playwright.config.ts +++ b/testplanit/e2e/a11y/playwright.config.ts @@ -23,7 +23,13 @@ export default defineConfig({ reporter: [ ["list"], - ["html", { outputFolder: path.join(__dirname, "playwright-report"), open: "never" }], + [ + "html", + { + outputFolder: path.join(__dirname, "playwright-report"), + open: "never", + }, + ], ], projects: [ diff --git a/testplanit/e2e/a11y/routes.ts b/testplanit/e2e/a11y/routes.ts index 329f7c7d8..e52cba707 100644 --- a/testplanit/e2e/a11y/routes.ts +++ b/testplanit/e2e/a11y/routes.ts @@ -56,8 +56,20 @@ const FORM = "form, input, button"; export const routes: A11yRoute[] = [ // ----- Public / auth (scanned logged-out) ----- - { name: "signin", group: "Auth", path: () => "/signin", authRequired: false, sanity: FORM }, - { name: "signup", group: "Auth", path: () => "/signup", authRequired: false, sanity: FORM }, + { + name: "signin", + group: "Auth", + path: () => "/signin", + authRequired: false, + sanity: FORM, + }, + { + name: "signup", + group: "Auth", + path: () => "/signup", + authRequired: false, + sanity: FORM, + }, { name: "verify-email", group: "Auth", @@ -76,100 +88,608 @@ export const routes: A11yRoute[] = [ }, // ----- Special-state auth pages (may redirect for a normal admin session) ----- - { name: "two-factor-setup", group: "Auth (gated)", path: () => "/auth/two-factor-setup", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, - { name: "two-factor-verify", group: "Auth (gated)", path: () => "/auth/two-factor-verify", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, - { name: "force-change-password", group: "Auth (gated)", path: () => "/auth/force-change-password", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, - { name: "account-link-sso", group: "Auth (gated)", path: () => "/account/link-sso", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, - { name: "trial-expired", group: "Auth (gated)", path: () => "/trial-expired", authRequired: true, mayRedirect: true, sanity: APP_SHELL }, + { + name: "two-factor-setup", + group: "Auth (gated)", + path: () => "/auth/two-factor-setup", + authRequired: true, + mayRedirect: true, + sanity: APP_SHELL, + }, + { + name: "two-factor-verify", + group: "Auth (gated)", + path: () => "/auth/two-factor-verify", + authRequired: true, + mayRedirect: true, + sanity: APP_SHELL, + }, + { + name: "force-change-password", + group: "Auth (gated)", + path: () => "/auth/force-change-password", + authRequired: true, + mayRedirect: true, + sanity: APP_SHELL, + }, + { + name: "account-link-sso", + group: "Auth (gated)", + path: () => "/account/link-sso", + authRequired: true, + mayRedirect: true, + sanity: APP_SHELL, + }, + { + name: "trial-expired", + group: "Auth (gated)", + path: () => "/trial-expired", + authRequired: true, + mayRedirect: true, + sanity: APP_SHELL, + }, // ----- Dashboard / global ----- - { name: "home", group: "Dashboard", path: () => "/", authRequired: true, sanity: APP_SHELL, mayRedirect: true }, - { name: "reviews", group: "Dashboard", path: () => "/reviews", authRequired: true, sanity: APP_SHELL }, - { name: "issues-global", group: "Dashboard", path: () => "/issues", authRequired: true, sanity: APP_SHELL }, - { name: "tags-global", group: "Dashboard", path: () => "/tags", authRequired: true, sanity: APP_SHELL }, - { name: "tag-global-detail", group: "Dashboard", path: (f) => `/tags/${f.tagId}`, authRequired: true, needs: ["tagId"], sanity: APP_SHELL, mayRedirect: true }, - { name: "users-global", group: "Dashboard", path: () => "/users", authRequired: true, sanity: APP_SHELL }, - { name: "user-profile", group: "Dashboard", path: (f) => `/users/profile/${f.userId}`, authRequired: true, needs: ["userId"], sanity: APP_SHELL }, + { + name: "home", + group: "Dashboard", + path: () => "/", + authRequired: true, + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "reviews", + group: "Dashboard", + path: () => "/reviews", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "issues-global", + group: "Dashboard", + path: () => "/issues", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "tags-global", + group: "Dashboard", + path: () => "/tags", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "tag-global-detail", + group: "Dashboard", + path: (f) => `/tags/${f.tagId}`, + authRequired: true, + needs: ["tagId"], + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "users-global", + group: "Dashboard", + path: () => "/users", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "user-profile", + group: "Dashboard", + path: (f) => `/users/profile/${f.userId}`, + authRequired: true, + needs: ["userId"], + sanity: APP_SHELL, + }, // ----- Projects ----- - { name: "projects-list", group: "Projects", path: () => "/projects", authRequired: true, sanity: APP_SHELL, interactions: ["dialog"] }, - { name: "project-overview", group: "Projects", path: (f) => `/projects/overview/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "project-documentation", group: "Projects", path: (f) => `/projects/documentation/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "project-issues", group: "Projects", path: (f) => `/projects/issues/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { + name: "projects-list", + group: "Projects", + path: () => "/projects", + authRequired: true, + sanity: APP_SHELL, + interactions: ["dialog"], + }, + { + name: "project-overview", + group: "Projects", + path: (f) => `/projects/overview/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "project-documentation", + group: "Projects", + path: (f) => `/projects/documentation/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "project-issues", + group: "Projects", + path: (f) => `/projects/issues/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, // ----- Repository / test cases ----- - { name: "repository-list", group: "Repository", path: (f) => `/projects/repository/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog", "menu"] }, - { name: "case-detail", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/${f.caseId}`, authRequired: true, needs: ["projectId", "caseId"], sanity: APP_SHELL, settleMs: 1500, interactions: ["menu"] }, - { name: "case-detail-2", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/${f.caseId2}`, authRequired: true, needs: ["projectId", "caseId2"], sanity: APP_SHELL, settleMs: 1500 }, - { name: "case-version", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/${f.caseId}/${f.version}`, authRequired: true, needs: ["projectId", "caseId", "version"], sanity: APP_SHELL, mayRedirect: true }, - { name: "case-global", group: "Repository", path: (f) => `/case/${f.caseId}`, authRequired: true, needs: ["caseId"], sanity: APP_SHELL, mayRedirect: true }, - { name: "repository-duplicates", group: "Repository", path: (f) => `/projects/repository/${f.projectId}/duplicates`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { + name: "repository-list", + group: "Repository", + path: (f) => `/projects/repository/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + interactions: ["dialog", "menu"], + }, + { + name: "case-detail", + group: "Repository", + path: (f) => `/projects/repository/${f.projectId}/${f.caseId}`, + authRequired: true, + needs: ["projectId", "caseId"], + sanity: APP_SHELL, + settleMs: 1500, + interactions: ["menu"], + }, + { + name: "case-detail-2", + group: "Repository", + path: (f) => `/projects/repository/${f.projectId}/${f.caseId2}`, + authRequired: true, + needs: ["projectId", "caseId2"], + sanity: APP_SHELL, + settleMs: 1500, + }, + { + name: "case-version", + group: "Repository", + path: (f) => `/projects/repository/${f.projectId}/${f.caseId}/${f.version}`, + authRequired: true, + needs: ["projectId", "caseId", "version"], + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "case-global", + group: "Repository", + path: (f) => `/case/${f.caseId}`, + authRequired: true, + needs: ["caseId"], + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "repository-duplicates", + group: "Repository", + path: (f) => `/projects/repository/${f.projectId}/duplicates`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, // ----- Test runs ----- - { name: "runs-list", group: "Test Runs", path: (f) => `/projects/runs/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog"] }, - { name: "run-detail", group: "Test Runs", path: (f) => `/projects/runs/${f.projectId}/${f.runId}`, authRequired: true, needs: ["projectId", "runId"], sanity: APP_SHELL, settleMs: 1000, interactions: ["menu"] }, + { + name: "runs-list", + group: "Test Runs", + path: (f) => `/projects/runs/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + interactions: ["dialog"], + }, + { + name: "run-detail", + group: "Test Runs", + path: (f) => `/projects/runs/${f.projectId}/${f.runId}`, + authRequired: true, + needs: ["projectId", "runId"], + sanity: APP_SHELL, + settleMs: 1000, + interactions: ["menu"], + }, // ----- Sessions ----- - { name: "sessions-list", group: "Sessions", path: (f) => `/projects/sessions/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog"] }, - { name: "session-detail", group: "Sessions", path: (f) => `/projects/sessions/${f.projectId}/${f.sessionId}`, authRequired: true, needs: ["projectId", "sessionId"], sanity: APP_SHELL, settleMs: 1000 }, - { name: "session-version", group: "Sessions", path: (f) => `/projects/sessions/${f.projectId}/${f.sessionId}/${f.version}`, authRequired: true, needs: ["projectId", "sessionId", "version"], sanity: APP_SHELL, mayRedirect: true }, + { + name: "sessions-list", + group: "Sessions", + path: (f) => `/projects/sessions/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + interactions: ["dialog"], + }, + { + name: "session-detail", + group: "Sessions", + path: (f) => `/projects/sessions/${f.projectId}/${f.sessionId}`, + authRequired: true, + needs: ["projectId", "sessionId"], + sanity: APP_SHELL, + settleMs: 1000, + }, + { + name: "session-version", + group: "Sessions", + path: (f) => + `/projects/sessions/${f.projectId}/${f.sessionId}/${f.version}`, + authRequired: true, + needs: ["projectId", "sessionId", "version"], + sanity: APP_SHELL, + mayRedirect: true, + }, // ----- Milestones ----- - { name: "milestones-list", group: "Milestones", path: (f) => `/projects/milestones/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, interactions: ["dialog"] }, - { name: "milestone-detail", group: "Milestones", path: (f) => `/projects/milestones/${f.projectId}/${f.milestoneId}`, authRequired: true, needs: ["projectId", "milestoneId"], sanity: APP_SHELL }, - { name: "milestone-global", group: "Milestones", path: (f) => `/milestone/${f.milestoneId}`, authRequired: true, needs: ["milestoneId"], sanity: APP_SHELL, mayRedirect: true }, + { + name: "milestones-list", + group: "Milestones", + path: (f) => `/projects/milestones/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + interactions: ["dialog"], + }, + { + name: "milestone-detail", + group: "Milestones", + path: (f) => `/projects/milestones/${f.projectId}/${f.milestoneId}`, + authRequired: true, + needs: ["projectId", "milestoneId"], + sanity: APP_SHELL, + }, + { + name: "milestone-global", + group: "Milestones", + path: (f) => `/milestone/${f.milestoneId}`, + authRequired: true, + needs: ["milestoneId"], + sanity: APP_SHELL, + mayRedirect: true, + }, // ----- Project settings ----- - { name: "settings-integrations", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/integrations`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-ai-models", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/ai-models`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-parameters", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/parameters`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-datasets", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/datasets`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-dataset-detail", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/datasets/${f.datasetId}`, authRequired: true, needs: ["projectId", "datasetId"], sanity: APP_SHELL, mayRedirect: true }, - { name: "settings-junit", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/junit`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-quickscript", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/quickscript`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-webhooks", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/webhooks`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-shares", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/shares`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "settings-advanced", group: "Project Settings", path: (f) => `/projects/settings/${f.projectId}/advanced`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { + name: "settings-integrations", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/integrations`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-ai-models", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/ai-models`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-parameters", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/parameters`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-datasets", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/datasets`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-dataset-detail", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/datasets/${f.datasetId}`, + authRequired: true, + needs: ["projectId", "datasetId"], + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "settings-junit", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/junit`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-quickscript", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/quickscript`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-webhooks", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/webhooks`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-shares", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/shares`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "settings-advanced", + group: "Project Settings", + path: (f) => `/projects/settings/${f.projectId}/advanced`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, // ----- Shared steps ----- - { name: "shared-steps", group: "Shared Steps", path: (f) => `/projects/shared-steps/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "shared-steps-duplicates", group: "Shared Steps", path: (f) => `/projects/shared-steps/${f.projectId}/step-duplicates`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, + { + name: "shared-steps", + group: "Shared Steps", + path: (f) => `/projects/shared-steps/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "shared-steps-duplicates", + group: "Shared Steps", + path: (f) => `/projects/shared-steps/${f.projectId}/step-duplicates`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, // ----- Project tags / reports ----- - { name: "project-tags", group: "Tags & Reports", path: (f) => `/projects/tags/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL }, - { name: "project-tag-detail", group: "Tags & Reports", path: (f) => `/projects/tags/${f.projectId}/${f.tagId}`, authRequired: true, needs: ["projectId", "tagId"], sanity: APP_SHELL, mayRedirect: true }, - { name: "project-reports", group: "Tags & Reports", path: (f) => `/projects/reports/${f.projectId}`, authRequired: true, needs: ["projectId"], sanity: APP_SHELL, settleMs: 1500 }, + { + name: "project-tags", + group: "Tags & Reports", + path: (f) => `/projects/tags/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + }, + { + name: "project-tag-detail", + group: "Tags & Reports", + path: (f) => `/projects/tags/${f.projectId}/${f.tagId}`, + authRequired: true, + needs: ["projectId", "tagId"], + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "project-reports", + group: "Tags & Reports", + path: (f) => `/projects/reports/${f.projectId}`, + authRequired: true, + needs: ["projectId"], + sanity: APP_SHELL, + settleMs: 1500, + }, // ----- Admin ----- - { name: "admin-home", group: "Admin", path: () => "/admin", authRequired: true, sanity: APP_SHELL, mayRedirect: true }, - { name: "admin-users", group: "Admin", path: () => "/admin/users", authRequired: true, sanity: APP_SHELL, interactions: ["dialog", "menu"] }, - { name: "admin-groups", group: "Admin", path: () => "/admin/groups", authRequired: true, sanity: APP_SHELL }, - { name: "admin-roles", group: "Admin", path: () => "/admin/roles", authRequired: true, sanity: APP_SHELL }, - { name: "admin-sso", group: "Admin", path: () => "/admin/sso", authRequired: true, sanity: APP_SHELL }, - { name: "admin-integrations", group: "Admin", path: () => "/admin/integrations", authRequired: true, sanity: APP_SHELL }, - { name: "admin-issues", group: "Admin", path: () => "/admin/issues", authRequired: true, sanity: APP_SHELL }, - { name: "admin-code-repositories", group: "Admin", path: () => "/admin/code-repositories", authRequired: true, sanity: APP_SHELL }, - { name: "admin-api-tokens", group: "Admin", path: () => "/admin/api-tokens", authRequired: true, sanity: APP_SHELL, interactions: ["dialog"] }, - { name: "admin-llm", group: "Admin", path: () => "/admin/llm", authRequired: true, sanity: APP_SHELL }, - { name: "admin-prompts", group: "Admin", path: () => "/admin/prompts", authRequired: true, sanity: APP_SHELL }, - { name: "admin-quickscripts", group: "Admin", path: () => "/admin/quickscripts", authRequired: true, sanity: APP_SHELL }, - { name: "admin-elasticsearch", group: "Admin", path: () => "/admin/elasticsearch", authRequired: true, sanity: APP_SHELL }, - { name: "admin-fields", group: "Admin", path: () => "/admin/fields", authRequired: true, sanity: APP_SHELL, interactions: ["dialog"] }, - { name: "admin-configurations", group: "Admin", path: () => "/admin/configurations", authRequired: true, sanity: APP_SHELL }, - { name: "admin-security", group: "Admin", path: () => "/admin/security", authRequired: true, sanity: APP_SHELL }, - { name: "admin-app-config", group: "Admin", path: () => "/admin/app-config", authRequired: true, sanity: APP_SHELL }, - { name: "admin-statuses", group: "Admin", path: () => "/admin/statuses", authRequired: true, sanity: APP_SHELL }, - { name: "admin-tags", group: "Admin", path: () => "/admin/tags", authRequired: true, sanity: APP_SHELL }, - { name: "admin-shares", group: "Admin", path: () => "/admin/shares", authRequired: true, sanity: APP_SHELL }, - { name: "admin-notifications", group: "Admin", path: () => "/admin/notifications", authRequired: true, sanity: APP_SHELL }, - { name: "admin-workflows", group: "Admin", path: () => "/admin/workflows", authRequired: true, sanity: APP_SHELL }, - { name: "admin-audit-logs", group: "Admin", path: () => "/admin/audit-logs", authRequired: true, sanity: APP_SHELL }, - { name: "admin-trash", group: "Admin", path: () => "/admin/trash", authRequired: true, sanity: APP_SHELL }, - { name: "admin-imports", group: "Admin", path: () => "/admin/imports", authRequired: true, sanity: APP_SHELL }, - { name: "admin-projects", group: "Admin", path: () => "/admin/projects", authRequired: true, sanity: APP_SHELL }, - { name: "admin-milestones", group: "Admin", path: () => "/admin/milestones", authRequired: true, sanity: APP_SHELL }, - { name: "admin-queues", group: "Admin", path: () => "/admin/queues", authRequired: true, sanity: APP_SHELL }, - { name: "admin-reports", group: "Admin", path: () => "/admin/reports", authRequired: true, sanity: APP_SHELL }, + { + name: "admin-home", + group: "Admin", + path: () => "/admin", + authRequired: true, + sanity: APP_SHELL, + mayRedirect: true, + }, + { + name: "admin-users", + group: "Admin", + path: () => "/admin/users", + authRequired: true, + sanity: APP_SHELL, + interactions: ["dialog", "menu"], + }, + { + name: "admin-groups", + group: "Admin", + path: () => "/admin/groups", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-roles", + group: "Admin", + path: () => "/admin/roles", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-sso", + group: "Admin", + path: () => "/admin/sso", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-integrations", + group: "Admin", + path: () => "/admin/integrations", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-issues", + group: "Admin", + path: () => "/admin/issues", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-code-repositories", + group: "Admin", + path: () => "/admin/code-repositories", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-api-tokens", + group: "Admin", + path: () => "/admin/api-tokens", + authRequired: true, + sanity: APP_SHELL, + interactions: ["dialog"], + }, + { + name: "admin-llm", + group: "Admin", + path: () => "/admin/llm", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-prompts", + group: "Admin", + path: () => "/admin/prompts", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-quickscripts", + group: "Admin", + path: () => "/admin/quickscripts", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-elasticsearch", + group: "Admin", + path: () => "/admin/elasticsearch", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-fields", + group: "Admin", + path: () => "/admin/fields", + authRequired: true, + sanity: APP_SHELL, + interactions: ["dialog"], + }, + { + name: "admin-configurations", + group: "Admin", + path: () => "/admin/configurations", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-security", + group: "Admin", + path: () => "/admin/security", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-app-config", + group: "Admin", + path: () => "/admin/app-config", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-statuses", + group: "Admin", + path: () => "/admin/statuses", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-tags", + group: "Admin", + path: () => "/admin/tags", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-shares", + group: "Admin", + path: () => "/admin/shares", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-notifications", + group: "Admin", + path: () => "/admin/notifications", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-workflows", + group: "Admin", + path: () => "/admin/workflows", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-audit-logs", + group: "Admin", + path: () => "/admin/audit-logs", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-trash", + group: "Admin", + path: () => "/admin/trash", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-imports", + group: "Admin", + path: () => "/admin/imports", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-projects", + group: "Admin", + path: () => "/admin/projects", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-milestones", + group: "Admin", + path: () => "/admin/milestones", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-queues", + group: "Admin", + path: () => "/admin/queues", + authRequired: true, + sanity: APP_SHELL, + }, + { + name: "admin-reports", + group: "Admin", + path: () => "/admin/reports", + authRequired: true, + sanity: APP_SHELL, + }, { name: "admin-sso-saml", group: "Admin", @@ -181,7 +701,13 @@ export const routes: A11yRoute[] = [ }, // ----- Docs ----- - { name: "docs-api", group: "Docs", path: () => "/docs/api", authRequired: true, sanity: APP_SHELL }, + { + name: "docs-api", + group: "Docs", + path: () => "/docs/api", + authRequired: true, + sanity: APP_SHELL, + }, ]; /** Helper for the spec/aggregator: which routes are interactive-state scanned. */ diff --git a/testplanit/e2e/a11y/run.ts b/testplanit/e2e/a11y/run.ts index 9af6a354b..d7c9e432f 100644 --- a/testplanit/e2e/a11y/run.ts +++ b/testplanit/e2e/a11y/run.ts @@ -32,8 +32,16 @@ if (route) { console.log(`[a11y] single-route run: ${route}`); } -const scan = spawnSync(bin("playwright"), playwrightArgs, { cwd, env, stdio: "inherit" }); +const scan = spawnSync(bin("playwright"), playwrightArgs, { + cwd, + env, + stdio: "inherit", +}); // Always aggregate, even if the scan reported failures (strict mode). -const agg = spawnSync(bin("tsx"), ["e2e/a11y/aggregate.ts"], { cwd, env, stdio: "inherit" }); +const agg = spawnSync(bin("tsx"), ["e2e/a11y/aggregate.ts"], { + cwd, + env, + stdio: "inherit", +}); process.exit((scan.status ?? 1) || (agg.status ?? 0)); diff --git a/testplanit/e2e/a11y/scan.spec.ts b/testplanit/e2e/a11y/scan.spec.ts index f211c6128..9db074d6c 100644 --- a/testplanit/e2e/a11y/scan.spec.ts +++ b/testplanit/e2e/a11y/scan.spec.ts @@ -237,11 +237,7 @@ function writeResult(result: RouteResult): void { } for (const route of routes) { - test(`a11y: ${route.group} › ${route.name}`, async ({ - page, - browser, - baseURL, - }) => { + test(`a11y: ${route.group} › ${route.name}`, async ({ page, browser }) => { test.setTimeout(90_000); const result: RouteResult = { diff --git a/testplanit/e2e/a11y/wcag.ts b/testplanit/e2e/a11y/wcag.ts index 8bd37ddd7..1d7e6e155 100644 --- a/testplanit/e2e/a11y/wcag.ts +++ b/testplanit/e2e/a11y/wcag.ts @@ -23,11 +23,23 @@ export interface SuccessCriterion { export const WCAG_TAG_TO_SC: Record<string, SuccessCriterion> = { // 1. Perceivable wcag111: { num: "1.1.1", name: "Non-text Content", level: "A" }, - wcag121: { num: "1.2.1", name: "Audio-only and Video-only (Prerecorded)", level: "A" }, + wcag121: { + num: "1.2.1", + name: "Audio-only and Video-only (Prerecorded)", + level: "A", + }, wcag122: { num: "1.2.2", name: "Captions (Prerecorded)", level: "A" }, - wcag123: { num: "1.2.3", name: "Audio Description or Media Alternative", level: "A" }, + wcag123: { + num: "1.2.3", + name: "Audio Description or Media Alternative", + level: "A", + }, wcag124: { num: "1.2.4", name: "Captions (Live)", level: "AA" }, - wcag125: { num: "1.2.5", name: "Audio Description (Prerecorded)", level: "AA" }, + wcag125: { + num: "1.2.5", + name: "Audio Description (Prerecorded)", + level: "AA", + }, wcag131: { num: "1.3.1", name: "Info and Relationships", level: "A" }, wcag132: { num: "1.3.2", name: "Meaningful Sequence", level: "A" }, wcag133: { num: "1.3.3", name: "Sensory Characteristics", level: "A" }, @@ -58,7 +70,11 @@ export const WCAG_TAG_TO_SC: Record<string, SuccessCriterion> = { wcag245: { num: "2.4.5", name: "Multiple Ways", level: "AA" }, wcag246: { num: "2.4.6", name: "Headings and Labels", level: "AA" }, wcag247: { num: "2.4.7", name: "Focus Visible", level: "AA" }, - wcag2411: { num: "2.4.11", name: "Focus Not Obscured (Minimum)", level: "AA" }, + wcag2411: { + num: "2.4.11", + name: "Focus Not Obscured (Minimum)", + level: "AA", + }, wcag251: { num: "2.5.1", name: "Pointer Gestures", level: "A" }, wcag252: { num: "2.5.2", name: "Pointer Cancellation", level: "A" }, wcag253: { num: "2.5.3", name: "Label in Name", level: "A" }, @@ -77,9 +93,17 @@ export const WCAG_TAG_TO_SC: Record<string, SuccessCriterion> = { wcag331: { num: "3.3.1", name: "Error Identification", level: "A" }, wcag332: { num: "3.3.2", name: "Labels or Instructions", level: "A" }, wcag333: { num: "3.3.3", name: "Error Suggestion", level: "AA" }, - wcag334: { num: "3.3.4", name: "Error Prevention (Legal, Financial, Data)", level: "AA" }, + wcag334: { + num: "3.3.4", + name: "Error Prevention (Legal, Financial, Data)", + level: "AA", + }, wcag337: { num: "3.3.7", name: "Redundant Entry", level: "A" }, - wcag338: { num: "3.3.8", name: "Accessible Authentication (Minimum)", level: "AA" }, + wcag338: { + num: "3.3.8", + name: "Accessible Authentication (Minimum)", + level: "AA", + }, // 4. Robust wcag411: { num: "4.1.1", name: "Parsing (obsolete)", level: "A" }, @@ -105,13 +129,17 @@ const NON_SC_TAGS = new Set([ * Resolve the primary success criterion for an axe violation from its tags. * Returns a synthetic "best-practice" or "other" bucket when no SC tag exists. */ -export function primaryCriterion(tags: string[]): SuccessCriterion & { key: string } { +export function primaryCriterion( + tags: string[] +): SuccessCriterion & { key: string } { for (const tag of tags) { const sc = WCAG_TAG_TO_SC[tag]; if (sc) return { ...sc, key: sc.num }; } // Unmapped wcag tag (new SC we don't have a name for yet) — derive a number. - const unmapped = tags.find((t) => /^wcag\d{3,4}$/.test(t) && !NON_SC_TAGS.has(t)); + const unmapped = tags.find( + (t) => /^wcag\d{3,4}$/.test(t) && !NON_SC_TAGS.has(t) + ); if (unmapped) { const digits = unmapped.replace("wcag", ""); const num = @@ -121,12 +149,19 @@ export function primaryCriterion(tags: string[]): SuccessCriterion & { key: stri return { num, name: "(unmapped criterion)", level: "AA", key: num }; } if (tags.includes("best-practice")) { - return { num: "—", name: "Best Practice (non-WCAG)", level: "A", key: "best-practice" }; + return { + num: "—", + name: "Best Practice (non-WCAG)", + level: "A", + key: "best-practice", + }; } return { num: "—", name: "Other", level: "A", key: "other" }; } /** True when a violation maps to at least one real WCAG success criterion. */ export function isWcagViolation(tags: string[]): boolean { - return tags.some((t) => WCAG_TAG_TO_SC[t] || (/^wcag\d{3,4}$/.test(t) && !NON_SC_TAGS.has(t))); + return tags.some( + (t) => WCAG_TAG_TO_SC[t] || (/^wcag\d{3,4}$/.test(t) && !NON_SC_TAGS.has(t)) + ); } From 422f5ce0c08bc0fb7a92bd199f526c949121fa68 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian <bderman@gmail.com> Date: Fri, 5 Jun 2026 10:26:58 -0500 Subject: [PATCH 25/25] docs(a11y): document the opt-in Accessible theme Add the Accessible theme to the user-menu and user-profile theme lists, a new Accessibility section in the user menu guide explaining what the theme does (contrast, target size, focus ring) and that it is opt-in and scoped, and an Accessible theme entry in the feature list. --- docs/docs/features.md | 1 + docs/docs/user-guide/user-menu.md | 15 ++++++++++++++- docs/docs/user-guide/user-profile.md | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/docs/features.md b/docs/docs/features.md index 2ab0a2d76..29e8ca103 100644 --- a/docs/docs/features.md +++ b/docs/docs/features.md @@ -174,6 +174,7 @@ TestPlanIt is a comprehensive test management platform designed to help teams pl - **Custom statuses** - Create statuses with custom icons and colors - **Templates** - Create templates for consistent test case structure - **User preferences** - Configure theme, locale, timezone, and date/time formats; preference selections are visible from the profile view as well as the editor +- **Accessible theme** - Opt-in high-contrast theme tuned for WCAG 2.2 Level AA (stronger contrast, larger interactive targets, visible focus), selectable per user without affecting other themes - **Configurations** - Author OS / browser / environment configurations scoped to projects, with an admin UX overhaul covering bulk assign and search ### Security & Compliance diff --git a/docs/docs/user-guide/user-menu.md b/docs/docs/user-guide/user-menu.md index 3fc89321c..ffd337c7f 100644 --- a/docs/docs/user-guide/user-menu.md +++ b/docs/docs/user-guide/user-menu.md @@ -14,7 +14,8 @@ Once opened, the menu displays: 1. **Your Name and Email**: Shown at the top for identification. 2. **View Profile**: Navigates you directly to your **[User Profile](./user-profile.md)** page. 3. **Theme**: Opens a sub-menu where you can select your preferred visual theme for the application. - - Options include Light, Dark, System (matches your operating system setting), Green, Orange, and Purple. + - Options include Light, Dark, System (matches your operating system setting), Green, Orange, Purple, and **Accessible**. + - **Accessible** is a high-contrast theme tuned for accessibility (WCAG 2.2 AA): it strengthens text and border contrast, enlarges small interactive targets, and adds a clearly visible keyboard focus ring. Choose it if you rely on these aids — see [Accessibility](#accessibility) below. - A checkmark indicates the currently active theme. - Selecting a new theme saves the preference to your profile and reloads the application to apply the change. 4. **Language**: Opens a sub-menu to select the display language for the application interface. @@ -22,3 +23,15 @@ Once opened, the menu displays: - A checkmark indicates the currently active language. - Selecting a new language saves the preference to your profile and reloads the application to apply the change. 5. **Sign Out**: Logs you out of your current session and redirects you to the Sign In page. + +## Accessibility + +TestPlanIt includes an opt-in **Accessible** theme designed to meet the [WCAG 2.2](https://www.w3.org/TR/WCAG22/) Level AA presentation requirements. It is one of the regular theme choices in the **Theme** sub-menu (and on your [User Profile](./user-profile.md) preferences), so you can turn it on or off at any time without affecting other users. + +Compared to the default themes, the Accessible theme: + +- **Increases contrast** for body text, secondary ("muted") text, and UI borders so content meets the minimum contrast ratio. +- **Enlarges small interactive targets** (such as compact icon buttons and toggles) to at least the recommended minimum size. +- **Adds a high-contrast keyboard focus ring** so the currently focused control is always clearly visible. + +Your selection is saved to your profile and persists across sessions and devices. The other themes are left unchanged, so teammates who prefer them are unaffected. diff --git a/docs/docs/user-guide/user-profile.md b/docs/docs/user-guide/user-profile.md index 710f088fe..dcfcbb320 100644 --- a/docs/docs/user-guide/user-profile.md +++ b/docs/docs/user-guide/user-profile.md @@ -63,7 +63,7 @@ When viewing your own profile, you can view and edit these preferences: #### Display Preferences -- **Theme**: Choose from Light, Dark, System, Green, Orange, or Purple themes (with color indicators) +- **Theme**: Choose from Light, Dark, System, Green, Orange, Purple, or **Accessible** themes (with color indicators). The Accessible theme is a high-contrast option tuned for [WCAG 2.2](https://www.w3.org/TR/WCAG22/) Level AA — see [Accessibility](./user-menu.md#accessibility) - **Locale**: Language preference (English, German, Spanish, French, Italian, Dutch, Polish, Portuguese, Turkish, Vietnamese, Russian, Chinese Simplified, Chinese Traditional, Japanese, Korean) - **Items Per Page**: Number of items to show in paginated tables (10, 25, 50, 100)