Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-ui-mock-visual.ts
T

170 lines
6.4 KiB
TypeScript

#!/usr/bin/env tsx
import assert from "node:assert/strict";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Use a relative path so vitest's Vite-based resolver can find the workspace package
// even when it has not been hoisted into the top-level node_modules/@toolctrl directory.
import { createHotSwapDrillPage, generateMockArtifacts } from "../packages/ui-mock/src/index.js";
const require = createRequire(import.meta.url);
const { JSDOM } = require("jsdom") as {
JSDOM: new (html: string) => { window: { document: Document } };
};
const BASELINE_DIR = "packages/ui-mock/visual-baseline";
const BASELINE_FILES = {
module: "hot-swap.mock.ts",
html: "hot-swap.html",
snapshot: "hot-swap.snapshot.json",
} as const;
export type UiMockVisualResult = {
readonly passes: readonly string[];
readonly failures: readonly string[];
readonly observations: readonly string[];
};
export function buildUiMockVisualDrill() {
const page = createHotSwapDrillPage();
const artifacts = generateMockArtifacts(page);
return {
module: artifacts.module,
html: artifacts.html,
snapshot: `${JSON.stringify(artifacts.snapshot, null, 2)}\n`,
};
}
export function writeUiMockVisualBaseline(rootDir = process.cwd()) {
const drill = buildUiMockVisualDrill();
const baselineDir = path.join(rootDir, BASELINE_DIR);
fs.mkdirSync(baselineDir, { recursive: true });
for (const [artifactName, fileName] of Object.entries(BASELINE_FILES)) {
fs.writeFileSync(path.join(baselineDir, fileName), drill[artifactName as keyof typeof drill], "utf8");
}
}
export function verifyUiMockVisualBaseline(rootDir = process.cwd()): UiMockVisualResult {
const drill = buildUiMockVisualDrill();
const passes: string[] = [];
const failures: string[] = [];
const observations: string[] = [];
assert.equal(drill.module.includes("export const mockPage"), true);
assert.equal(drill.html.includes('data-ui-mock-route="/mock/phase-c/hot-swap"'), true);
const domSmoke = verifyRenderedHotSwapDrill(drill.html);
passes.push(...domSmoke.passes);
failures.push(...domSmoke.failures);
for (const [artifactName, fileName] of Object.entries(BASELINE_FILES)) {
const relativePath = `${BASELINE_DIR}/${fileName}`;
const baselinePath = path.join(rootDir, relativePath);
if (!fs.existsSync(baselinePath)) {
failures.push(`${relativePath}: missing UI mock static artifact baseline`);
continue;
}
const expected = normalizeNewlines(fs.readFileSync(baselinePath, "utf8"));
const actual = normalizeNewlines(drill[artifactName as keyof typeof drill]);
if (actual === expected) {
passes.push(`${relativePath}: matches generated hot-swap drill artifact`);
} else {
failures.push(`${relativePath}: static artifact baseline drifted; run pnpm run verify:ui-visual -- --update after reviewing the change`);
}
}
observations.push("UI mock static drill renders through JSDOM in CI; browser screenshot and E2E commands remain intentionally outside this gate.");
return { passes, failures, observations };
}
function verifyRenderedHotSwapDrill(html: string): { passes: string[]; failures: string[] } {
const passes: string[] = [];
const failures: string[] = [];
const document = new JSDOM(html).window.document;
const body = document.body;
const shell = document.querySelector(".mock-shell");
const activeLinks = [...document.querySelectorAll(".mock-link")].filter((item) => item.getAttribute("data-active") === "true");
const cards = [...document.querySelectorAll('section[aria-label="Mock metrics"] .mock-card')];
const stateScript = document.querySelector("#mock-page-state");
if (body?.getAttribute("data-ui-mock-route") === "/mock/phase-c/hot-swap" && shell?.getAttribute("data-ui-mock-page") === "phase-c-hot-swap") {
passes.push("rendered hot-swap HTML exposes canonical mock route and page id");
} else {
failures.push("rendered hot-swap HTML is missing canonical mock route or page id");
}
if (activeLinks.length === 1) {
passes.push("rendered hot-swap navigation has exactly one active route");
} else {
failures.push(`rendered hot-swap navigation expected one active route but found ${activeLinks.length}`);
}
if (cards.length >= 3 && document.body.textContent?.includes("Serialized state")) {
passes.push("rendered hot-swap page includes metric cards and serialized state");
} else {
failures.push("rendered hot-swap page is missing metric cards or serialized state");
}
try {
const state = stateScript?.textContent ? JSON.parse(decodeHtmlEntities(stateScript.textContent)) : null;
if (state?.status === "ready") {
passes.push("rendered hot-swap state payload parses as ready");
} else {
failures.push("rendered hot-swap state payload is missing or invalid");
}
} catch {
failures.push("rendered hot-swap state payload is missing or invalid");
}
return { passes, failures };
}
function decodeHtmlEntities(value: string) {
return value.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
}
export function formatUiMockVisualResult(result: UiMockVisualResult): string {
const status = result.failures.length > 0 ? "FAIL" : "PASS";
return [
`${status} UI mock static artifact baseline verification`,
...result.passes.map((pass) => `PASS ${pass}`),
...result.observations.map((observation) => `OBSERVE ${observation}`),
...result.failures.map((failure) => `FAIL ${failure}`),
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
"",
].join("\n");
}
function normalizeNewlines(value: string): string {
return value.replace(/\r\n/g, "\n");
}
export function assertVisualBaselineUpdateAllowed(env: NodeJS.ProcessEnv = process.env): void {
if (env.CI) {
throw new Error("Refusing to update UI mock static artifact baselines while CI is set.");
}
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
if (process.argv.includes("--update")) {
assertVisualBaselineUpdateAllowed();
writeUiMockVisualBaseline(process.cwd());
}
const result = verifyUiMockVisualBaseline(process.cwd());
const output = formatUiMockVisualResult(result);
if (result.failures.length > 0) {
process.stderr.write(output);
process.exitCode = 1;
} else {
process.stdout.write(output);
}
}