170 lines
6.4 KiB
TypeScript
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(/"/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);
|
|
}
|
|
}
|