326 lines
16 KiB
JavaScript
326 lines
16 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { afterEach, describe, it } from "vitest";
|
|
|
|
import { formatCiBaselineResult, verifyCiBaseline } from "./verify-ci-baseline.mjs";
|
|
|
|
const VALID_PACKAGE = {
|
|
private: true,
|
|
packageManager: "pnpm@10.33.0",
|
|
scripts: {
|
|
build: "pnpm --filter @toolctrl/db prisma:generate && pnpm --filter @toolctrl/db build && pnpm --filter @toolctrl/web build",
|
|
"build:ci": "pnpm run build",
|
|
lint: "eslint --max-warnings=0 --ext .tsx,.ts .",
|
|
test: "pnpm --filter @toolctrl/web test",
|
|
"test:packages": "node scripts/verify-package-test-scripts.mjs && pnpm --filter './packages/*' --if-present test",
|
|
"test:mobile": "pnpm --filter @toolctrl/mobile test",
|
|
typecheck: "tsc -b",
|
|
"verify:i18n": "tsx scripts/i18n-check.ts && node scripts/verify-hardcoded-strings.mjs && eslint --max-warnings=0 --ext .tsx,.ts packages/ui-app/src packages/ui-subframe/src packages/ui-mock/src",
|
|
"verify:ui-hot-swap": "tsx scripts/verify-ui-hot-swap.ts",
|
|
"test:hot-swap": "pnpm exec vitest run scripts/verify-ui-hot-swap.test.ts && pnpm run verify:ui-hot-swap",
|
|
"verify:test-coverage": "node scripts/verify-test-coverage.mjs",
|
|
"verify:sidebar-routes": "pnpm exec vitest run apps/web/app/application/sidebar/AdminSidebar.test.tsx apps/web/app/application/sidebar/AppSidebar.test.tsx apps/web/app/components/layouts/sidebars/shadcn/app-sidebar.test.tsx apps/web/app/routes/admin/admin-route-targets.test.ts apps/web/app/routes.test.ts",
|
|
"verify:a11y-coverage": "node scripts/verify-a11y-coverage.mjs",
|
|
"test:a11y": "pnpm exec vitest run apps/web/app/components/ui/button.a11y.test.tsx apps/web/app/components/ui/errors/ErrorPage.a11y.test.tsx apps/web/app/components/ui/*.axe.test.tsx apps/web/app/components/core/SkipToContent.test.tsx apps/web/app/components/aceternity/HoverEffect.a11y.test.tsx apps/web/app/components/ui/lists/ViewToggle.a11y.test.tsx apps/web/app/components/ui/lists/ViewToggleWithUrl.a11y.test.tsx apps/web/app/components/ui/tabs/TabsWithIcons.a11y.test.tsx scripts/verify-color-contrast.test.mjs",
|
|
},
|
|
};
|
|
|
|
const PHASE_C_DESCRIPTOR_SELECTORS = [
|
|
"Property[key.name=/^(title|label|placeholder|ariaLabel|alt|description|message|heading|subject)$/] > Literal[value=/[A-Za-zÄÖÜäöü]/]",
|
|
"Property[key.value=/^(title|label|placeholder|aria-label|ariaLabel|alt|description|message|heading|subject)$/] > Literal[value=/[A-Za-zÄÖÜäöü]/]",
|
|
"Property[key.name=/^(title|label|placeholder|ariaLabel|alt|description|message|heading|subject)$/] > TemplateLiteral",
|
|
"Property[key.value=/^(title|label|placeholder|aria-label|ariaLabel|alt|description|message|heading|subject)$/] > TemplateLiteral",
|
|
];
|
|
|
|
const VALID_ESLINTRC = {
|
|
root: true,
|
|
rules: {
|
|
"no-console": "warn",
|
|
"@typescript-eslint/no-unused-vars": "warn",
|
|
},
|
|
overrides: [
|
|
{
|
|
files: ["packages/ui-app/src/**/*.{ts,tsx}", "packages/ui-subframe/src/**/*.{ts,tsx}", "packages/ui-mock/src/**/*.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
...PHASE_C_DESCRIPTOR_SELECTORS.map((selector) => ({
|
|
selector,
|
|
message: "Use i18n key fields instead of hardcoded user-facing strings in Phase C UI package descriptors.",
|
|
})),
|
|
],
|
|
},
|
|
},
|
|
{
|
|
files: ["packages/ui-app/src/**/*.{tsx}", "packages/ui-subframe/src/**/*.{tsx}", "packages/ui-mock/src/**/*.{tsx}"],
|
|
rules: {
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
...PHASE_C_DESCRIPTOR_SELECTORS.map((selector) => ({
|
|
selector,
|
|
message: "Use i18n key fields instead of hardcoded user-facing strings in Phase C UI package descriptors.",
|
|
})),
|
|
{
|
|
selector: "JSXText[value=/\\S/]",
|
|
message: "Use i18n label keys or translated values instead of hardcoded JSX text in Phase C UI packages.",
|
|
},
|
|
{
|
|
selector: "JSXAttribute[name.name=/^(aria-label|title|placeholder)$/] > Literal[value=/[A-Za-zÄÖÜäöü]/]",
|
|
message: "Use i18n label keys or translated values instead of hardcoded JSX accessibility/title text in Phase C UI packages.",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const fixtures = [];
|
|
|
|
function writeFile(rootDir, relativePath, content) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content);
|
|
}
|
|
|
|
function writeJson(rootDir, relativePath, value) {
|
|
writeFile(rootDir, relativePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function createFixture(overrides = {}) {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-baseline-"));
|
|
fixtures.push(rootDir);
|
|
|
|
writeJson(rootDir, "package.json", overrides.rootPackage ?? VALID_PACKAGE);
|
|
writeFile(rootDir, "pnpm-lock.yaml", overrides.lockfile ?? "lockfileVersion: '9.0'\n");
|
|
writeJson(rootDir, ".eslintrc", overrides.eslintrc ?? VALID_ESLINTRC);
|
|
writeFile(rootDir, "packages/ui-app/src/index.ts", "export const uiApp = true;\n");
|
|
writeFile(rootDir, "packages/ui-subframe/src/index.ts", "export const uiSubframe = true;\n");
|
|
writeFile(rootDir, "packages/ui-mock/src/index.ts", "export const uiMock = true;\n");
|
|
|
|
if (overrides.remove) {
|
|
for (const relativePath of overrides.remove) {
|
|
fs.rmSync(path.join(rootDir, relativePath), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
return rootDir;
|
|
}
|
|
|
|
function mutatePackage(mutator) {
|
|
const rootPackage = structuredClone(VALID_PACKAGE);
|
|
mutator(rootPackage);
|
|
return rootPackage;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-ci-baseline", () => {
|
|
it("passes for a repository with required CI prerequisites", () => {
|
|
const rootDir = createFixture();
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
const output = formatCiBaselineResult(result);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: packageManager is pnpm@10.33.0"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"typecheck\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"test:mobile\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"build:ci\" is defined"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"build:ci\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"lint\" invokes ESLint"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"lint\" fails on ESLint warnings"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"verify:i18n\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"verify:ui-hot-swap\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"test:hot-swap\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"verify:test-coverage\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"verify:sidebar-routes\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"verify:a11y-coverage\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"test:a11y\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"test:mobile\" matches the required CI command"));
|
|
assert.ok(result.passes.some((pass) => pass === "package.json: script \"verify:i18n\" runs ESLint over Phase C UI packages"));
|
|
assert.ok(result.passes.some((pass) => pass === "pnpm-lock.yaml: exists"));
|
|
assert.ok(result.passes.some((pass) => pass === ".eslintrc: rule \"no-console\" is warn"));
|
|
assert.ok(result.passes.some((pass) => pass === ".eslintrc: rule \"@typescript-eslint/no-unused-vars\" is warn"));
|
|
assert.ok(result.passes.some((pass) => pass.includes(".eslintrc: Phase C i18n ESLint selector \"JSXText")));
|
|
assert.match(output, /^PASS CI baseline verification/);
|
|
assert.match(output, /Summary: \d+ passed, 0 observed, 0 failed/);
|
|
});
|
|
|
|
it("fails when the lockfile is missing", () => {
|
|
const rootDir = createFixture({ remove: ["pnpm-lock.yaml"] });
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /pnpm-lock\.yaml: missing required CI baseline file/);
|
|
});
|
|
|
|
it("fails when the lint script is missing", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
delete rootPackage.scripts.lint;
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: missing required script "lint"/);
|
|
});
|
|
|
|
it("fails when the lint script does not invoke ESLint", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
rootPackage.scripts.lint = "prettier --check .";
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: script "lint" must invoke ESLint/);
|
|
});
|
|
|
|
it("fails when the lint script allows ESLint warnings", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
rootPackage.scripts.lint = "eslint --ext .tsx,.ts .";
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: script "lint" must include --max-warnings=0/);
|
|
});
|
|
|
|
it("fails when ESLint config is missing", () => {
|
|
const rootDir = createFixture({ remove: [".eslintrc"] });
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.eslintrc: missing required CI baseline file/);
|
|
});
|
|
|
|
it("fails when required ESLint warn baselines are not explicit", () => {
|
|
const rootDir = createFixture({
|
|
eslintrc: {
|
|
root: true,
|
|
rules: {
|
|
"no-console": "error",
|
|
"@typescript-eslint/no-unused-vars": "off",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.eslintrc: rule "no-console" must be warn for the current CI baseline/);
|
|
assert.match(result.failures.join("\n"), /\.eslintrc: rule "@typescript-eslint\/no-unused-vars" must be warn for the current CI baseline/);
|
|
});
|
|
|
|
it("fails when verify:i18n does not run ESLint over Phase C UI packages", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
rootPackage.scripts["verify:i18n"] = "tsx scripts/i18n-check.ts";
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: script "verify:i18n" must run ESLint over Phase C UI packages/);
|
|
});
|
|
|
|
it("fails when critical verification script bodies are softened", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
rootPackage.scripts["verify:i18n"] = "eslint --ext .tsx,.ts packages/ui-app/src packages/ui-subframe/src packages/ui-mock/src";
|
|
rootPackage.scripts["verify:ui-hot-swap"] = "echo skipped";
|
|
rootPackage.scripts["test:hot-swap"] = "pnpm run verify:ui-hot-swap || true";
|
|
rootPackage.scripts["verify:test-coverage"] = "echo skipped";
|
|
rootPackage.scripts["verify:sidebar-routes"] = "echo skipped";
|
|
rootPackage.scripts["verify:a11y-coverage"] = "echo skipped";
|
|
rootPackage.scripts["test:a11y"] = "echo skipped";
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
const failures = result.failures.join("\n");
|
|
|
|
assert.match(failures, /package\.json: script "verify:i18n" must be exactly "tsx scripts\/i18n-check\.ts && node scripts\/verify-hardcoded-strings\.mjs && eslint --max-warnings=0 --ext \.tsx,\.ts packages\/ui-app\/src packages\/ui-subframe\/src packages\/ui-mock\/src"/);
|
|
assert.match(failures, /package\.json: script "verify:ui-hot-swap" must be exactly "tsx scripts\/verify-ui-hot-swap\.ts"/);
|
|
assert.match(failures, /package\.json: script "test:hot-swap" must be exactly "pnpm exec vitest run scripts\/verify-ui-hot-swap\.test\.ts && pnpm run verify:ui-hot-swap"/);
|
|
assert.match(failures, /package\.json: script "verify:test-coverage" must be exactly "node scripts\/verify-test-coverage\.mjs"/);
|
|
assert.match(failures, /package\.json: script "verify:sidebar-routes" must be exactly "pnpm exec vitest run apps\/web\/app\/application\/sidebar\/AdminSidebar\.test\.tsx apps\/web\/app\/application\/sidebar\/AppSidebar\.test\.tsx apps\/web\/app\/components\/layouts\/sidebars\/shadcn\/app-sidebar\.test\.tsx apps\/web\/app\/routes\/admin\/admin-route-targets\.test\.ts apps\/web\/app\/routes\.test\.ts"/);
|
|
assert.match(failures, /package\.json: script "verify:a11y-coverage" must be exactly "node scripts\/verify-a11y-coverage\.mjs"/);
|
|
assert.match(failures, /package\.json: script "test:a11y" must be exactly "pnpm exec vitest run apps\/web\/app\/components\/ui\/button\.a11y\.test\.tsx apps\/web\/app\/components\/ui\/errors\/ErrorPage\.a11y\.test\.tsx apps\/web\/app\/components\/ui\/\*\.axe\.test\.tsx apps\/web\/app\/components\/core\/SkipToContent\.test\.tsx apps\/web\/app\/components\/aceternity\/HoverEffect\.a11y\.test\.tsx apps\/web\/app\/components\/ui\/lists\/ViewToggle\.a11y\.test\.tsx apps\/web\/app\/components\/ui\/lists\/ViewToggleWithUrl\.a11y\.test\.tsx apps\/web\/app\/components\/ui\/tabs\/TabsWithIcons\.a11y\.test\.tsx scripts\/verify-color-contrast\.test\.mjs"/);
|
|
});
|
|
|
|
it("fails when build omits Prisma generation", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
rootPackage.scripts.build = "pnpm --filter @toolctrl/db build && pnpm --filter @toolctrl/web build";
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/package\.json: script "build" must be exactly "pnpm --filter @toolctrl\/db prisma:generate && pnpm --filter @toolctrl\/db build && pnpm --filter @toolctrl\/web build"/,
|
|
);
|
|
});
|
|
|
|
it("fails when verify:i18n suppresses unmatched lint roots", () => {
|
|
const rootDir = createFixture({
|
|
rootPackage: mutatePackage((rootPackage) => {
|
|
rootPackage.scripts["verify:i18n"] = "eslint --no-error-on-unmatched-pattern --ext .tsx,.ts packages/ui-app/src packages/ui-subframe/src packages/ui-mock/src";
|
|
}),
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /package\.json: script "verify:i18n" must not include --no-error-on-unmatched-pattern/);
|
|
});
|
|
|
|
it("fails when a Phase C UI lint root is missing", () => {
|
|
const rootDir = createFixture({ remove: ["packages/ui-mock/src"] });
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /packages\/ui-mock\/src: missing Phase C UI lint root/);
|
|
});
|
|
|
|
it("fails when Phase C i18n JSX ESLint selectors are missing", () => {
|
|
const rootDir = createFixture({
|
|
eslintrc: {
|
|
...VALID_ESLINTRC,
|
|
overrides: [VALID_ESLINTRC.overrides[0]],
|
|
},
|
|
});
|
|
|
|
const result = verifyCiBaseline(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /\.eslintrc: missing Phase C i18n ESLint selector "JSXText/);
|
|
assert.match(result.failures.join("\n"), /\.eslintrc: missing Phase C i18n ESLint selector "JSXAttribute/);
|
|
});
|
|
|
|
it("CLI exits non-zero and prints path-specific output for a missing prerequisite", () => {
|
|
const rootDir = createFixture({ remove: ["pnpm-lock.yaml"] });
|
|
const scriptPath = path.resolve("scripts/verify-ci-baseline.mjs");
|
|
|
|
assert.throws(
|
|
() => execFileSync(process.execPath, [scriptPath], { cwd: rootDir, encoding: "utf8", stdio: "pipe" }),
|
|
(error) => {
|
|
assert.equal(error.status, 1);
|
|
assert.match(error.stderr, /^FAIL CI baseline verification/);
|
|
assert.match(error.stderr, /FAIL pnpm-lock\.yaml: missing required CI baseline file/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|