fix: complete TechLog migration evidence

This commit is contained in:
DongHyeonka
2026-08-16 04:55:52 +09:00
parent 6c2780b7a7
commit 3a7c5deca0
81 changed files with 1492 additions and 345 deletions
+21 -1
View File
@@ -15,6 +15,7 @@ function ignoredConsole(message: ConsoleMessage) {
export const test = base.extend({
page: async ({ page }, use) => {
const failures: string[] = [];
let mainDocumentStatus: number | null = null;
const onConsole = (message: ConsoleMessage) => {
if (
["error", "warning"].includes(message.type()) &&
@@ -31,14 +32,33 @@ export const test = base.extend({
`requestfailed:${request.method()}:${new URL(request.url()).pathname}:${request.failure()?.errorText ?? "unknown"}`,
);
};
const onResponse = (response: import("@playwright/test").Response) => {
if (
response.request().resourceType() === "document" &&
response.frame() === page.mainFrame()
) {
mainDocumentStatus = response.status();
}
};
page.on("console", onConsole);
page.on("pageerror", onPageError);
page.on("requestfailed", onRequestFailed);
page.on("response", onResponse);
await use(page);
page.off("console", onConsole);
page.off("pageerror", onPageError);
page.off("requestfailed", onRequestFailed);
expect(failures, "unexpected browser console/page errors").toEqual([]);
page.off("response", onResponse);
const expectedDocumentNotFoundConsole =
"console:error:Failed to load resource: the server responded with a status of 404 (Not Found)";
expect(
failures.filter(
(failure) =>
failure !== expectedDocumentNotFoundConsole ||
mainDocumentStatus !== 404,
),
"unexpected browser console/page errors",
).toEqual([]);
},
});
+5 -1
View File
@@ -127,8 +127,12 @@ export async function settleTechLogPage(page: Page) {
export async function gotoTechLog(page: Page, path: string) {
await prepareTechLogPage(page);
await page.goto(path);
const response = await page.goto(path, { waitUntil: "networkidle" });
await page.locator("body").waitFor({ state: "visible" });
if (response?.headers()["content-type"]?.startsWith("text/html")) {
await page.locator("html[data-build-id][data-release-id]").waitFor();
await page.locator(".site-frame, .studio-app").waitFor();
}
await settleTechLogPage(page);
}
@@ -1,7 +1,9 @@
// @ts-nocheck -- standalone evidence runner executed directly with tsx.
// @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 { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { lstat, mkdir, readFile, readdir, readlink, writeFile } from "node:fs/promises";
import { relative, resolve } from "node:path";
import { chromium } from "@playwright/test";
@@ -9,6 +11,7 @@ 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";
@@ -18,9 +21,10 @@ 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:4175";
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 outputPath = process.env.TECH_LOG_PARITY_OUTPUT ?? "artifacts/quality/tech-log-source-parity.json";
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 {
@@ -35,6 +39,11 @@ const noMotionCss = `
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: "/",
@@ -117,45 +126,123 @@ async function interact(page, action) {
}
}
async function settle(page, baseUrl, parityCase) {
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);
await page.goto(new URL(parityCase.path, baseUrl).href, { waitUntil: "networkidle" });
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 projection(page) {
async function productSubtree(page) {
return page.evaluate(() => {
const normalizeReference = (value) => value
.split(" ")
.map((token) => token.replace(/^_[rR].*?_(?=-|$)/, "<generated-id>"))
.join(" ");
const attributes = (element) => Object.fromEntries(
[...element.attributes]
.filter(({ name }) => name === "role" || name.startsWith("aria-"))
.map(({ name, value }) => [name, normalizeReference(value)])
.sort(([left], [right]) => left.localeCompare(right)),
);
return [...document.querySelectorAll("header, main, footer, dialog")].map((element) => ({
tag: element.tagName.toLowerCase(),
className: [...element.classList].map((name) => name.replace(/^_([A-Za-z0-9]+)_[A-Za-z0-9]+_(\d+)$/, "_$1_<generated-module>_$2")).sort().join(" "),
text: element.textContent?.replace(/\s+/g, " ").trim() ?? "",
aria: attributes(element),
descendants: [...element.querySelectorAll("[role], [aria-label], [aria-labelledby], [aria-describedby], [aria-current], [aria-expanded], [aria-controls]")]
.map((child) => ({
tag: child.tagName.toLowerCase(),
className: [...child.classList].map((name) => name.replace(/^_([A-Za-z0-9]+)_[A-Za-z0-9]+_(\d+)$/, "_$1_<generated-module>_$2")).sort().join(" "),
text: child.textContent?.replace(/\s+/g, " ").trim() ?? "",
aria: attributes(child),
})),
}));
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();
@@ -194,6 +281,44 @@ function pixelDifference(sourceBuffer, targetBuffer) {
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",
@@ -203,78 +328,123 @@ const contextOptions = {
serviceWorkers: "block",
timezoneId: "Asia/Seoul",
};
const sourceContext = await browser.newContext(contextOptions);
const targetContext = await browser.newContext(contextOptions);
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 = [];
for (const [page, errors] of [[sourcePage, sourceErrors], [targetPage, targetErrors]]) {
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) => errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText ?? ""}`));
}
await Promise.all([
settle(sourcePage, sourceBaseUrl, parityCase),
settle(targetPage, targetBaseUrl, parityCase),
]);
const [sourceShot, targetShot, sourceDom, targetDom, sourceLayout, targetLayout, sourceFonts, targetFonts] = await Promise.all([
sourcePage.screenshot({ fullPage: true }),
targetPage.screenshot({ fullPage: true }),
projection(sourcePage),
projection(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 domEqual = JSON.stringify(sourceDom) === JSON.stringify(targetDom);
const fontsEqual = JSON.stringify(sourceFonts) === JSON.stringify(targetFonts);
const isPlainNotFound =
parityCase.name.includes("NOT_FOUND") || parityCase.name.includes("UNKNOWN");
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 && domEqual && (!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({ sourceDom, targetDom, sourceLayout, targetLayout }, null, 2)}\n`),
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()]);
}
results.push({ name: parityCase.name, path: parityCase.path, width: parityCase.width, action: parityCase.action ?? null, pixel, domEqual, fontsEqual, fontContractApplies, sourceFonts, targetFonts, sourceErrors, targetErrors, unexplainedSourceErrors, unexplainedTargetErrors, passed });
console.log(`${passed ? "PASS" : "FAIL"} ${parityCase.name} pixels=${pixel.pixels ?? "SIZE"} dom=${domEqual} fonts=${fontContractApplies ? fontsEqual : "source-plain-text"} errors=${unexplainedSourceErrors.length}/${unexplainedTargetErrors.length}`);
await Promise.all([sourcePage.close(), targetPage.close()]);
}
} finally {
await Promise.all([sourceContext.close(), targetContext.close()]);
await browser.close();
}
const evidence = {
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(),
sourceBaseUrl,
targetBaseUrl,
conditions: { ...contextOptions, fixedTime: TECH_LOG_FIXED_TIME, viewportHeight: 1000, screenshots: "fullPage", masks: 0 },
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 }));