// 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 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); if (UPDATE || !fs.existsSync(baseFile)) { fs.writeFileSync(baseFile, PNG.sync.write(cur)); rows.push({ name, status: UPDATE ? "갱신" : "신규기준", 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) + "%" }); } 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);