Add harness workflow and Material showcase design system
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const zeroShaPattern = /^0{40}$/;
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const repoRoot = path.resolve(scriptDir, "../..");
|
||||
export const artifactsDir = path.join(repoRoot, ".artifacts", "harness");
|
||||
export const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
export const suiteDefinitions = {
|
||||
static: {
|
||||
description: "Fast static validation: lint and workspace typecheck.",
|
||||
commands: [
|
||||
{ args: ["lint"], label: "Lint repository" },
|
||||
{ args: ["typecheck"], label: "Typecheck workspace" }
|
||||
]
|
||||
},
|
||||
component: {
|
||||
description: "Fast component feedback: lint, typecheck, and unit coverage.",
|
||||
commands: [
|
||||
{ args: ["lint"], label: "Lint repository" },
|
||||
{ args: ["typecheck"], label: "Typecheck workspace" },
|
||||
{ args: ["test"], label: "Run component tests" }
|
||||
]
|
||||
},
|
||||
docs: {
|
||||
description: "Build the Storybook review surface.",
|
||||
commands: [{ args: ["build:docs"], label: "Build Storybook" }]
|
||||
},
|
||||
a11y: {
|
||||
description: "Run Storybook accessibility validation and report violations/incomplete results.",
|
||||
commands: [{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" }]
|
||||
},
|
||||
"docs-smoke": {
|
||||
description: "Exercise high-value Storybook flows with Playwright.",
|
||||
commands: [{ args: ["test:e2e:smoke"], label: "Run Storybook smoke tests" }]
|
||||
},
|
||||
consumers: {
|
||||
description: "Validate generated registry metadata and downstream consumers.",
|
||||
commands: [
|
||||
{ args: ["registry:check"], label: "Check registry metadata" },
|
||||
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
|
||||
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" }
|
||||
]
|
||||
},
|
||||
pr: {
|
||||
description: "Baseline pull request gate for source, docs, and consumer surfaces.",
|
||||
commands: [
|
||||
{ args: ["lint"], label: "Lint repository" },
|
||||
{ args: ["typecheck"], label: "Typecheck workspace" },
|
||||
{ args: ["test"], label: "Run component tests" },
|
||||
{ args: ["build"], label: "Build packages" },
|
||||
{ args: ["build:docs"], label: "Build Storybook" },
|
||||
{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" },
|
||||
{ args: ["registry:check"], label: "Check registry metadata" },
|
||||
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
|
||||
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" }
|
||||
]
|
||||
},
|
||||
release: {
|
||||
description: "Full release gate, including browser-driven Storybook coverage.",
|
||||
commands: [
|
||||
{ args: ["lint"], label: "Lint repository" },
|
||||
{ args: ["typecheck"], label: "Typecheck workspace" },
|
||||
{ args: ["test"], label: "Run component tests" },
|
||||
{ args: ["build"], label: "Build packages" },
|
||||
{ args: ["build:docs"], label: "Build Storybook" },
|
||||
{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" },
|
||||
{ args: ["registry:check"], label: "Check registry metadata" },
|
||||
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
|
||||
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" },
|
||||
{ args: ["test:e2e:smoke"], label: "Run Storybook smoke tests" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const suiteOrder = [
|
||||
"static",
|
||||
"component",
|
||||
"docs",
|
||||
"a11y",
|
||||
"docs-smoke",
|
||||
"consumers",
|
||||
"pr",
|
||||
"release"
|
||||
];
|
||||
|
||||
const rootStaticFiles = new Set([
|
||||
"eslint.config.mjs",
|
||||
"package.json",
|
||||
"playwright.config.ts",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts"
|
||||
]);
|
||||
|
||||
function normalizeFilePath(filePath) {
|
||||
return filePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
function isExactMatch(filePath, exactPaths) {
|
||||
return exactPaths.has(filePath);
|
||||
}
|
||||
|
||||
function isWithin(filePath, directory) {
|
||||
return filePath === directory || filePath.startsWith(`${directory}/`);
|
||||
}
|
||||
|
||||
function isPackageSourceChange(filePath) {
|
||||
return isWithin(filePath, "packages/ui/src") || isWithin(filePath, "packages/tokens/src");
|
||||
}
|
||||
|
||||
function isPackageContractChange(filePath) {
|
||||
return (
|
||||
isPackageSourceChange(filePath) ||
|
||||
filePath === "packages/ui/package.json" ||
|
||||
filePath === "packages/tokens/package.json"
|
||||
);
|
||||
}
|
||||
|
||||
function isDocsSurfaceChange(filePath) {
|
||||
return isWithin(filePath, "apps/docs") || isWithin(filePath, ".storybook");
|
||||
}
|
||||
|
||||
function isDocsInteractiveChange(filePath) {
|
||||
return (
|
||||
isWithin(filePath, "apps/docs/src") ||
|
||||
isWithin(filePath, ".storybook") ||
|
||||
filePath === "playwright.config.ts"
|
||||
);
|
||||
}
|
||||
|
||||
function isConsumerSurfaceChange(filePath) {
|
||||
return (
|
||||
isWithin(filePath, "registry") ||
|
||||
isWithin(filePath, "tests/package-consumer") ||
|
||||
isWithin(filePath, "tests/registry") ||
|
||||
filePath === "scripts/build-registry.mjs" ||
|
||||
filePath === "scripts/registry-install.mjs" ||
|
||||
isPackageContractChange(filePath)
|
||||
);
|
||||
}
|
||||
|
||||
function isHarnessCodeChange(filePath) {
|
||||
return (
|
||||
isWithin(filePath, "scripts/harness") ||
|
||||
filePath === "AGENTS.md" ||
|
||||
filePath === "CONTRIBUTING.md" ||
|
||||
filePath === "README.md" ||
|
||||
filePath === "docs/harness-engineering.md" ||
|
||||
filePath === "docs/orchestration.md" ||
|
||||
isWithin(filePath, "docs/exec-plans")
|
||||
);
|
||||
}
|
||||
|
||||
function isStaticSurfaceChange(filePath) {
|
||||
return (
|
||||
isPackageContractChange(filePath) ||
|
||||
isDocsSurfaceChange(filePath) ||
|
||||
isWithin(filePath, "tests") ||
|
||||
isWithin(filePath, "scripts") ||
|
||||
isWithin(filePath, ".github/workflows") ||
|
||||
isExactMatch(filePath, rootStaticFiles) ||
|
||||
isHarnessCodeChange(filePath)
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyCodeChange(filePath) {
|
||||
return /\.(?:cjs|css|cts|js|json|jsx|mdx|mjs|mts|scss|ts|tsx|yaml|yml)$/.test(filePath);
|
||||
}
|
||||
|
||||
function runGitCommand(args) {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => normalizeFilePath(line));
|
||||
}
|
||||
|
||||
function getWorkingTreeChangedFiles() {
|
||||
const files = new Set([
|
||||
...runGitCommand(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"]),
|
||||
...runGitCommand(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "HEAD"]),
|
||||
...runGitCommand(["ls-files", "--others", "--exclude-standard"])
|
||||
]);
|
||||
|
||||
return [...files].sort();
|
||||
}
|
||||
|
||||
export function getChangedFiles({ changedFiles = [], from = null, to = null } = {}) {
|
||||
if (changedFiles.length > 0) {
|
||||
return [...new Set(changedFiles.map((filePath) => normalizeFilePath(filePath)))].sort();
|
||||
}
|
||||
|
||||
if (from || to) {
|
||||
const toRef = to ?? "HEAD";
|
||||
|
||||
if (from && zeroShaPattern.test(from)) {
|
||||
return [
|
||||
...new Set([
|
||||
...runGitCommand(["ls-files"]),
|
||||
...runGitCommand(["ls-files", "--others", "--exclude-standard"])
|
||||
])
|
||||
].sort();
|
||||
}
|
||||
|
||||
if (!from) {
|
||||
throw new Error("Expected --from when selecting changed files from git refs.");
|
||||
}
|
||||
|
||||
return runGitCommand(["diff", "--name-only", "--diff-filter=ACMR", `${from}...${toRef}`]);
|
||||
}
|
||||
|
||||
return getWorkingTreeChangedFiles();
|
||||
}
|
||||
|
||||
function addReason(reasonMap, suiteName, filePath, reason) {
|
||||
const currentReasons = reasonMap.get(suiteName) ?? [];
|
||||
currentReasons.push({ file: filePath, reason });
|
||||
reasonMap.set(suiteName, currentReasons);
|
||||
}
|
||||
|
||||
export function selectSuitesForChangedFiles(changedFiles) {
|
||||
const normalizedFiles = [...new Set(changedFiles.map((filePath) => normalizeFilePath(filePath)))].sort();
|
||||
const selectedSuites = new Set();
|
||||
const reasons = new Map();
|
||||
const unmatchedFiles = [];
|
||||
|
||||
for (const filePath of normalizedFiles) {
|
||||
let matched = false;
|
||||
|
||||
if (isPackageContractChange(filePath)) {
|
||||
selectedSuites.add("component");
|
||||
selectedSuites.add("docs");
|
||||
selectedSuites.add("a11y");
|
||||
selectedSuites.add("consumers");
|
||||
addReason(reasons, "component", filePath, "Package contract or source changed.");
|
||||
addReason(reasons, "docs", filePath, "Package changes affect the Storybook review surface.");
|
||||
addReason(reasons, "a11y", filePath, "Package changes can alter Storybook accessibility results.");
|
||||
addReason(reasons, "consumers", filePath, "Package changes affect downstream consumers.");
|
||||
matched = true;
|
||||
|
||||
if (isPackageSourceChange(filePath)) {
|
||||
selectedSuites.add("docs-smoke");
|
||||
addReason(reasons, "docs-smoke", filePath, "Interactive package source changed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocsSurfaceChange(filePath)) {
|
||||
selectedSuites.add("static");
|
||||
selectedSuites.add("docs");
|
||||
selectedSuites.add("a11y");
|
||||
addReason(reasons, "static", filePath, "Docs or Storybook source changed.");
|
||||
addReason(reasons, "docs", filePath, "Docs or Storybook source changed.");
|
||||
addReason(reasons, "a11y", filePath, "Docs or Storybook source changed.");
|
||||
matched = true;
|
||||
|
||||
if (isDocsInteractiveChange(filePath)) {
|
||||
selectedSuites.add("docs-smoke");
|
||||
addReason(reasons, "docs-smoke", filePath, "Storybook stories or smoke harness changed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (isConsumerSurfaceChange(filePath)) {
|
||||
selectedSuites.add("static");
|
||||
selectedSuites.add("consumers");
|
||||
addReason(reasons, "static", filePath, "Registry or consumer validation surface changed.");
|
||||
addReason(reasons, "consumers", filePath, "Registry or consumer validation surface changed.");
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (isStaticSurfaceChange(filePath)) {
|
||||
selectedSuites.add("static");
|
||||
addReason(reasons, "static", filePath, "Static validation surface changed.");
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
unmatchedFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSuites.has("component")) {
|
||||
selectedSuites.delete("static");
|
||||
reasons.delete("static");
|
||||
}
|
||||
|
||||
if (selectedSuites.size === 0 && unmatchedFiles.some((filePath) => isLikelyCodeChange(filePath))) {
|
||||
selectedSuites.add("static");
|
||||
|
||||
for (const filePath of unmatchedFiles) {
|
||||
if (isLikelyCodeChange(filePath)) {
|
||||
addReason(reasons, "static", filePath, "Fallback static validation for unmatched code change.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changedFiles: normalizedFiles,
|
||||
reasons: Object.fromEntries(
|
||||
[...reasons.entries()].map(([suiteName, suiteReasons]) => [suiteName, suiteReasons])
|
||||
),
|
||||
suites: suiteOrder.filter((suiteName) => selectedSuites.has(suiteName)),
|
||||
unmatchedFiles
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommandsForSuites(suiteNames) {
|
||||
const seenCommands = new Set();
|
||||
const commands = [];
|
||||
|
||||
for (const suiteName of suiteNames) {
|
||||
const suite = suiteDefinitions[suiteName];
|
||||
|
||||
if (!suite) {
|
||||
throw new Error(`Unknown suite "${suiteName}".`);
|
||||
}
|
||||
|
||||
for (const command of suite.commands) {
|
||||
const key = command.args.join("\u0000");
|
||||
|
||||
if (seenCommands.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenCommands.add(key);
|
||||
commands.push({
|
||||
...command,
|
||||
suite: suiteName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function ensureKnownSuite(name) {
|
||||
if (name === "changed") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(name in suiteDefinitions)) {
|
||||
const knownSuites = [...suiteOrder, "changed"].join(", ");
|
||||
throw new Error(`Unknown suite "${name}". Expected one of: ${knownSuites}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHarnessArtifact(name, payload) {
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
const outputPath = path.join(artifactsDir, `${name}.json`);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
export function formatSuiteListing() {
|
||||
return [...suiteOrder, "changed"]
|
||||
.map((suiteName) => {
|
||||
if (suiteName === "changed") {
|
||||
return {
|
||||
name: suiteName,
|
||||
description: "Select suites from git diff or working tree changes before validating."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: suiteName,
|
||||
description: suiteDefinitions[suiteName].description
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
|
||||
import { repoRoot } from "./core.mjs";
|
||||
|
||||
const orchBin =
|
||||
process.env.CADENCE_UI_ORCH_BIN ?? "/Users/xd/.codex/skills/orch/assets/orch";
|
||||
const defaultDbPath = path.join(repoRoot, ".artifacts", "orch", "coord.db");
|
||||
const defaultWorkspaceRoot = path.join(repoRoot, ".artifacts", "orch", "worktrees");
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Cadence UI orchestration wrapper
|
||||
|
||||
Usage:
|
||||
pnpm harness:orch -- <orch command> [flags]
|
||||
|
||||
Examples:
|
||||
pnpm harness:orch -- run init --run cadence_ui_demo --goal "Refine release UX" --summary "Break work into isolated tasks"
|
||||
pnpm harness:orch -- task add --run cadence_ui_demo --task T1 --title "Stabilize smoke tests" --summary "Fix Storybook smoke drift"
|
||||
pnpm harness:orch -- dispatch --run cadence_ui_demo --task T1 --to default-worker --body-file docs/exec-plans/task-t1.md
|
||||
pnpm harness:orch -- status --run cadence_ui_demo
|
||||
|
||||
Defaults applied by this wrapper:
|
||||
--db ${path.relative(repoRoot, defaultDbPath)}
|
||||
dispatch --repo-path ${repoRoot}
|
||||
dispatch --workspace-root ${path.relative(repoRoot, defaultWorkspaceRoot)}
|
||||
dispatch --strict-worktree
|
||||
dispatch --base-ref <current branch>
|
||||
`);
|
||||
}
|
||||
|
||||
function hasFlag(args, flag) {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function getCurrentBranch() {
|
||||
try {
|
||||
return execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
}).trim();
|
||||
} catch {
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
const rawArgs = process.argv.slice(2).filter((value) => value !== "--");
|
||||
|
||||
if (
|
||||
rawArgs.length === 0 ||
|
||||
rawArgs[0] === "help" ||
|
||||
rawArgs[0] === "--help" ||
|
||||
rawArgs[0] === "-h"
|
||||
) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(defaultDbPath), { recursive: true });
|
||||
fs.mkdirSync(defaultWorkspaceRoot, { recursive: true });
|
||||
|
||||
const command = rawArgs[0];
|
||||
const orchArgs = [...rawArgs];
|
||||
|
||||
if (!hasFlag(orchArgs, "--db")) {
|
||||
orchArgs.unshift(defaultDbPath);
|
||||
orchArgs.unshift("--db");
|
||||
}
|
||||
|
||||
if (command === "dispatch") {
|
||||
if (!hasFlag(orchArgs, "--repo-path")) {
|
||||
orchArgs.push("--repo-path", repoRoot);
|
||||
}
|
||||
|
||||
if (!hasFlag(orchArgs, "--workspace-root")) {
|
||||
orchArgs.push("--workspace-root", defaultWorkspaceRoot);
|
||||
}
|
||||
|
||||
if (!hasFlag(orchArgs, "--strict-worktree")) {
|
||||
orchArgs.push("--strict-worktree");
|
||||
}
|
||||
|
||||
if (!hasFlag(orchArgs, "--base-ref")) {
|
||||
orchArgs.push("--base-ref", getCurrentBranch());
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(orchBin, orchArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,238 @@
|
||||
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();
|
||||
@@ -0,0 +1,92 @@
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getChangedFiles,
|
||||
selectSuitesForChangedFiles,
|
||||
writeHarnessArtifact,
|
||||
repoRoot
|
||||
} from "./core.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
changedFiles: [],
|
||||
from: null,
|
||||
json: false,
|
||||
to: null
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = argv[index];
|
||||
|
||||
if (current === "--") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--json") {
|
||||
options.json = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--changed-file" || current === "--from" || current === "--to") {
|
||||
const next = argv[index + 1];
|
||||
|
||||
if (!next) {
|
||||
throw new Error(`Expected a value after ${current}.`);
|
||||
}
|
||||
|
||||
if (current === "--changed-file") {
|
||||
options.changedFiles.push(next);
|
||||
} else if (current === "--from") {
|
||||
options.from = next;
|
||||
} else {
|
||||
options.to = next;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${current}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const changedFiles = getChangedFiles(options);
|
||||
const selection = selectSuitesForChangedFiles(changedFiles);
|
||||
const reportPath = writeHarnessArtifact("selection", {
|
||||
...selection,
|
||||
selectedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...selection, reportPath: path.relative(repoRoot, reportPath) }, null, 2)}\n`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(`Harness selection report written to ${path.relative(repoRoot, reportPath)}\n`);
|
||||
|
||||
if (selection.suites.length === 0) {
|
||||
process.stdout.write("No harness suites selected for the current diff.\n");
|
||||
} else {
|
||||
process.stdout.write("Selected harness suites:\n");
|
||||
|
||||
for (const suiteName of selection.suites) {
|
||||
process.stdout.write(`- ${suiteName}\n`);
|
||||
|
||||
for (const reason of selection.reasons[suiteName] ?? []) {
|
||||
process.stdout.write(` - ${reason.file}: ${reason.reason}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.unmatchedFiles.length > 0) {
|
||||
process.stdout.write("Unmatched files:\n");
|
||||
|
||||
for (const filePath of selection.unmatchedFiles) {
|
||||
process.stdout.write(`- ${filePath}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
ensureKnownSuite,
|
||||
formatSuiteListing,
|
||||
getChangedFiles,
|
||||
getCommandsForSuites,
|
||||
pnpmBin,
|
||||
repoRoot,
|
||||
selectSuitesForChangedFiles,
|
||||
suiteDefinitions,
|
||||
writeHarnessArtifact
|
||||
} from "./core.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
changedFiles: [],
|
||||
dryRun: false,
|
||||
from: null,
|
||||
list: false,
|
||||
to: null,
|
||||
suite: "component"
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = argv[index];
|
||||
|
||||
if (current === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--list") {
|
||||
options.list = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
current === "--changed-file" ||
|
||||
current === "--from" ||
|
||||
current === "--suite" ||
|
||||
current === "--to"
|
||||
) {
|
||||
const next = argv[index + 1];
|
||||
|
||||
if (!next) {
|
||||
throw new Error(`Expected a value after ${current}.`);
|
||||
}
|
||||
|
||||
if (current === "--changed-file") {
|
||||
options.changedFiles.push(next);
|
||||
} else if (current === "--from") {
|
||||
options.from = next;
|
||||
} else if (current === "--to") {
|
||||
options.to = next;
|
||||
} else {
|
||||
options.suite = next;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "--") {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${current}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function runCommand(command) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const startTime = Date.now();
|
||||
|
||||
process.stdout.write(`\n[harness] ${command.label}\n`);
|
||||
process.stdout.write(`[harness] pnpm ${command.args.join(" ")}\n\n`);
|
||||
|
||||
const result = spawnSync(pnpmBin, command.args, {
|
||||
cwd: repoRoot,
|
||||
shell: false,
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
return {
|
||||
command: `pnpm ${command.args.join(" ")}`,
|
||||
durationMs: Date.now() - startTime,
|
||||
exitCode: result.status ?? 1,
|
||||
label: command.label,
|
||||
startedAt,
|
||||
status: result.status === 0 ? "passed" : "failed"
|
||||
};
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (options.list) {
|
||||
process.stdout.write("Available harness suites:\n");
|
||||
|
||||
for (const suite of formatSuiteListing()) {
|
||||
process.stdout.write(`- ${suite.name}: ${suite.description}\n`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
ensureKnownSuite(options.suite);
|
||||
const selection =
|
||||
options.suite === "changed"
|
||||
? selectSuitesForChangedFiles(getChangedFiles(options))
|
||||
: null;
|
||||
const selectedSuites = selection?.suites ?? [options.suite];
|
||||
const commands = getCommandsForSuites(selectedSuites);
|
||||
const report = {
|
||||
commands: [],
|
||||
description:
|
||||
options.suite === "changed"
|
||||
? "Validate suites selected from the current git diff or working tree."
|
||||
: suiteDefinitions[options.suite].description,
|
||||
finishedAt: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: options.dryRun ? "dry-run" : "passed",
|
||||
suite: options.suite,
|
||||
selectedSuites
|
||||
};
|
||||
|
||||
if (selection) {
|
||||
report.selection = selection;
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
if (options.dryRun) {
|
||||
report.commands.push({
|
||||
command: `pnpm ${command.args.join(" ")}`,
|
||||
label: command.label,
|
||||
status: "planned",
|
||||
suite: command.suite
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = runCommand(command);
|
||||
report.commands.push(result);
|
||||
|
||||
if (result.status === "failed") {
|
||||
report.status = "failed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
report.finishedAt = new Date().toISOString();
|
||||
|
||||
const reportPath = writeHarnessArtifact(options.suite, report);
|
||||
process.stdout.write(`\n[harness] Report written to ${path.relative(repoRoot, reportPath)}\n`);
|
||||
|
||||
if (report.status === "failed") {
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user