import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; import pixelmatch from "pixelmatch"; import { PNG } from "pngjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); function arg(name, fallback = "") { const index = process.argv.indexOf(`--${name}`); return index >= 0 ? process.argv[index + 1] : fallback; } function boolArg(name) { return process.argv.includes(`--${name}`); } function parseViewport(value) { const [width, height] = value.split("x").map((item) => Number.parseInt(item, 10)); if (!width || !height) { throw new Error(`Invalid --viewport "${value}". Expected WIDTHxHEIGHT, for example 1440x900.`); } return { width, height }; } function ensureUrl(value) { if (!value) throw new Error("Missing required URL argument."); if (value.startsWith("file://") || value.startsWith("http://") || value.startsWith("https://")) return value; return `file://${path.resolve(repoRoot, value)}`; } function readPng(file) { return PNG.sync.read(fs.readFileSync(file)); } async function preparePage(page, url, shouldClearStorage, token) { // 先到同源根页注入 token,再进目标路由 —— 否则目标路由在「无 token 首跳」时 // 会跑一遍登出/重定向 boot,某些路由(如 /model-photo)即便随后 reload 也回不来, // 导致截图停在登录页(假性 ~27% diff)。 if (token) { await page.goto(new URL(url).origin + "/", { waitUntil: "domcontentloaded" }); await page.evaluate((value) => localStorage.setItem("airshelf_token", value), token); } await page.goto(url, { waitUntil: "networkidle" }); if (shouldClearStorage) { await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); await page.goto(url, { waitUntil: "networkidle" }); } // 注入登录 token,让受保护的真 React 路由 + /exact 镜像都能 hydrate 同一份真实数据 if (token) { await page.evaluate((value) => localStorage.setItem("airshelf_token", value), token); await page.goto(url, { waitUntil: "networkidle" }); await page.waitForTimeout(900); // 等异步数据 hydrate 落定 } await page.evaluate(async () => { if ("fonts" in document) await document.fonts.ready; }); await page.addStyleTag({ content: ` *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; caret-color: transparent !important; } ` }); } const source = ensureUrl(arg("source")); const target = ensureUrl(arg("target")); const name = arg("name", "page"); const viewport = parseViewport(arg("viewport", "1440x900")); const outDir = path.resolve(repoRoot, arg("out", "core/qa/visual-parity/output")); const threshold = Number.parseFloat(arg("threshold", "0.03")); const clearTargetStorage = boolArg("clear-target-storage"); const token = arg("token", ""); fs.mkdirSync(outDir, { recursive: true }); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme: "light", reducedMotion: "reduce" }); const sourcePage = await context.newPage(); const targetPage = await context.newPage(); const sourcePath = path.join(outDir, `${name}.design.png`); const targetPath = path.join(outDir, `${name}.implementation.png`); const diffPath = path.join(outDir, `${name}.diff.png`); const reportPath = path.join(outDir, `${name}.report.json`); await preparePage(sourcePage, source, false, token); await preparePage(targetPage, target, clearTargetStorage, token); await sourcePage.screenshot({ path: sourcePath, fullPage: false }); await targetPage.screenshot({ path: targetPath, fullPage: false }); await browser.close(); const sourcePng = readPng(sourcePath); const targetPng = readPng(targetPath); if (sourcePng.width !== targetPng.width || sourcePng.height !== targetPng.height) { throw new Error(`Screenshot size mismatch: design ${sourcePng.width}x${sourcePng.height}, implementation ${targetPng.width}x${targetPng.height}`); } const diff = new PNG({ width: sourcePng.width, height: sourcePng.height }); const diffPixels = pixelmatch(sourcePng.data, targetPng.data, diff.data, sourcePng.width, sourcePng.height, { threshold, includeAA: false }); fs.writeFileSync(diffPath, PNG.sync.write(diff)); const totalPixels = sourcePng.width * sourcePng.height; const report = { name, source, target, viewport, threshold, diffPixels, totalPixels, diffRatio: Number((diffPixels / totalPixels).toFixed(6)), pass: diffPixels === 0, artifacts: { design: path.relative(repoRoot, sourcePath), implementation: path.relative(repoRoot, targetPath), diff: path.relative(repoRoot, diffPath) } }; fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify(report, null, 2));