AirShelf/core/qa/visual-parity/compare-page.mjs
zyc 3fac38c5ef
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)
消息中心:全量渲染 → 真·后端分页滚动加载
- backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+
  响应回 type_counts(按收件人绝对计数,不受分页/搜索影响)
- frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页;
  代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数
- api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts

命令面板(侧边栏搜索):修复点开后 UI 错位
- app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下;
  补回基类 + header 类名对齐 .shell-command-h
- messages-page.css: 工作台收进视口高度,收件箱在面板内滚动

本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings +
accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:37:41 +08:00

142 lines
4.9 KiB
JavaScript

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));