// @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].*?_(?=-|$)/, "")) .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__$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__$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;