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.
This commit is contained in:
Amruth Pillai
2026-09-29 01:22:21 +02:00
parent 62572a20ca
commit 92dc11bd2f
4 changed files with 197 additions and 0 deletions
+1
View File
@@ -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",
+19
View File
@@ -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)
+128
View File
@@ -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();
}
});
+49
View File
@@ -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/);
});