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

239 lines
6.4 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import { chromium } from "@playwright/test";
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 stories = [
{
id: "components-button--playground",
label: "Button playground"
},
{
id: "components-combobox--controlled",
label: "Combobox controlled"
},
{
id: "components-data-table--playground",
label: "Data table playground"
},
{
id: "components-datepicker--playground",
label: "Date picker playground",
prepare: async (page) => {
await page.getByRole("combobox", { name: "Launch date" }).click();
await page.getByRole("grid").waitFor({ state: "visible" });
}
},
{
id: "components-dialog--playground",
label: "Dialog playground",
prepare: async (page) => {
await page.getByRole("button", { name: "Open approval dialog" }).click();
await page.getByRole("dialog", { name: "Launch this release?" }).waitFor({ state: "visible" });
}
},
{
id: "components-dropdownmenu--states",
label: "Dropdown menu states",
prepare: async (page) => {
await page.getByRole("button", { name: "Review lane menu" }).click();
await page.getByRole("menu").waitFor({ state: "visible" });
}
},
{
id: "components-form--launch-settings",
label: "Form launch settings",
query: "globals=motion:reduced"
},
{
id: "components-popover--playground",
label: "Popover playground",
prepare: async (page) => {
await page.getByRole("button", { name: "Inspect summary" }).click();
await page.getByText("Release health").waitFor({ state: "visible" });
}
},
{
id: "components-sheet--playground",
label: "Sheet playground",
prepare: async (page) => {
await page.getByRole("button", { name: "Open right sheet" }).click();
await page.getByRole("dialog", { name: "Launch settings" }).waitFor({ state: "visible" });
}
}
];
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 = document.title.toLowerCase();
const root = 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
}))
}));
}
async function runAxe(page) {
return page.evaluate(async () => {
const root = document.getElementById("storybook-root") ?? document.body;
return window.axe.run(root, {
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.prepare) {
await story.prepare(page);
}
const result = await runAxe(page);
const violations = summarizeResults(result.violations);
const incomplete = summarizeResults(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();