452 lines
19 KiB
TypeScript
452 lines
19 KiB
TypeScript
// @ts-nocheck -- standalone evidence runner executed directly by Node 24.
|
|
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import { lstat, mkdir, readFile, readdir, readlink, writeFile } from "node:fs/promises";
|
|
import { relative, resolve } from "node:path";
|
|
|
|
import { chromium } from "@playwright/test";
|
|
|
|
import {
|
|
TECH_LOG_BREAKPOINT_WIDTHS,
|
|
TECH_LOG_CANONICAL_ROUTES,
|
|
TECH_LOG_FIXED_TIME,
|
|
TECH_LOG_PUBLIC_FIXTURE_PATHS,
|
|
TECH_LOG_STUDIO_STATE_PATHS,
|
|
TECH_LOG_UNKNOWN_PUBLIC_PATHS,
|
|
} from "./tech-log-fixtures.ts";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { PNG } = require(
|
|
resolve("node_modules/.pnpm/playwright-core@1.62.0/node_modules/playwright-core/lib/utilsBundle.js"),
|
|
);
|
|
|
|
const sourceBaseUrl = process.env.TECH_LOG_SOURCE_URL ?? "http://127.0.0.1:4375";
|
|
const targetBaseUrl = process.env.TECH_LOG_TARGET_URL ?? "http://127.0.0.1:4174";
|
|
const sourceRoot = process.env.TECH_LOG_SOURCE_ROOT ?? "/home/donghyeon/workspace/techlog-studio-frontend";
|
|
const outputPath = process.env.TECH_LOG_PARITY_OUTPUT ?? "docs/operations/evidence/tech-log-source-parity.json";
|
|
const fixedDate = new Date(TECH_LOG_FIXED_TIME);
|
|
const noMotionCss = `
|
|
*, *::before, *::after {
|
|
animation-delay: 0s !important;
|
|
animation-duration: 0s !important;
|
|
caret-color: transparent !important;
|
|
transition-delay: 0s !important;
|
|
transition-duration: 0s !important;
|
|
}
|
|
`;
|
|
|
|
const allCases = [
|
|
...TECH_LOG_CANONICAL_ROUTES.flatMap(({ routeId, path }) =>
|
|
[360, 1440].map((width) => ({ name: `${routeId}-${width}`, path, width }))),
|
|
...TECH_LOG_PUBLIC_FIXTURE_PATHS.map((path) => ({
|
|
name: `TECH_LOG_PUBLIC_FIXTURE-${path.slice(1).replaceAll("/", "-").toUpperCase()}`,
|
|
path,
|
|
width: 1440,
|
|
})),
|
|
...TECH_LOG_BREAKPOINT_WIDTHS.map((width) => ({
|
|
name: `TECH_LOG_HOME-BREAKPOINT-${width}`,
|
|
path: "/",
|
|
width,
|
|
})),
|
|
...TECH_LOG_STUDIO_STATE_PATHS.map(([state, path]) => ({
|
|
name: `TECH_LOG_STUDIO_STATE-${state}`,
|
|
path,
|
|
width: 1440,
|
|
})),
|
|
...TECH_LOG_UNKNOWN_PUBLIC_PATHS.flatMap(([route, path]) =>
|
|
[360, 1440].map((width) => ({
|
|
name: `TECH_LOG_UNKNOWN_${route.toUpperCase().replaceAll("-", "_")}-${width}`,
|
|
path,
|
|
width,
|
|
}))),
|
|
{ name: "TECH_LOG_SEARCH_DIALOG", path: "/", width: 390, action: "search-dialog" },
|
|
{ name: "TECH_LOG_STUDIO_MOBILE_MENU", path: "/studio", width: 390, action: "studio-menu" },
|
|
{
|
|
name: "TECH_LOG_STUDIO_IMMEDIATE_PREVIEW",
|
|
path: "/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
|
width: 1440,
|
|
action: "immediate-preview",
|
|
},
|
|
{
|
|
name: "TECH_LOG_STUDIO_DIRTY_DIALOG",
|
|
path: "/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
|
width: 1440,
|
|
action: "dirty-dialog",
|
|
},
|
|
{
|
|
name: "TECH_LOG_STUDIO_CURRENT_PREVIEW_CREATED",
|
|
path: "/studio/documents/11111111-1111-4111-8111-111111111113/preview",
|
|
width: 1440,
|
|
action: "create-preview",
|
|
},
|
|
{
|
|
name: "TECH_LOG_STUDIO_UNPUBLISH_DIALOG",
|
|
path: "/studio/publications",
|
|
width: 1440,
|
|
action: "unpublish-dialog",
|
|
},
|
|
{
|
|
name: "TECH_LOG_STUDIO_WARNING_ACKNOWLEDGED",
|
|
path: "/studio/documents/11111111-1111-4111-8111-111111111116/validation",
|
|
width: 1440,
|
|
action: "warning-acknowledged",
|
|
},
|
|
];
|
|
const caseFilter = process.env.TECH_LOG_PARITY_CASE;
|
|
const cases = caseFilter
|
|
? allCases.filter(({ name }) => name.includes(caseFilter))
|
|
: allCases;
|
|
const diagnosticDirectory = process.env.TECH_LOG_PARITY_DIAGNOSTICS ?? "/tmp/techlog-parity-diagnostics";
|
|
|
|
async function interact(page, action) {
|
|
if (action === "search-dialog") {
|
|
await page.getByRole("button", { name: "검색 열기" }).click();
|
|
} else if (action === "studio-menu") {
|
|
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
|
} else if (action === "immediate-preview") {
|
|
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
|
} else if (action === "dirty-dialog") {
|
|
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
|
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
|
} else if (action === "create-preview") {
|
|
await page.getByRole("button", { name: "Public Preview 만들기" }).click();
|
|
await page.getByText("현재 저장 버전의 Public Preview입니다.").waitFor();
|
|
} else if (action === "unpublish-dialog") {
|
|
await page.getByRole("button", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가 게시 취소/ }).click();
|
|
} else if (action === "warning-acknowledged") {
|
|
await page.getByRole("button", { name: "검증하기" }).click();
|
|
await page.getByText("경고를 확인하고 Preview를 만들 수 있습니다").waitFor();
|
|
await page.getByRole("link", { name: "Public Preview 만들기" }).click();
|
|
await page.getByRole("button", { name: "Public Preview 만들기" }).click();
|
|
await page.getByText("현재 저장 버전의 Public Preview입니다.").waitFor();
|
|
await page.getByRole("link", { name: "게시 준비로 이동" }).click();
|
|
await page.getByRole("checkbox").check();
|
|
await page.getByRole("button", { name: /게시$/ }).waitFor();
|
|
}
|
|
}
|
|
|
|
async function settle(page, baseUrl, parityCase, waitForTargetBootstrap) {
|
|
page.setDefaultNavigationTimeout(15_000);
|
|
page.setDefaultTimeout(15_000);
|
|
await page.setViewportSize({ width: parityCase.width, height: 1000 });
|
|
await page.clock.setFixedTime(fixedDate);
|
|
const navigationResponse = await page.goto(
|
|
new URL(parityCase.path, baseUrl).href,
|
|
{ waitUntil: "networkidle" },
|
|
);
|
|
if (!navigationResponse) throw new Error(`Missing navigation response: ${parityCase.name}`);
|
|
const response = await responseMetadata(navigationResponse);
|
|
if (response.mimeType === "text/html" && waitForTargetBootstrap) {
|
|
// The target sets these only after config + release-manifest bodies have
|
|
// been fully read, validated, and composed. This is the product's boot
|
|
// completion signal, not a delay or retry.
|
|
await page.locator("html[data-build-id][data-release-id]").waitFor();
|
|
}
|
|
await page.locator("body").waitFor({ state: "visible" });
|
|
await page.evaluate(async () => document.fonts.ready);
|
|
await page.addStyleTag({ content: noMotionCss });
|
|
await interact(page, parityCase.action);
|
|
await page.evaluate(async () => document.fonts.ready);
|
|
return response;
|
|
}
|
|
|
|
async function productSubtree(page) {
|
|
return page.evaluate(() => {
|
|
const normalize = (value) => value
|
|
.replace(/_[rR][^\s"']*?(?=-|\s|$)/g, "<generated-id>")
|
|
.replace(/_([A-Za-z0-9]+)_[A-Za-z0-9]+_(\d+)/g, "_$1_<generated-module>_$2");
|
|
const frameworkGeneratedAttributes = new Set([
|
|
"data-discover", // React Router link discovery metadata.
|
|
"data-nimg", // Next/Image metadata derived from source props.
|
|
"decoding", // Next/Image output derived from priority=false.
|
|
"srcset", // Next/Image widths derived from one source SVG.
|
|
"selected", // Next SSR output for a controlled <select> value.
|
|
]);
|
|
const serialize = (node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.nodeValue?.replace(/\s+/g, " ").trim() ?? "";
|
|
return text ? { type: "text", text } : null;
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
|
const element = node;
|
|
return {
|
|
type: "element",
|
|
tag: element.tagName.toLowerCase(),
|
|
classes: [...element.classList].map(normalize),
|
|
attributes: [...element.attributes]
|
|
.filter(({ name }) =>
|
|
name !== "class" && !frameworkGeneratedAttributes.has(name))
|
|
.map(({ name, value }) => [name, normalize(value)])
|
|
.sort(([left], [right]) => left.localeCompare(right)),
|
|
children: [...element.childNodes].map(serialize).filter(Boolean),
|
|
};
|
|
};
|
|
const root = document.querySelector(".site-frame, .studio-app") ?? document.body;
|
|
return serialize(root);
|
|
});
|
|
}
|
|
|
|
function sha256(value) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
async function responseMetadata(response) {
|
|
const body = await response.body();
|
|
const contentType = response.headers()["content-type"] ?? "";
|
|
return {
|
|
status: response.status(),
|
|
contentType,
|
|
mimeType: contentType.split(";", 1)[0].toLowerCase(),
|
|
bodyBytes: body.byteLength,
|
|
bodySha256: sha256(body),
|
|
};
|
|
}
|
|
|
|
function responseContractEqual(source, target) {
|
|
if (source.status !== target.status || source.mimeType !== target.mimeType) return false;
|
|
if (source.status !== 404 || source.mimeType === "text/html") return true;
|
|
return source.contentType === target.contentType &&
|
|
source.bodyBytes === 9 &&
|
|
target.bodyBytes === 9 &&
|
|
source.bodySha256 === target.bodySha256 &&
|
|
source.bodySha256 === sha256("Not Found");
|
|
}
|
|
|
|
const excludedSourceDirectories = new Set([
|
|
".git", ".next", ".sites-runtime", ".vinext", ".turbo", "artifacts", "coverage", "dist", "node_modules",
|
|
]);
|
|
|
|
async function sourceChecksums(root) {
|
|
const checksums = [];
|
|
async function walk(directory) {
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && excludedSourceDirectories.has(entry.name)) continue;
|
|
const absolutePath = resolve(directory, entry.name);
|
|
const sourcePath = relative(root, absolutePath).replaceAll("\\", "/");
|
|
if (entry.isDirectory()) {
|
|
await walk(absolutePath);
|
|
} else if (entry.isSymbolicLink()) {
|
|
const target = await readlink(absolutePath);
|
|
checksums.push({ path: sourcePath, bytes: Buffer.byteLength(target), sha256: sha256(target), kind: "symlink" });
|
|
} else if ((await lstat(absolutePath)).isFile()) {
|
|
const bytes = await readFile(absolutePath);
|
|
checksums.push({ path: sourcePath, bytes: bytes.byteLength, sha256: sha256(bytes), kind: "file" });
|
|
}
|
|
}
|
|
}
|
|
await walk(resolve(root));
|
|
checksums.sort((left, right) => left.path.localeCompare(right.path));
|
|
return {
|
|
files: checksums,
|
|
digest: sha256(checksums.map(({ path, bytes, sha256: digest, kind }) => `${kind}\0${path}\0${bytes}\0${digest}\n`).join("")),
|
|
};
|
|
}
|
|
|
|
async function layoutProjection(page) {
|
|
return page.evaluate(() => [...document.querySelectorAll("header, main, main *, footer, dialog, .studio-app *")].map((element) => {
|
|
const box = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
tag: element.tagName.toLowerCase(),
|
|
className: [...element.classList].sort().join(" "),
|
|
top: box.top,
|
|
left: box.left,
|
|
width: box.width,
|
|
height: box.height,
|
|
fontFamily: style.fontFamily,
|
|
fontSize: style.fontSize,
|
|
lineHeight: style.lineHeight,
|
|
margin: style.margin,
|
|
padding: style.padding,
|
|
};
|
|
}));
|
|
}
|
|
|
|
function pixelDifference(sourceBuffer, targetBuffer) {
|
|
const source = PNG.sync.read(sourceBuffer);
|
|
const target = PNG.sync.read(targetBuffer);
|
|
if (source.width !== target.width || source.height !== target.height) {
|
|
return { pixels: null, sourceSize: [source.width, source.height], targetSize: [target.width, target.height] };
|
|
}
|
|
let pixels = 0;
|
|
for (let offset = 0; offset < source.data.length; offset += 4) {
|
|
if (
|
|
source.data[offset] !== target.data[offset] ||
|
|
source.data[offset + 1] !== target.data[offset + 1] ||
|
|
source.data[offset + 2] !== target.data[offset + 2] ||
|
|
source.data[offset + 3] !== target.data[offset + 3]
|
|
) pixels += 1;
|
|
}
|
|
return { pixels, sourceSize: [source.width, source.height], targetSize: [target.width, target.height] };
|
|
}
|
|
|
|
function observePage(page, errors) {
|
|
const bootRequests = [];
|
|
const records = new Map();
|
|
const isBootRequest = (request) => {
|
|
const pathname = new URL(request.url()).pathname;
|
|
return pathname === "/config.json" || pathname === "/release-manifest.json";
|
|
};
|
|
page.on("request", (request) => {
|
|
if (!isBootRequest(request)) return;
|
|
const record = {
|
|
url: request.url(),
|
|
method: request.method(),
|
|
resourceType: request.resourceType(),
|
|
state: "started",
|
|
};
|
|
records.set(request, record);
|
|
bootRequests.push(record);
|
|
});
|
|
page.on("requestfinished", (request) => {
|
|
const record = records.get(request);
|
|
if (record) record.state = "finished";
|
|
});
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") errors.push(`console: ${message.text()}`);
|
|
});
|
|
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
|
|
page.on("requestfailed", (request) => {
|
|
const failure = request.failure()?.errorText ?? "";
|
|
const record = records.get(request);
|
|
if (record) {
|
|
record.state = "failed";
|
|
record.failure = failure;
|
|
}
|
|
errors.push(`requestfailed: ${request.resourceType()} ${request.url()} ${failure}`);
|
|
});
|
|
return bootRequests;
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
const contextOptions = {
|
|
colorScheme: "light",
|
|
deviceScaleFactor: 1,
|
|
locale: "ko-KR",
|
|
reducedMotion: "reduce",
|
|
serviceWorkers: "block",
|
|
timezoneId: "Asia/Seoul",
|
|
};
|
|
const results = [];
|
|
|
|
try {
|
|
for (const parityCase of cases) {
|
|
const sourceContext = await browser.newContext(contextOptions);
|
|
const targetContext = await browser.newContext(contextOptions);
|
|
const sourcePage = await sourceContext.newPage();
|
|
const targetPage = await targetContext.newPage();
|
|
const sourceErrors = [];
|
|
const targetErrors = [];
|
|
const sourceBootRequests = observePage(sourcePage, sourceErrors);
|
|
const targetBootRequests = observePage(targetPage, targetErrors);
|
|
try {
|
|
const [sourceResponse, targetResponse] = await Promise.all([
|
|
settle(sourcePage, sourceBaseUrl, parityCase, false),
|
|
settle(targetPage, targetBaseUrl, parityCase, true),
|
|
]);
|
|
const [sourceShot, targetShot, sourceTree, targetTree, sourceLayout, targetLayout, sourceFonts, targetFonts] = await Promise.all([
|
|
sourcePage.screenshot({ fullPage: true }),
|
|
targetPage.screenshot({ fullPage: true }),
|
|
productSubtree(sourcePage),
|
|
productSubtree(targetPage),
|
|
layoutProjection(sourcePage),
|
|
layoutProjection(targetPage),
|
|
sourcePage.evaluate(() => [...document.fonts].map(({ family, status }) => ({ family, status })).filter(({ family }) => /Pretendard|IBM Plex Mono/.test(family))),
|
|
targetPage.evaluate(() => [...document.fonts].map(({ family, status }) => ({ family, status })).filter(({ family }) => /Pretendard|IBM Plex Mono/.test(family))),
|
|
]);
|
|
const pixel = pixelDifference(sourceShot, targetShot);
|
|
const sourceTreeJson = JSON.stringify(sourceTree);
|
|
const targetTreeJson = JSON.stringify(targetTree);
|
|
const structureEqual = sourceTreeJson === targetTreeJson;
|
|
const fontsEqual = JSON.stringify(sourceFonts) === JSON.stringify(targetFonts);
|
|
const responseEqual = responseContractEqual(sourceResponse, targetResponse);
|
|
const isPlainNotFound = sourceResponse.mimeType === "text/plain";
|
|
const expectsSourceNotFoundResponse =
|
|
isPlainNotFound || parityCase.path.includes("unknown");
|
|
const expectedNotFoundConsole = expectsSourceNotFoundResponse
|
|
? (message) => message === "console: Failed to load resource: the server responded with a status of 404 (Not Found)"
|
|
: () => false;
|
|
const unexplainedSourceErrors = sourceErrors.filter((message) => !expectedNotFoundConsole(message));
|
|
const unexplainedTargetErrors = targetErrors.filter((message) => !expectedNotFoundConsole(message));
|
|
const fontContractApplies = !isPlainNotFound;
|
|
const passed = pixel.pixels === 0 && structureEqual && responseEqual && (!fontContractApplies || fontsEqual) && unexplainedSourceErrors.length === 0 && unexplainedTargetErrors.length === 0;
|
|
if (!passed) {
|
|
await mkdir(diagnosticDirectory, { recursive: true });
|
|
const stem = parityCase.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
|
await Promise.all([
|
|
writeFile(resolve(diagnosticDirectory, `${stem}-source.png`), sourceShot),
|
|
writeFile(resolve(diagnosticDirectory, `${stem}-target.png`), targetShot),
|
|
writeFile(resolve(diagnosticDirectory, `${stem}-dom.json`), `${JSON.stringify({ sourceTree, targetTree, sourceLayout, targetLayout }, null, 2)}\n`),
|
|
]);
|
|
}
|
|
results.push({
|
|
name: parityCase.name,
|
|
path: parityCase.path,
|
|
width: parityCase.width,
|
|
action: parityCase.action ?? null,
|
|
pixel,
|
|
screenshotSha256: { source: sha256(sourceShot), target: sha256(targetShot) },
|
|
structureEqual,
|
|
productSubtreeSha256: { source: sha256(sourceTreeJson), target: sha256(targetTreeJson) },
|
|
responseEqual,
|
|
response: { source: sourceResponse, target: targetResponse },
|
|
fontsEqual,
|
|
fontContractApplies,
|
|
sourceFonts,
|
|
targetFonts,
|
|
sourceErrors,
|
|
targetErrors,
|
|
sourceBootRequests,
|
|
targetBootRequests,
|
|
unexplainedSourceErrors,
|
|
unexplainedTargetErrors,
|
|
passed,
|
|
});
|
|
console.log(`${passed ? "PASS" : "FAIL"} ${parityCase.name} pixels=${pixel.pixels ?? "SIZE"} tree=${structureEqual} response=${responseEqual} fonts=${fontContractApplies ? fontsEqual : "source-plain-text"} errors=${unexplainedSourceErrors.length}/${unexplainedTargetErrors.length}`);
|
|
} finally {
|
|
await Promise.all([sourceContext.close(), targetContext.close()]);
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const [sourceTree, buildManifest, viteManifest] = await Promise.all([
|
|
sourceChecksums(sourceRoot),
|
|
readFile("artifacts/release/build-manifest.json"),
|
|
readFile("dist/.vite/manifest.json"),
|
|
]);
|
|
const evidenceWithoutDigest = {
|
|
generatedAt: new Date().toISOString(),
|
|
schemaVersion: 1,
|
|
source: {
|
|
baseUrl: sourceBaseUrl,
|
|
root: sourceRoot,
|
|
treeSha256: sourceTree.digest,
|
|
checksums: sourceTree.files,
|
|
},
|
|
target: {
|
|
baseUrl: targetBaseUrl,
|
|
commit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
|
buildManifestSha256: sha256(buildManifest),
|
|
viteManifestSha256: sha256(viteManifest),
|
|
},
|
|
conditions: { ...contextOptions, fixedTime: TECH_LOG_FIXED_TIME, viewportHeight: 1000, screenshots: "fullPage", masks: 0, freshSourceAndTargetContextsPerCase: true },
|
|
caseInventory: allCases,
|
|
caseInventorySha256: sha256(JSON.stringify(allCases)),
|
|
total: results.length,
|
|
passed: results.filter((result) => result.passed).length,
|
|
failed: results.filter((result) => !result.passed).length,
|
|
totalDifferentPixels: results.reduce((sum, result) => sum + (result.pixel.pixels ?? 0), 0),
|
|
results,
|
|
};
|
|
const evidence = {
|
|
...evidenceWithoutDigest,
|
|
evidenceSha256: sha256(JSON.stringify(evidenceWithoutDigest)),
|
|
};
|
|
await mkdir(resolve(outputPath, ".."), { recursive: true });
|
|
await writeFile(outputPath, `${JSON.stringify(evidence, null, 2)}\n`);
|
|
console.log(JSON.stringify({ outputPath, total: evidence.total, passed: evidence.passed, failed: evidence.failed, totalDifferentPixels: evidence.totalDifferentPixels }));
|
|
if (evidence.failed > 0) process.exitCode = 1;
|