mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
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:
@@ -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",
|
||||
|
||||
Generated
+19
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user