All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
- core/frontend: Vite 多阶段镜像 + nginx 同源反代 /api,/admin,/static(零 CORS) - core/backend: Django gunicorn 镜像 + entrypoint(自动 migrate/collectstatic)+ WhiteNoise - k8s/core: api/worker/web Deployment+Service + ingress(airshelf-web.airlabs.art) - workflow: 追加 core 前后端 build/push,从 core/backend/.env 套生产覆盖生成 env Secret 后部署 - .gitignore 放行 core/backend/.env;.env 白名单加入 airshelf-web 域名 - 含前端 WIP 还原改动 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
4.4 KiB
JavaScript
135 lines
4.4 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) {
|
|
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));
|