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();