db38adbe12
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
290 lines
7.8 KiB
JavaScript
290 lines
7.8 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("dialog", { name: "Launch date calendar" }).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 = 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.prepare) {
|
|
await story.prepare(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();
|