#!/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(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/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); } }