test: prove TechLog UI migration parity
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// @ts-nocheck -- standalone evidence runner executed directly with tsx.
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
import {
|
||||
TECH_LOG_BREAKPOINT_WIDTHS,
|
||||
TECH_LOG_CANONICAL_ROUTES,
|
||||
TECH_LOG_FIXED_TIME,
|
||||
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:4175";
|
||||
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 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_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) {
|
||||
await page.setViewportSize({ width: parityCase.width, height: 1000 });
|
||||
await page.clock.setFixedTime(fixedDate);
|
||||
await page.goto(new URL(parityCase.path, baseUrl).href, { waitUntil: "networkidle" });
|
||||
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);
|
||||
}
|
||||
|
||||
async function projection(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),
|
||||
})),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
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] };
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const contextOptions = {
|
||||
colorScheme: "light",
|
||||
deviceScaleFactor: 1,
|
||||
locale: "ko-KR",
|
||||
reducedMotion: "reduce",
|
||||
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 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`),
|
||||
]);
|
||||
}
|
||||
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 = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceBaseUrl,
|
||||
targetBaseUrl,
|
||||
conditions: { ...contextOptions, fixedTime: TECH_LOG_FIXED_TIME, viewportHeight: 1000, screenshots: "fullPage", masks: 0 },
|
||||
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,
|
||||
};
|
||||
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;
|
||||
Reference in New Issue
Block a user