Files
cadence-ui/scripts/harness/run-storybook-a11y.mjs
T

262 lines
7.5 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import { chromium } from "@playwright/test";
import harnessStoryContract from "../../tests/e2e/support/story-harness-contract.json" with { type: "json" };
import { repoRoot } from "./core.mjs";
const require = createRequire(import.meta.url);
const { startStorybookServer, stopStorybookServer } = require(
path.resolve(repoRoot, "tests/e2e/support/storybook-server.cjs")
);
const baseURL = "http://127.0.0.1:6006";
const axeSourcePath = require.resolve("axe-core/axe.min.js");
const reportDir = path.join(repoRoot, ".artifacts", "a11y");
const reportPath = path.join(reportDir, "storybook-a11y.json");
const a11yPrepareHandlers = {
"open-date-picker": async (page) => {
await page.getByRole("combobox", { name: "Launch date" }).click();
await page.getByRole("dialog", { name: "Launch date calendar" }).waitFor({ state: "visible" });
},
"open-dialog": async (page) => {
await page.getByRole("button", { name: "Open approval dialog" }).click();
await page.getByRole("dialog", { name: "Launch this release?" }).waitFor({ state: "visible" });
},
"open-dropdown-menu": async (page) => {
await page.getByRole("button", { name: "Review lane menu" }).click();
await page.getByRole("menu").waitFor({ state: "visible" });
},
"open-popover": async (page) => {
await page.getByRole("button", { name: "Inspect summary" }).click();
await page.getByText("Release health").waitFor({ state: "visible" });
},
"open-sheet": async (page) => {
await page.getByRole("button", { name: "Open right sheet" }).click();
await page.getByRole("dialog", { name: "Launch settings" }).waitFor({ state: "visible" });
}
};
const stories = harnessStoryContract.stories.filter((story) => story.suites.includes("a11y"));
for (const story of stories) {
if (story.a11yPrepare && !(story.a11yPrepare in a11yPrepareHandlers)) {
throw new Error(`Unknown a11y prepare handler "${story.a11yPrepare}" for ${story.id}.`);
}
}
function buildStoryUrl(story) {
const params = new URLSearchParams({
id: story.id,
viewMode: "story"
});
if (story.query) {
for (const [key, value] of new URLSearchParams(story.query).entries()) {
params.set(key, value);
}
}
return `${baseURL}/iframe.html?${params.toString()}`;
}
async function gotoStory(page, story) {
await page.goto(buildStoryUrl(story));
await page.waitForLoadState("domcontentloaded");
await page.waitForFunction(
(storyId) => {
const title = globalThis.document.title.toLowerCase();
const root = globalThis.document.getElementById("storybook-root");
return (
title.includes(String(storyId).toLowerCase()) ||
Boolean(root && root.childElementCount > 0)
);
},
story.id,
{ timeout: 30_000 }
);
}
function summarizeResults(results) {
return results.map((result) => ({
description: result.description,
help: result.help,
helpUrl: result.helpUrl,
id: result.id,
impact: result.impact,
nodeCount: result.nodes.length,
nodes: result.nodes.map((node) => ({
failureSummary: node.failureSummary,
html: node.html,
target: node.target
}))
}));
}
function isKnownIncompleteFalsePositive(result, node) {
const failureSummary = node.failureSummary ?? "";
return (
(result.id === "aria-valid-attr-value" &&
failureSummary.includes(
"Unable to determine if aria-controls referenced ID exists on the page while using aria-haspopup"
)) ||
(result.id === "color-contrast" &&
(failureSummary.includes("Could not parse color string") ||
failureSummary.includes("Axe encountered an error; test the page for this type of problem manually")))
);
}
function summarizeIncompleteResults(results) {
return results
.map((result) => {
const nodes = result.nodes
.filter((node) => !isKnownIncompleteFalsePositive(result, node))
.map((node) => ({
failureSummary: node.failureSummary,
html: node.html,
target: node.target
}));
if (nodes.length === 0) {
return null;
}
return {
description: result.description,
help: result.help,
helpUrl: result.helpUrl,
id: result.id,
impact: result.impact,
nodeCount: nodes.length,
nodes
};
})
.filter(Boolean);
}
async function runAxe(page) {
return page.evaluate(async () => {
return globalThis.window.axe.run(
{
exclude: [
["#storybook-root[aria-hidden='true']"],
["#storybook-root[data-aria-hidden='true']"],
["[data-radix-focus-guard]"]
],
include: [["body"]]
},
{
resultTypes: ["violations", "incomplete"]
}
);
});
}
async function main() {
const axeSource = await fs.readFile(axeSourcePath, "utf8");
await fs.mkdir(reportDir, { recursive: true });
await startStorybookServer();
const browser = await chromium.launch({
headless: true
});
const context = await browser.newContext();
await context.addInitScript({ content: axeSource });
const report = {
checkedAt: new Date().toISOString(),
errors: [],
incompleteCount: 0,
stories: [],
storyCount: stories.length,
violationCount: 0
};
try {
const page = await context.newPage();
for (const story of stories) {
process.stdout.write(`[a11y] checking ${story.id}\n`);
try {
await gotoStory(page, story);
if (story.a11yPrepare) {
await a11yPrepareHandlers[story.a11yPrepare](page);
}
const result = await runAxe(page);
const violations = summarizeResults(result.violations);
const incomplete = summarizeIncompleteResults(result.incomplete);
report.violationCount += violations.length;
report.incompleteCount += incomplete.length;
report.stories.push({
id: story.id,
incomplete,
label: story.label,
url: buildStoryUrl(story),
violations
});
process.stdout.write(
`[a11y] ${story.id}: ${violations.length} violations, ${incomplete.length} incomplete\n`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
report.errors.push({
id: story.id,
label: story.label,
message
});
report.stories.push({
error: message,
id: story.id,
incomplete: [],
label: story.label,
url: buildStoryUrl(story),
violations: []
});
process.stdout.write(`[a11y] ${story.id}: error: ${message}\n`);
}
}
} finally {
await context.close();
await browser.close();
await stopStorybookServer();
}
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
process.stdout.write(`[a11y] Report written to ${path.relative(repoRoot, reportPath)}\n`);
if (report.incompleteCount > 0) {
process.stdout.write(
`[a11y] ${report.incompleteCount} incomplete results detected. Review the JSON report before merge.\n`
);
}
if (report.errors.length > 0) {
process.stdout.write(
`[a11y] ${report.errors.length} story checks failed before axe completed. Failing validation.\n`
);
process.exit(1);
}
if (report.violationCount > 0) {
process.stdout.write(
`[a11y] ${report.violationCount} violations detected. Failing validation.\n`
);
process.exit(1);
}
}
await main();