380 lines
12 KiB
JavaScript
380 lines
12 KiB
JavaScript
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
|
|
};
|
|
});
|
|
}
|