From 92dc11bd2f1b6c7098e9d0c885a1bbb1db7f56a1 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 01:22:21 +0200 Subject: [PATCH] test(e2e): axe on the main screens, and focus return Adds @axe-core/playwright and audits the editor's modes, the assistant, both Share tabs, Documents, New, the letter editor, the command palette, Applications, Settings, the shared resume, the ATS checker and sign-in against WCAG 2.1 AA, in light and dark and at phone width. A keyboard spec checks that the Share sheet, the assistant, New and the command palette return focus to what opened them. --- package.json | 1 + pnpm-lock.yaml | 19 ++++ tests/e2e/specs/accessibility.spec.ts | 128 ++++++++++++++++++++++++++ tests/e2e/specs/keyboard.spec.ts | 49 ++++++++++ 4 files changed, 197 insertions(+) create mode 100644 tests/e2e/specs/accessibility.spec.ts create mode 100644 tests/e2e/specs/keyboard.spec.ts diff --git a/package.json b/package.json index 6df3340b4..12dc37620 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "icons:build": "pnpm --filter @reactive-resume/tooling icons:build" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@biomejs/biome": "^2.5.14", "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8d401c3c..18e288a0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: .: devDependencies: + '@axe-core/playwright': + specifier: ^4.13.0 + version: 4.13.0(playwright-core@1.63.0) '@biomejs/biome': specifier: ^2.5.14 version: 2.5.14 @@ -1722,6 +1725,11 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} + '@axe-core/playwright@4.13.0': + resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -5482,6 +5490,10 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -9405,6 +9417,11 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} + '@axe-core/playwright@4.13.0(playwright-core@1.63.0)': + dependencies: + axe-core: 4.13.0 + playwright-core: 1.63.0 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -12937,6 +12954,8 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 + axe-core@4.13.0: {} + babel-dead-code-elimination@1.0.12(supports-color@7.2.0): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) diff --git a/tests/e2e/specs/accessibility.spec.ts b/tests/e2e/specs/accessibility.spec.ts new file mode 100644 index 000000000..4be75ce34 --- /dev/null +++ b/tests/e2e/specs/accessibility.spec.ts @@ -0,0 +1,128 @@ +import type { Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { createSampleResumeFromDashboard, makeResumePublic, openDownloadDialog } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +// WCAG 2.1 A and AA, which the design's contrast and target rules are written against. +const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]; + +async function audit(page: Page, name: string) { + const { violations } = await new AxeBuilder({ page }) + .withTags(TAGS) + // The PDF.js text layer is transparent text over the canvas; its contrast isn't what anyone reads. + .exclude(".textLayer") + .analyze(); + const summary = violations.map( + (violation) => + `${name}: ${violation.id} (${violation.impact}) × ${violation.nodes.length}: ${violation.nodes + .slice(0, 3) + .map( + (node) => + `${node.target.join(" ")} ${node.html.slice(0, 160)} ${node.failureSummary?.split("\n").slice(1, 2).join("") ?? ""}`, + ) + .join(" | ")}`, + ); + expect(summary, summary.join("\n")).toEqual([]); +} + +for (const scheme of ["light", "dark"] as const) { + test(`the main screens pass axe in ${scheme} mode`, async ({ authPage: page, browser }, testInfo) => { + test.setTimeout(120_000); + await page.emulateMedia({ colorScheme: scheme, reducedMotion: "reduce" }); + + await createSampleResumeFromDashboard(page, testInfo); + await expect(page.getByRole("button", { name: "Download PDF", exact: true })).toBeVisible(); + await audit(page, "editor · write"); + + await page.getByRole("tab", { name: /^Design/ }).click(); + await audit(page, "editor · design"); + await page.getByRole("tab", { name: /^Check/ }).click(); + await audit(page, "editor · check"); + + await page.getByRole("button", { name: "Assistant", exact: true }).click(); + await expect(page.getByRole("region", { name: "Assistant" })).toBeVisible(); + await audit(page, "assistant"); + await page.getByRole("button", { name: "Close the assistant" }).click(); + + const publicUrl = await makeResumePublic(page); + await audit(page, "share sheet · link"); + await page.keyboard.press("Escape"); + await openDownloadDialog(page); + await audit(page, "share sheet · download"); + await page.keyboard.press("Escape"); + + for (const [path, name] of [ + ["/dashboard", "documents"], + ["/dashboard/applications", "applications"], + ["/dashboard/applications?view=board", "applications · board"], + ["/dashboard/settings/account", "settings · account"], + ["/dashboard/settings/preferences", "settings · preferences"], + ["/dashboard/settings/ai", "settings · ai"], + ] as const) { + await page.goto(path); + await page.waitForLoadState("networkidle"); + await audit(page, name); + } + + // Axe needs a page from an explicit context. + const context = await browser.newContext({ colorScheme: scheme, reducedMotion: "reduce" }); + await page.goto("/dashboard"); + await page.getByRole("button", { name: "New", exact: true }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await audit(page, "new dialog"); + await page.getByRole("button", { name: "New cover letter instead" }).click(); + await page.waitForURL(/\/builder\/letter\//); + await expect(page.getByRole("button", { name: /^Document menu/ })).toBeVisible(); + await audit(page, "letter editor"); + + await page.keyboard.press("ControlOrMeta+k"); + await expect(page.getByRole("dialog")).toBeVisible(); + await audit(page, "command palette"); + await page.keyboard.press("Escape"); + + const visitor = await context.newPage(); + try { + await visitor.goto(publicUrl); + await expect(visitor.getByRole("heading", { level: 1 })).toBeVisible(); + await audit(visitor, "shared resume"); + await visitor.goto("/ats-checker"); + await expect(visitor.getByRole("button", { name: "Check a sample file" })).toBeVisible(); + await audit(visitor, "ats checker"); + await visitor.goto("/auth/login"); + await audit(visitor, "sign in"); + } finally { + await context.close(); + } + }); +} + +test("phone screens pass axe", async ({ authPage: page, browser }, testInfo) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + + await createSampleResumeFromDashboard(page, testInfo); + await expect(page.getByRole("textbox", { name: "Full name" })).toBeVisible(); + await audit(page, "phone · editor"); + const publicUrl = await makeResumePublic(page); + + for (const [path, name] of [ + ["/dashboard", "phone · documents"], + ["/dashboard/applications", "phone · applications"], + ["/dashboard/settings", "phone · settings root"], + ] as const) { + await page.goto(path); + await page.waitForLoadState("networkidle"); + await audit(page, name); + } + + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: "reduce" }); + const visitor = await context.newPage(); + try { + await visitor.goto(publicUrl); + await expect(visitor.getByRole("button", { name: "Download PDF" })).toBeVisible(); + await audit(visitor, "phone · shared resume"); + } finally { + await context.close(); + } +}); diff --git a/tests/e2e/specs/keyboard.spec.ts b/tests/e2e/specs/keyboard.spec.ts new file mode 100644 index 000000000..9a56c1bed --- /dev/null +++ b/tests/e2e/specs/keyboard.spec.ts @@ -0,0 +1,49 @@ +import type { Page } from "@playwright/test"; +import { createSampleResumeFromDashboard } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +const focusedName = (page: Page) => + page.evaluate(() => { + const element = document.activeElement as HTMLElement | null; + return element?.getAttribute("aria-label") ?? element?.textContent?.trim() ?? ""; + }); + +test("sheets, panels and dialogs return focus to what opened them", async ({ authPage: page }, testInfo) => { + test.setTimeout(60_000); + await createSampleResumeFromDashboard(page, testInfo); + + // Share & export, opened and closed from the keyboard. + const share = page.getByRole("button", { name: "Share", exact: true }); + await share.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog", { name: "Share & export" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Share & export" })).toBeHidden(); + await expect(share).toBeFocused(); + + // The assistant: closing it returns to the ✦ button. + const assistant = page.getByRole("button", { name: "Assistant", exact: true }); + await assistant.focus(); + await page.keyboard.press("Enter"); + const close = page.getByRole("button", { name: "Close the assistant" }); + await close.focus(); + await page.keyboard.press("Enter"); + await expect(assistant).toBeFocused(); + + // New, from Documents. + await page.goto("/dashboard"); + const create = page.getByRole("button", { name: "New", exact: true }); + await create.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(create).toBeFocused(); + + // The command palette, from the search button. + const search = page.getByRole("button", { name: /Search or run/ }); + await search.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + expect(await focusedName(page)).toMatch(/Search or run/); +});