designpaca/apps/site/tools/visual.mjs
Yun Chan e09d4efb1c
Some checks failed
ci / build (push) Has been cancelled
feat: strengthen design skill and refresh all showcases
2026-09-12 18:16:33 +09:00

95 lines
3.9 KiB
JavaScript

// L3 — 시각 회귀. 기준 화면과 pixelmatch 비교.
// 사용: node tools/visual.mjs [--update-baseline]
// 규칙: diff 픽셀 비율 ≥ 0.1% → 실패(안티앨리어싱 잡음은 무시).
// 기준 갱신은 검증된 배포 후에만 --update-baseline 으로.
import puppeteer from "puppeteer-core";
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";
import path from "node:path";
import url from "node:url";
import fs from "node:fs";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, "../public/work");
const SHOTS = path.join(here, "__shots__");
const BASE = path.join(SHOTS, "baseline");
const CURR = path.join(SHOTS, "current");
const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
const UPDATE = process.argv.includes("--update-baseline");
const scopedUpdates = new Set(
process.argv
.filter((arg) => arg.startsWith("--update-baseline="))
.flatMap((arg) => arg.slice("--update-baseline=".length).split(","))
.map((name) => name.trim())
.filter(Boolean),
);
const APPS = {
"gaon-lms": ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"],
"dure-enrollment": ["catalog", "starred", "enrollments", "credits", "archive", "notices"],
};
const WIDTHS = [390, 1440];
fs.mkdirSync(BASE, { recursive: true });
fs.mkdirSync(CURR, { recursive: true });
const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
const page = await browser.newPage();
await page.setCacheEnabled(false);
const shots = [];
for (const [app, views] of Object.entries(APPS)) {
for (const w of WIDTHS) {
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await new Promise((r) => setTimeout(r, 250));
for (const v of views) {
await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v);
await new Promise((r) => setTimeout(r, 100));
const name = `${app}-${v}-${w}.png`;
const file = path.join(CURR, name);
await page.screenshot({ path: file });
shots.push(name);
}
}
}
await browser.close();
let fails = 0;
const rows = [];
for (const name of shots) {
const cur = PNG.sync.read(fs.readFileSync(path.join(CURR, name)));
const baseFile = path.join(BASE, name);
const updateThisBaseline = UPDATE || scopedUpdates.has(name);
if (updateThisBaseline || !fs.existsSync(baseFile)) {
fs.writeFileSync(baseFile, PNG.sync.write(cur));
rows.push({ name, status: updateThisBaseline ? "갱신" : "신규기준", diff: "—" });
continue;
}
const base = PNG.sync.read(fs.readFileSync(baseFile));
if (base.width !== cur.width || base.height !== cur.height) {
rows.push({ name, status: "FAIL", diff: "크기 불일치" });
fails++;
continue;
}
const diff = new PNG({ width: base.width, height: base.height });
const n = pixelmatch(base.data, cur.data, diff.data, base.width, base.height, { threshold: 0.1 });
const ratio = (n / (base.width * base.height)) * 100;
const bad = ratio >= 0.1;
if (bad) {
fails++;
fs.writeFileSync(path.join(SHOTS, `diff-${name}`), PNG.sync.write(diff));
}
rows.push({ name, status: bad ? "FAIL" : "PASS", diff: ratio.toFixed(3) + "%" });
}
const unknownScopedUpdates = [...scopedUpdates].filter((name) => !shots.includes(name));
if (unknownScopedUpdates.length) {
console.error(`Unknown baseline name(s): ${unknownScopedUpdates.join(", ")}`);
process.exit(2);
}
for (const r of rows) console.log(`${r.status.padEnd(4)} ${r.name} ${r.diff}`);
console.log(`\nVISUAL: ${fails ? `${fails} FAIL (기준 대비 회귀)` : UPDATE ? "기준 갱신 완료" : "ALL MATCH"}`);
process.exit(fails ? 1 : 0);