- route table: extension route for products with an existing visual language, feedback row, derived state tokens stay local - completion: blocking 0 plus reported unverified; hard-gate surface is the touched scope; delivery report starts with what and how to open - design.md created at step 0 with a skeleton, fingerprint line and change log; product-surface rubric reading; direction delegation states the chosen card and reason - layout: hero families, nav forms, footer forms; art-direction page convergence 6 axes; genre sections; image treatments; placeholder conventions; multi-screen order; Tailwind theme mapping - platform: scroll-driven examples, anchor positioning, canvas OT, three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid - design-gate: four result states, exit codes, cwd module resolution, --url/--out-dir/--strict, oklch contrast, dead selector word match - Korean font budget split from the Latin baseline
1247 lines
74 KiB
JavaScript
1247 lines
74 KiB
JavaScript
#!/usr/bin/env node
|
||
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
|
||
//
|
||
// 조사 기반 7계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션 + 죽은 선택자·토큰 위생) + L2(불변식·SEO/meta·theme-color 정합·터치타깃·런타임 오류) + L3(시각회귀·옵션) + L4(WebKit·옵션).
|
||
// 프로젝트 고유의 L1(단위)·L5(탐색)·L6(시나리오 E2E)는 각 프로젝트의 calc/테스트로 보강한다.
|
||
//
|
||
// 사용법·옵션·설정 키·종료 코드는 `node design-gate.mjs --help` 가 정본이다(아래 HELP).
|
||
//
|
||
// 철학: 결과는 넷이다 — PASS(쟀고 충족) / FAIL(쟀고 미달) / 미검증(도구·입력이 없어 못 쟀다) / OFF(설정으로 껐다).
|
||
// 미검증은 통과가 아니다. 예전에는 못 잰 검사를 "PASS … SKIP"으로 찍고 "전 항목 통과"라고 썼다(실측:
|
||
// axe-core 가 없는 프로젝트의 접근성 검사가 통과로 보고됐다). 지금은 따로 세고, 하나라도 있으면
|
||
// 리포트 머리말이 "검증 미완료"다.
|
||
import { spawnSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import url from "node:url";
|
||
|
||
const HELP = `design-gate — 디자인 QA 게이트
|
||
|
||
사용법
|
||
node design-gate.mjs [옵션] cwd 의 gate.config.json 으로 실행
|
||
node design-gate.mjs --url <주소> [--url …] 설정 파일 없이 떠 있는 주소를 검사(읽기 전용)
|
||
node design-gate.mjs --init [--force] 설정 초안 생성(있으면 덮어쓰지 않는다)
|
||
|
||
옵션 (값은 "--키 값" 과 "--키=값" 둘 다 받는다)
|
||
--config <파일> 설정 파일(기본 gate.config.json). --init 과 같이 쓰면 그 경로에 초안을 쓴다
|
||
--url <주소> 검사할 주소. 여러 번 줄 수 있다. --config 없이 쓰면 설정 파일을 읽지 않고 기본값으로 돈다.
|
||
--out-dir 이 없으면 리포트도 쓰지 않는다 — 프로젝트 디렉터리에 아무것도 남기지 않는다
|
||
--out-dir <경로> 리포트(gate-report.md)와 시각 기준 화면(__gate__/baseline)을 쓰는 위치(기본 cwd)
|
||
--no-report 리포트 파일을 쓰지 않는다(결과는 표준 출력으로만)
|
||
--strict 미검증이 하나라도 있으면 exit 3
|
||
--static-only 브라우저를 띄우지 않고 L0 정적 검사만 돈다(브라우저 계층은 미검증으로 보고)
|
||
--update-baseline 시각 기준 화면 갱신(검증된 배포 후에만)
|
||
--force --init 이 기존 설정을 덮어쓰게 한다
|
||
--help, -h 이 도움말
|
||
|
||
결과 상태
|
||
PASS 쟀고 충족 FAIL 쟀고 미달
|
||
미검증 도구·입력이 없어 못 쟀다(통과로 세지 않는다)
|
||
OFF 설정으로 끈 검사(집계에서 뺀다)
|
||
|
||
종료 코드
|
||
0 실패 0건(미검증이 있으면 머리말이 "검증 미완료"다)
|
||
1 실패 1건 이상
|
||
2 사용법·설정 오류(모르는 인자, 설정 파일 없음·형식 오류, --init 덮어쓰기 거부)
|
||
3 --strict 인데 미검증이 있다(실패는 0건)
|
||
|
||
선택 도구 — 실행한 디렉터리(cwd)의 node_modules → 이 스크립트 위치 순으로 찾는다
|
||
puppeteer-core 브라우저 계층 전체. 없으면 브라우저 계층이 미검증이다
|
||
axe-core checks.axe. 없으면 axe 미검증
|
||
pixelmatch·pngjs checks.visual playwright checks.webkit
|
||
stylelint·html-validate checks.static(cwd 쪽만 찾는다)
|
||
브라우저 — CHROME_PATH → 설정 chromePath → Chrome·Chromium·Edge 기본 설치 경로 → PATH 순으로 찾는다
|
||
|
||
설정 키 (gate.config.json)
|
||
pages[] name, path(로컬 HTML) 또는 url(떠 있는 주소) — 둘 중 하나는 필수, 같이 줄 수도 있다
|
||
h1 제목 선택자(기본 "h1") h1MaxLines 줄 수 계약(양의 정수일 때만 검사)
|
||
brand 브랜드 선택자(있을 때만 줄바꿈 검사)
|
||
viewAttribute 뷰 전환 버튼의 속성 이름(예: data-view)
|
||
views 뷰 이름 목록. 버튼 [viewAttribute="이름"] 을 눌러 전환한다. 버튼이 없으면 실패다
|
||
staticContract selectorFiles·selectorDocuments·hygieneFiles·tokenFiles·budgets{colorLiterals,radiusLiterals}
|
||
widths 가로 오버플로를 재는 폭 목록(기본 320·390·768·1440·1920·2560)
|
||
fontScales 글꼴 확대 배율(기본 1·1.3, 375·390 폭에서 잰다)
|
||
checks meta contrast stack wrap rhythm scaleMatrix tapTargets leftInset runtime
|
||
deadCss cssHygiene (기본 켜짐) · static visual webkit (기본 꺼짐) · axe("auto"|false)
|
||
thresholds contrastNormal 4.5 · contrastLarge 3 · stackLines 3 · radiusLiterals 3
|
||
tapTargetMin 44 · inlineTargetMin 24 · leftInsetMin 12 · visualDiffPct 0.1
|
||
tapTargetPolicy "contract"(44/24px 미달도 실패, 기본) | "wcag"(WCAG 2.5.8 만 실패)
|
||
tapTargetsInline 인라인 링크로 볼 선택자 목록 deadCssIgnore 동적 클래스 접두사 목록
|
||
load { waitUntil: "networkidle0", timeoutMs: 30000 }
|
||
chromePath 브라우저 실행 파일 chromeArgs 추가 실행 인자 목록(예: ["--no-sandbox"])
|
||
strict true 면 --strict 와 같다
|
||
`;
|
||
|
||
// ── 인자 ──
|
||
const usageError = (message) => {
|
||
console.error(`design-gate: ${message}\n사용법은 --help 로 확인한다.`);
|
||
process.exit(2);
|
||
};
|
||
const FLAG_NAMES = new Set(["--init", "--force", "--strict", "--static-only", "--no-report", "--update-baseline", "--help", "-h"]);
|
||
const VALUE_NAMES = new Set(["--config", "--url", "--out-dir"]);
|
||
const opt = { flags: new Set(), config: null, urls: [], outDir: null };
|
||
{
|
||
const argv = process.argv.slice(2);
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const arg = argv[i];
|
||
if (FLAG_NAMES.has(arg)) { opt.flags.add(arg); continue; }
|
||
const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
||
const key = eq > 0 ? arg.slice(0, eq) : arg;
|
||
// 모르는 인자를 조용히 넘기면 "--config other.json"(공백형)이 무시되고 기본 설정이 돈다(실측)
|
||
if (!VALUE_NAMES.has(key)) usageError(`모르는 인자: ${arg}`);
|
||
const value = eq > 0 ? arg.slice(eq + 1) : argv[++i];
|
||
if (value === undefined || value === "" || (eq < 0 && value.startsWith("--"))) usageError(`${key} 에 값이 필요하다`);
|
||
if (key === "--config") opt.config = value;
|
||
else if (key === "--out-dir") opt.outDir = value;
|
||
else opt.urls.push(value);
|
||
}
|
||
}
|
||
if (opt.flags.has("--help") || opt.flags.has("-h")) {
|
||
console.log(HELP);
|
||
process.exit(0);
|
||
}
|
||
|
||
const CWD = process.cwd();
|
||
const SCRIPT_DIR = path.dirname(url.fileURLToPath(import.meta.url));
|
||
const cfgFile = path.resolve(CWD, opt.config || "gate.config.json");
|
||
|
||
const DEFAULT_CFG = {
|
||
// 뷰 전환(viewAttribute·views)과 brand 는 기본값에 넣지 않는다. 그런 버튼·요소가 없는 일반 페이지에서
|
||
// 없는 것을 가정하면 같은 화면을 여러 번 재고 "뷰 n개 검증"이라고 보고하게 된다(실측).
|
||
pages: [{ name: "app", path: "index.html", h1: "h1" }],
|
||
// preflight W1 의 기본 매트릭스와 같은 목록이다. 한쪽만 고치지 않는다.
|
||
widths: [320, 390, 768, 1440, 1920, 2560],
|
||
fontScales: [1, 1.3],
|
||
thresholds: { contrastNormal: 4.5, contrastLarge: 3, visualDiffPct: 0.1, stackLines: 3, radiusLiterals: 3, tapTargetMin: 44, inlineTargetMin: 24, leftInsetMin: 12 },
|
||
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, tapTargets: true, leftInset: true, runtime: true, static: false, visual: false, webkit: false, deadCss: true, cssHygiene: true, axe: "auto" },
|
||
// "contract"(기본 — 컨트롤 <44px·인라인 <24px 실패) | "wcag"(WCAG 2.5.8 간격 예외 실패만 하드 게이트로 본다, 계약 미달은 참고로만 남긴다)
|
||
tapTargetPolicy: "contract",
|
||
deadCssIgnore: ["is-"], // 동적으로 조립되는 클래스 접두사(is-${type} 등). 오탐 방지 — 프로젝트마다 추가한다
|
||
load: { waitUntil: "networkidle0", timeoutMs: 30000 },
|
||
chromePath: null,
|
||
};
|
||
|
||
if (opt.flags.has("--init")) {
|
||
// 예전에는 묻지 않고 덮어써서 두 번째 실행에서 뷰 목록·staticContract·예산이 사라졌다(실측)
|
||
if (fs.existsSync(cfgFile) && !opt.flags.has("--force")) {
|
||
console.error(`design-gate: ${path.relative(CWD, cfgFile) || cfgFile} 이 이미 있다 — 덮어쓰지 않았다. 새 초안으로 바꾸려면 --init --force 를 쓴다.`);
|
||
process.exit(2);
|
||
}
|
||
fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
|
||
fs.writeFileSync(cfgFile, JSON.stringify(DEFAULT_CFG, null, 2) + "\n");
|
||
console.log(`${path.relative(CWD, cfgFile) || cfgFile} 초안 생성 — pages 를 프로젝트에 맞게 고친다.`);
|
||
console.log("빌드 결과를 검사하려면 pages[].url 에 떠 있는 주소를, 뷰 전환이 있으면 viewAttribute·views 를, 브랜드 줄바꿈을 보려면 brand 선택자를 적는다.");
|
||
console.log("설정 키·옵션·종료 코드 전체는 --help 에 있다.");
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── 설정 ──
|
||
const URL_MODE = opt.urls.length > 0;
|
||
let _user = {};
|
||
if (opt.config || !URL_MODE) {
|
||
if (!fs.existsSync(cfgFile)) {
|
||
console.error(`design-gate: 설정 파일을 찾지 못했다 — ${cfgFile}\n--init 으로 초안을 만들거나, --config <파일> 또는 --url <주소> 를 준다.`);
|
||
process.exit(2);
|
||
}
|
||
try { _user = JSON.parse(fs.readFileSync(cfgFile, "utf8")); }
|
||
catch (e) {
|
||
console.error(`design-gate: 설정 파일을 읽지 못했다 — ${cfgFile}: ${e.message}`);
|
||
process.exit(2);
|
||
}
|
||
if (!_user || typeof _user !== "object" || Array.isArray(_user)) {
|
||
console.error(`design-gate: 설정 파일은 JSON 객체여야 한다 — ${cfgFile}`);
|
||
process.exit(2);
|
||
}
|
||
}
|
||
// checks·thresholds 는 깊은 병합 — 새 검사·임계값이 기본 켜지도록(실측: 프로젝트 구형 설정이 새 기본값을 지우고 "≥undefinedpx"를 찍었다)
|
||
const CFG = {
|
||
...DEFAULT_CFG,
|
||
..._user,
|
||
checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) },
|
||
thresholds: { ...DEFAULT_CFG.thresholds, ...(_user.thresholds || {}) },
|
||
load: { ...DEFAULT_CFG.load, ...(_user.load || {}) },
|
||
};
|
||
if (URL_MODE) CFG.pages = opt.urls.map((u, i) => ({ name: `url${i + 1}`, url: u, h1: "h1" }));
|
||
if (!Array.isArray(CFG.pages) || !CFG.pages.length) {
|
||
console.error("gate.config 오류: pages 가 비어 있다.");
|
||
process.exit(2);
|
||
}
|
||
CFG.pages = CFG.pages.map((p, i) => ({ ...p, name: p && p.name ? String(p.name) : `page${i + 1}` }));
|
||
// path·url 둘 다 없는 페이지는 렌더도 파일 검사도 대상이 없다 — 실행 전에 명확한 설정 오류로 끊는다
|
||
for (const p of CFG.pages) {
|
||
if (!p.path && !p.url) {
|
||
console.error(`gate.config 오류: pages 항목(name=${p.name || "?"}) 에 path 또는 url 이 필요합니다.`);
|
||
process.exit(2);
|
||
}
|
||
}
|
||
if (!Array.isArray(CFG.widths) || !CFG.widths.length || CFG.widths.some((w) => !Number.isFinite(w) || w <= 0)) {
|
||
console.error("gate.config 오류: widths 는 양수 폭의 목록이어야 한다.");
|
||
process.exit(2);
|
||
}
|
||
|
||
const UPDATE = opt.flags.has("--update-baseline");
|
||
const STRICT = opt.flags.has("--strict") || CFG.strict === true;
|
||
const STATIC_ONLY = opt.flags.has("--static-only");
|
||
const OUT_DIR = opt.outDir ? path.resolve(CWD, opt.outDir) : CWD;
|
||
// --url 만 준 실행은 리뷰 경로의 읽기 전용 실행이다. 쓸 곳(--out-dir)을 받지 않았으면 리포트도 쓰지 않는다.
|
||
const WRITE_REPORT = !opt.flags.has("--no-report") && !(URL_MODE && !opt.outDir);
|
||
|
||
const results = [];
|
||
const record = (state) => (name, detail = "") => results.push({ state, name, detail });
|
||
const pass = record("pass");
|
||
const fail = record("fail");
|
||
const unverified = record("unverified"); // 도구·입력이 없어 못 쟀다. 통과로 세지 않는다
|
||
const off = record("off"); // 설정으로 껐다. 집계에서 뺀다
|
||
const errText = (e) => String((e && e.message) || e).split("\n")[0].slice(0, 200);
|
||
if (URL_MODE) for (const p of CFG.pages) console.log(`${p.name} = ${p.url.length > 100 ? p.url.slice(0, 100) + "…" : p.url}`);
|
||
|
||
// ── 선택 도구 해석 ──
|
||
// ESM 의 맨 이름 import 는 "이 스크립트 파일 위치"에서 node_modules 를 찾는다. 스킬이 사용자 홈에
|
||
// 설치돼 있으면 프로젝트에 `puppeteer-core` 를 설치해도 게이트가 찾지 못했다(실측). 모든 선택 도구를
|
||
// 실행한 디렉터리(cwd) → 스크립트 위치 순으로 직접 찾아서 불러온다.
|
||
const packageDirFrom = (start, name) => {
|
||
for (let dir = start; ; dir = path.dirname(dir)) {
|
||
const candidate = path.join(dir, "node_modules", name);
|
||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||
if (path.dirname(dir) === dir) return null;
|
||
}
|
||
};
|
||
const pickExport = (entry) => {
|
||
if (typeof entry === "string") return entry;
|
||
if (Array.isArray(entry)) {
|
||
for (const item of entry) { const found = pickExport(item); if (found) return found; }
|
||
return null;
|
||
}
|
||
if (entry && typeof entry === "object") {
|
||
for (const key of ["import", "node", "default", "require"]) {
|
||
if (key in entry) { const found = pickExport(entry[key]); if (found) return found; }
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const packageEntry = (dir) => {
|
||
const json = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
||
const root = json.exports && typeof json.exports === "object" && !Array.isArray(json.exports) && "." in json.exports ? json.exports["."] : json.exports;
|
||
return path.join(dir, pickExport(root) || json.module || json.main || "index.js");
|
||
};
|
||
const optionalDirs = (name) => [...new Set([packageDirFrom(CWD, name), packageDirFrom(SCRIPT_DIR, name)].filter(Boolean))];
|
||
const loadOptional = async (name) => {
|
||
for (const dir of optionalDirs(name)) {
|
||
try { return await import(url.pathToFileURL(packageEntry(dir)).href); } catch { /* 다음 후보 */ }
|
||
}
|
||
return null;
|
||
};
|
||
const readOptionalSource = (name) => {
|
||
for (const dir of optionalDirs(name)) {
|
||
try { return fs.readFileSync(packageEntry(dir), "utf8"); } catch { /* 다음 후보 */ }
|
||
}
|
||
return null;
|
||
};
|
||
// CJS 를 import() 하면 내보낸 값이 default 아래에 한 겹 더 들어갈 수 있다
|
||
const exportOf = (mod, test) => [mod, mod && mod.default, mod && mod.default && mod.default.default].find((m) => m && test(m)) || null;
|
||
|
||
// ── 브라우저 확보 (puppeteer-core + 실행 파일 자동 탐색) ──
|
||
const LOCAL_APP_DATA = process.env.LOCALAPPDATA ? process.env.LOCALAPPDATA.replace(/\\/g, "/") : null;
|
||
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
||
const fromPath = (names) => {
|
||
const out = [];
|
||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||
if (dir) for (const name of names) out.push(path.join(dir, name));
|
||
}
|
||
return out;
|
||
};
|
||
const CHROME_CANDIDATES = [
|
||
process.env.CHROME_PATH,
|
||
CFG.chromePath,
|
||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||
LOCAL_APP_DATA && `${LOCAL_APP_DATA}/Google/Chrome/Application/chrome.exe`,
|
||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||
HOME && `${HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||
"/usr/bin/google-chrome",
|
||
"/usr/bin/google-chrome-stable",
|
||
"/usr/bin/chromium",
|
||
"/usr/bin/chromium-browser",
|
||
"/snap/bin/chromium",
|
||
// Edge 는 같은 Chromium 엔진이다. Windows 에는 기본으로 있어 Chrome 이 없는 기기에서도 게이트가 뜬다.
|
||
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
||
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||
"/usr/bin/microsoft-edge",
|
||
...fromPath(process.platform === "win32" ? ["chrome.exe", "msedge.exe"] : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]),
|
||
].filter((p) => typeof p === "string" && p.length > 0);
|
||
const CHROME = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
|
||
// 설정 파일(gate.config.json)이 실행 파일을 지정할 수 있으므로 무엇을 실행하는지 항상 드러낸다
|
||
if (CHROME && CHROME === CFG.chromePath && CHROME !== process.env.CHROME_PATH) {
|
||
console.log(`브라우저 실행 파일(gate.config chromePath): ${CHROME}`);
|
||
}
|
||
|
||
const pageUrl = (p) => (p.url ? p.url : url.pathToFileURL(path.resolve(CWD, p.path)).href);
|
||
const hasPath = (p) => typeof p.path === "string" && p.path.length > 0;
|
||
const viewsOf = (p) => (Array.isArray(p.views) && p.views.length ? p.views.map(String) : ["single"]);
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
// ── L0 정적 (옵션) ──
|
||
// 프로젝트에 설치된 CLI 를 셸 없이 실행한다. 예전 `spawnSync("npx", …, { shell: true })` 는
|
||
// gate.config 의 path 값이 셸 문자열로 이어 붙어 명령 주입이 됐고(`"index.html & calc"`),
|
||
// Windows 에서는 cwd 의 npx.cmd 가 PATH 보다 먼저 잡혔다. 없는 도구는 받지 않고 미검증으로 남긴다.
|
||
const findLocalBin = (pkg) => {
|
||
for (let dir = CWD; ; dir = path.dirname(dir)) {
|
||
const meta = path.join(dir, "node_modules", pkg, "package.json");
|
||
if (fs.existsSync(meta)) {
|
||
try {
|
||
const json = JSON.parse(fs.readFileSync(meta, "utf8"));
|
||
const rel = typeof json.bin === "string" ? json.bin : json.bin && json.bin[pkg];
|
||
return rel ? path.join(path.dirname(meta), rel) : null;
|
||
} catch { return null; }
|
||
}
|
||
if (path.dirname(dir) === dir) return null;
|
||
}
|
||
};
|
||
const runLocalBin = (pkg, args) => {
|
||
const bin = findLocalBin(pkg);
|
||
return bin ? spawnSync(process.execPath, [bin, ...args], { encoding: "utf8" }) : null;
|
||
};
|
||
// 설정의 path 는 cwd 아래 실제 파일만 인자로 넘긴다(옵션으로 해석되지 않게 ./ 를 붙인다)
|
||
const safePagePath = (p) => {
|
||
const abs = path.resolve(CWD, p.path);
|
||
const rel = path.relative(CWD, abs);
|
||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || !fs.existsSync(abs)) return null;
|
||
return "./" + rel.split(path.sep).join("/");
|
||
};
|
||
if (CFG.checks.static) {
|
||
const css = runLocalBin("stylelint", ["**/*.css"]);
|
||
if (!css) unverified("L0 stylelint", "미설치 — 프로젝트에 stylelint 가 없다");
|
||
else css.status === 0 ? pass("L0 stylelint") : fail("L0 stylelint", (css.stdout || "").slice(0, 200));
|
||
const staticPages = CFG.pages.filter(hasPath);
|
||
const files = staticPages.map(safePagePath);
|
||
if (files.some((f) => f === null)) {
|
||
fail("L0 html-validate", "pages[].path 는 cwd 아래 실제 파일이어야 한다: " + staticPages.filter((_, i) => files[i] === null).map((p) => p.path).join(", "));
|
||
} else if (staticPages.length) {
|
||
const html = runLocalBin("html-validate", files);
|
||
if (!html) unverified("L0 html-validate", "미설치 — 프로젝트에 html-validate 가 없다");
|
||
else html.status === 0 ? pass("L0 html-validate") : fail("L0 html-validate", (html.stdout || "").slice(0, 200));
|
||
} else unverified("L0 html-validate", "path 없음(url 전용 페이지)");
|
||
} else off("L0 정적", "checks.static=false");
|
||
|
||
// ── L0 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
|
||
// 컴포넌트 제거 시 CSS 만 남아 12종의 미사용 선택자가 쌓였고, 토큰 밖 상수색 8건이 눈 검사를 통과했다) ──
|
||
const pageDir = (p) => path.dirname(path.resolve(CWD, p.path));
|
||
const cssFilesOf = (p) => {
|
||
const out = [];
|
||
if (!hasPath(p)) return out;
|
||
let html = "";
|
||
try { html = fs.readFileSync(path.resolve(CWD, p.path), "utf8"); } catch { return out; }
|
||
for (const tag of html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/g)) {
|
||
const href = /href=["']([^"']+)["']/.exec(tag[0])?.[1];
|
||
if (href && !/^https?:/.test(href)) {
|
||
const f = path.resolve(pageDir(p), href.split("?")[0]);
|
||
if (fs.existsSync(f)) out.push(f);
|
||
}
|
||
}
|
||
// @import 로 딸려오는 로컬 파일(토큰 등)까지 따라간다
|
||
for (const f of [...out]) {
|
||
try {
|
||
for (const im of fs.readFileSync(f, "utf8").matchAll(/@import\s+(?:url\()?["']?([^"')]+)["']?\)?/g)) {
|
||
if (!/^https?:/.test(im[1])) {
|
||
const g = path.resolve(path.dirname(f), im[1]);
|
||
if (fs.existsSync(g) && !out.includes(g)) out.push(g);
|
||
}
|
||
}
|
||
} catch { /* 읽기 실패는 아래 검사가 각자 보고 */ }
|
||
}
|
||
return out;
|
||
};
|
||
|
||
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
|
||
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
|
||
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
|
||
const contractFilesOf = (p, key, fallback) => {
|
||
const specs = p.staticContract?.[key];
|
||
if (!Array.isArray(specs)) return fallback;
|
||
return specs.map((file) => path.resolve(CWD, file));
|
||
};
|
||
|
||
const scriptFilesOf = (p) => {
|
||
const out = [];
|
||
if (!hasPath(p)) return out;
|
||
let html = "";
|
||
try { html = fs.readFileSync(path.resolve(CWD, p.path), "utf8"); } catch { return out; }
|
||
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
|
||
const src = tag[1];
|
||
if (/^(?:https?:|data:)/.test(src)) continue;
|
||
const file = path.resolve(pageDir(p), src.split("?")[0]);
|
||
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const selectorDocumentsOf = (p) => contractFilesOf(
|
||
p,
|
||
"selectorDocuments",
|
||
hasPath(p) ? [path.resolve(CWD, p.path), ...scriptFilesOf(p)] : [],
|
||
);
|
||
|
||
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
|
||
|
||
// CSS 원문에서 주석·문자열·url() 내용을 지운다. 지우지 않고 전체 텍스트에 정규식을 걸면
|
||
// `url(fonts/Brand.woff2)` 의 `.woff2` 가 클래스로, `content:"a.zip"` 의 `.zip` 이 클래스로 잡힌다(실측).
|
||
const readCss = (file) => fs.readFileSync(file, "utf8")
|
||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||
.replace(/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g, '""')
|
||
.replace(/url\(\s*[^)]*\)/gi, "url()");
|
||
// 선택자(`{` 앞 프렐류드)만 뽑는다. 중첩 규칙이면 프렐류드 앞에 선언이 붙어 오므로 마지막 `;` 뒤만 쓴다.
|
||
const cssPreludes = (css) => [...css.matchAll(/([^{}]*)\{/g)]
|
||
.map((m) => m[1].slice(m[1].lastIndexOf(";") + 1).trim())
|
||
.filter((prelude) => prelude && !prelude.startsWith("@"));
|
||
// 선언값만 뽑는다. `a:hover{` 의 `hover` 는 뒤가 `{` 라 값이 아니고, 마지막 선언은 `;` 없이 `}` 로 끝날 수 있다.
|
||
const cssValues = (css) => [...css.matchAll(/:\s*([^;{}]+)(?=[;}])/g)].map((m) => m[1]);
|
||
const COLOR_LITERAL = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9A-Za-z_-])|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color)\([^)]*\)/g;
|
||
// 0·50%·키워드·알약(큰 고정값)은 "토큰으로 옮길 반지름 값"이 아니다
|
||
const RADIUS_NEUTRAL = /^(?:0(?:px|rem|em|%)?|50%|100%|inherit|initial|unset|revert|revert-layer|9{3,}(?:px|rem|em)?|100v(?:w|h|max|min)|calc\(infinity\s*\*\s*1px\))$/i;
|
||
|
||
if (CFG.checks.deadCss) {
|
||
const ignore = CFG.deadCssIgnore || [];
|
||
for (const p of CFG.pages) {
|
||
if (!hasPath(p)) { unverified(`${p.name} 죽은 선택자`, "path 없음(url 전용 페이지)"); continue; }
|
||
const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
|
||
const documents = selectorDocumentsOf(p);
|
||
const missing = missingFiles([...files, ...documents]);
|
||
if (missing.length) {
|
||
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(CWD, file)).join(", ")}`);
|
||
continue;
|
||
}
|
||
if (!files.length) { unverified(`${p.name} 죽은 선택자`, "로컬 CSS 없음(<link rel=stylesheet> 로 연결된 로컬 파일만 읽는다 — 인라인 <style>·빌드 산출 CSS 는 staticContract.selectorFiles 로 지정한다)"); continue; }
|
||
// 사용 판정은 토큰 일치다. 부분 문자열로 보면 `.card` 가 본문의 "discard" 한 단어로 사용됨이 된다.
|
||
const used = new Set(documents.map((file) => fs.readFileSync(file, "utf8")).join("\n").split(/[^A-Za-z0-9_-]+/));
|
||
const dead = new Set();
|
||
for (const f of files) {
|
||
for (const prelude of cssPreludes(readCss(f))) {
|
||
for (const m of prelude.matchAll(/\.(-?[A-Za-z_][A-Za-z0-9_-]*)/g)) {
|
||
const cls = m[1];
|
||
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
|
||
if (!used.has(cls)) dead.add(cls);
|
||
}
|
||
}
|
||
}
|
||
dead.size
|
||
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
|
||
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
|
||
}
|
||
} else off("죽은 선택자", "checks.deadCss=false");
|
||
|
||
if (CFG.checks.cssHygiene) {
|
||
for (const p of CFG.pages) {
|
||
if (!hasPath(p)) { unverified(`${p.name} 토큰 위생`, "path 없음(url 전용 페이지)"); continue; }
|
||
const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
|
||
if (!files.length) { unverified(`${p.name} 토큰 위생`, "로컬 CSS 없음(<link rel=stylesheet> 로 연결된 로컬 파일만 읽는다 — staticContract.hygieneFiles 로 지정한다)"); continue; }
|
||
const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
|
||
const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
|
||
const missing = missingFiles([...files, ...tokenFiles]);
|
||
if (missing.length) {
|
||
fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(CWD, file)).join(", ")}`);
|
||
continue;
|
||
}
|
||
const tokenNames = new Set();
|
||
for (const f of new Set([...tokenFiles, ...files])) {
|
||
for (const m of readCss(f).matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
|
||
}
|
||
const colors = []; const colorKinds = new Set(); const radii = new Set();
|
||
for (const f of files) {
|
||
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
|
||
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다. 마지막 선언은 `;` 없이
|
||
// `}` 로 끝날 수 있다(미니파이 CSS 의 기본 꼴) — `;` 만 종결로 보면 토큰 파일 자신이 실패한다.
|
||
const src = readCss(f).replace(/--[A-Za-z0-9_-]+\s*:\s*[^;{}]*(?=[;}])/g, "");
|
||
// 색은 선언값에서만 찾는다. 전체 텍스트에서 찾으면 `#fade{}` 같은 ID 선택자가 색 리터럴로 잡힌다.
|
||
for (const value of cssValues(src)) {
|
||
for (const m of value.matchAll(COLOR_LITERAL)) {
|
||
if (m[0].includes("var(")) continue; // rgb(var(--ink-rgb) / .5) 는 토큰에서 온 값이다
|
||
colors.push(`${path.basename(f)}:${m[0]}`);
|
||
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
|
||
}
|
||
}
|
||
for (const m of src.matchAll(/border-radius\s*:\s*([^;{}]+)(?=[;}])/g)) {
|
||
const value = m[1].replace(/\s*!important\s*$/i, "").trim();
|
||
if (!value.includes("var(") && !RADIUS_NEUTRAL.test(value)) radii.add(value);
|
||
}
|
||
}
|
||
const budgets = p.staticContract?.budgets || {};
|
||
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
|
||
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
|
||
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
|
||
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
|
||
const bad = [];
|
||
if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
|
||
if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
|
||
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
|
||
}
|
||
bad.length
|
||
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
|
||
: pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
|
||
}
|
||
} else off("토큰 위생", "checks.cssHygiene=false");
|
||
|
||
// ── 페이지 안에서 도는 측정 도구 ──
|
||
const UTILS = `
|
||
window.__lines = (sel) => {
|
||
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
|
||
if (!el) return -1;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
|
||
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
|
||
const ls = [];
|
||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||
return ls.length;
|
||
};
|
||
window.__textLines = (sel) => {
|
||
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
|
||
if (!el) return -1;
|
||
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
|
||
return window.__lines(text || el);
|
||
};
|
||
// 요소 전체를 하나의 Range 로 재면(예: display:inline-grid 로 두 텍스트 블록을 세로로
|
||
// 쌓은 의도된 로크업) 자식 인라인 박스 rect 가 섞여 줄 수가 과다 계산된다(실측: 조각달
|
||
// ".wordmark" <span>+<small> 2블록이 "3줄"로 오카운트). 비어 있지 않은 텍스트 노드
|
||
// 하나하나를 각자의 Range 로 재면 이 섞임이 없다 — 블록마다 실제 줄 수만 남는다.
|
||
window.__brandTextBlocks = (el) => {
|
||
if (!el) return [];
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||
const out = [];
|
||
let n;
|
||
while ((n = walker.nextNode())) {
|
||
const text = n.textContent.trim();
|
||
if (text) out.push({ text, lines: window.__lines(n) });
|
||
}
|
||
return out;
|
||
};
|
||
window.__heading = (sel) => {
|
||
let el;
|
||
try { el = document.querySelector(sel); }
|
||
catch { return { state: "invalid" }; }
|
||
if (!el) return { state: "missing" };
|
||
if (!el.textContent.trim()) return { state: "empty" };
|
||
const box = el.getBoundingClientRect();
|
||
const hiddenAncestor = (() => {
|
||
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
|
||
const style = getComputedStyle(node);
|
||
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || parseFloat(style.opacity) <= 0) return true;
|
||
}
|
||
return false;
|
||
})();
|
||
if (hiddenAncestor || box.width <= 0 || box.height <= 0) {
|
||
return { state: "hidden" };
|
||
}
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
|
||
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
|
||
const lines = [];
|
||
for (const top of tops) if (!lines.length || top - lines[lines.length - 1] > 5) lines.push(top);
|
||
return { state: "visible", lines: lines.length, rects: rects.map((r) => ({ left: r.left, right: r.right, top: r.top, bottom: r.bottom })) };
|
||
};
|
||
window.__headingScaleAudit = (sel) => {
|
||
const heading = window.__heading(sel);
|
||
if (heading.state !== "visible") return heading;
|
||
const el = document.querySelector(sel);
|
||
const clipping = [];
|
||
const viewportWidth = document.documentElement.clientWidth;
|
||
for (const rect of heading.rects) {
|
||
if (rect.left < -1 || rect.right > viewportWidth + 1) clipping.push("viewport");
|
||
}
|
||
for (let ancestor = el; ancestor && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
|
||
const cs = getComputedStyle(ancestor);
|
||
const box = ancestor.getBoundingClientRect();
|
||
const name = ancestor.className ? "." + String(ancestor.className).trim().split(/\\s+/)[0] : ancestor.tagName.toLowerCase();
|
||
if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowX) && heading.rects.some((rect) => rect.left < box.left - 1 || rect.right > box.right + 1)) clipping.push(name + "(x)");
|
||
if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowY) && heading.rects.some((rect) => rect.top < box.top - 1 || rect.bottom > box.bottom + 1)) clipping.push(name + "(y)");
|
||
}
|
||
return {
|
||
...heading,
|
||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
clipping: [...new Set(clipping)],
|
||
};
|
||
};
|
||
`;
|
||
|
||
// 색 정규화와 시각적 숨김 판정. 페이지 안에서 도는 자기완결 함수다(Node 쪽 값을 참조하지 않는다).
|
||
const installColorUtils = () => {
|
||
// getComputedStyle 은 oklch()·lab()·color(srgb …)·color-mix() 결과를 rgb() 로 바꿔 주지 않는다.
|
||
// 예전 파서는 rgb()/rgba() 만 읽고 나머지 요소를 판정에서 조용히 뺐다(실측: Tailwind v4 기본
|
||
// 팔레트는 oklch 라 대부분의 텍스트를 재지 않고 "대비 AA PASS"를 냈다). 문법을 하나씩 따라가지 않고
|
||
// 브라우저에 맡긴다 — 1×1 캔버스에 칠한 뒤 픽셀을 읽으면 브라우저가 아는 모든 색이 sRGB 8비트로 나온다.
|
||
const cache = new Map();
|
||
let ctx;
|
||
const LEGACY = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/;
|
||
window.__rgba = (value) => {
|
||
const key = String(value || "");
|
||
if (cache.has(key)) return cache.get(key);
|
||
let out = null;
|
||
const legacy = LEGACY.exec(key);
|
||
if (legacy) {
|
||
out = { r: +legacy[1], g: +legacy[2], b: +legacy[3], a: legacy[4] === undefined ? 1 : +legacy[4] };
|
||
} else if (key) {
|
||
if (ctx === undefined) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = canvas.height = 1;
|
||
ctx = canvas.getContext("2d", { willReadFrequently: true }) || null;
|
||
}
|
||
if (ctx) {
|
||
// 해석하지 못한 값을 넣으면 fillStyle 이 이전 값으로 남는다. 서로 다른 두 표식으로 확인한다.
|
||
ctx.fillStyle = "#010203"; ctx.fillStyle = key; const first = ctx.fillStyle;
|
||
ctx.fillStyle = "#040506"; ctx.fillStyle = key; const second = ctx.fillStyle;
|
||
if (!(first === "#010203" && second === "#040506")) {
|
||
ctx.clearRect(0, 0, 1, 1);
|
||
ctx.fillRect(0, 0, 1, 1);
|
||
const d = ctx.getImageData(0, 0, 1, 1).data;
|
||
out = { r: d[0], g: d[1], b: d[2], a: d[3] / 255 };
|
||
}
|
||
}
|
||
}
|
||
cache.set(key, out);
|
||
return out;
|
||
};
|
||
// 스크린리더 전용 텍스트·스킵 링크의 표준 구현(1px 상자 + clip). 눈에 보이지도, 누를 수도 없다.
|
||
window.__visuallyHidden = (el, style) => {
|
||
const cs = style || getComputedStyle(el);
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width > 2 || r.height > 2) return false;
|
||
return (cs.clip && cs.clip !== "auto") || (cs.clipPath && cs.clipPath !== "none") || (cs.position === "absolute" && cs.overflow !== "visible");
|
||
};
|
||
};
|
||
|
||
// ── 브라우저 계층 ──
|
||
const browserLayer = async () => {
|
||
if (STATIC_ONLY) return unverified("브라우저 계층", "--static-only — 렌더 검사(오버플로·제목·대비·터치 타깃·axe 등)를 돌리지 않았다");
|
||
if (!CHROME) return unverified("브라우저 계층", "브라우저 실행 파일을 찾지 못했다 — Chrome·Chromium·Edge 를 설치하거나 CHROME_PATH 를 지정한다");
|
||
const puppeteer = exportOf(await loadOptional("puppeteer-core"), (m) => typeof m.launch === "function");
|
||
if (!puppeteer) return unverified("브라우저 계층", "puppeteer-core 를 찾지 못했다 — 실행한 디렉터리(cwd)나 이 스크립트 위치의 node_modules 에 있어야 한다");
|
||
|
||
// axe-core 선택 해석 (checks.axe: "auto" 기본). 해석되면 각 page/view 에 소스를 주입해 실행하고,
|
||
// 안 되면 "미검증"으로 보고한다 — 통과로 추정하지 않는다.
|
||
const axeSource = CFG.checks.axe !== false ? readOptionalSource("axe-core") : null;
|
||
|
||
let browser;
|
||
try {
|
||
browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1", ...(Array.isArray(CFG.chromeArgs) ? CFG.chromeArgs.map(String) : [])] });
|
||
} catch (e) {
|
||
return unverified("브라우저 계층", `브라우저 실행 실패(${CHROME}) — ${errText(e)}`);
|
||
}
|
||
|
||
try {
|
||
for (const p of CFG.pages) {
|
||
// 페이지 하나가 타임아웃·예외로 죽어도 누적 결과와 나머지 페이지를 버리지 않는다
|
||
const page = await browser.newPage();
|
||
const mark = results.length;
|
||
try {
|
||
await auditPage(page, p, axeSource);
|
||
} catch (e) {
|
||
const loaded = results.length > mark;
|
||
fail(loaded ? `${p.name} 실행 오류` : `${p.name} 로드`, `${errText(e)} — 이 페이지의 남은 검사를 돌리지 못했다`);
|
||
} finally {
|
||
await page.close().catch(() => {});
|
||
}
|
||
}
|
||
if (CFG.checks.visual) await visualLayer(browser);
|
||
} catch (e) {
|
||
fail("브라우저 계층 실행 오류", errText(e));
|
||
} finally {
|
||
await browser.close().catch(() => {});
|
||
}
|
||
};
|
||
|
||
const auditPage = async (page, p, axeSource) => {
|
||
const href = pageUrl(p);
|
||
const views = viewsOf(p);
|
||
const load = async () => {
|
||
await page.goto(href, { waitUntil: CFG.load.waitUntil, timeout: CFG.load.timeoutMs });
|
||
await page.evaluate(UTILS);
|
||
await page.evaluate(installColorUtils);
|
||
};
|
||
|
||
// 런타임 오류 수집 — 깨진 화면을 재고 통과시키지 않는다(스크립트 예외로 반쯤 그려진 화면, 404 난 로컬 CSS)
|
||
const runtime = { errors: new Set(), requests: new Set() };
|
||
const isLocal = (u) => {
|
||
if (u.startsWith("file:")) return true;
|
||
try { return /^https?:/.test(u) && new URL(u).origin === new URL(href).origin; } catch { return false; }
|
||
};
|
||
page.on("pageerror", (e) => runtime.errors.add(errText(e)));
|
||
// requestfailed 는 "파일이 없다"만 센다. 게이트는 뷰포트를 바꿔 가며 같은 페이지를 여러 번 다시 여는데,
|
||
// 그때 취소된 요청이 ERR_FAILED·ERR_ABORTED 로 들어온다(실측: 정상 CSS·폰트가 실패로 집계됐다).
|
||
// file:// 에서 못 읽는 manifest 도 ERR_FAILED 다. HTTP 의 4xx·5xx 는 아래 response 에서 센다.
|
||
page.on("requestfailed", (req) => {
|
||
const reason = req.failure()?.errorText || "";
|
||
if (isLocal(req.url()) && /ERR_FILE_NOT_FOUND/.test(reason)) runtime.requests.add(`${req.url().split("/").pop().split("?")[0].slice(0, 40)}(없는 파일)`);
|
||
});
|
||
page.on("response", (res) => {
|
||
if (res.status() >= 400 && isLocal(res.url())) runtime.requests.add(`${res.url().split("/").pop().slice(0, 40)}(${res.status()})`);
|
||
});
|
||
|
||
await page.setCacheEnabled(false);
|
||
await page.setViewport({ width: 1440, height: 900 });
|
||
await load();
|
||
await page.evaluate(() => document.fonts.ready);
|
||
|
||
// ── SEO / meta (조사 기반 체크리스트) ──
|
||
if (CFG.checks.meta) {
|
||
const meta = await page.evaluate(() => {
|
||
const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; };
|
||
const title = document.title.trim();
|
||
const desc = get('meta[name="description"]');
|
||
const og = (k) => get(`meta[property="og:${k}"]`);
|
||
const imgs = [...document.querySelectorAll("img")];
|
||
const hex = (value) => { const c = window.__rgba(value); return c ? "#" + [c.r, c.g, c.b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("") : null; };
|
||
const themeColor = get('meta[name="theme-color"]');
|
||
// 프리셋 전환에서 브랜드 표면이 누락되면 theme-color 만 옛 값으로 남는다 — 실제 배경과 비교해 잡는다
|
||
const bodyBg = (() => {
|
||
const body = window.__rgba(getComputedStyle(document.body).backgroundColor);
|
||
return body && body.a > 0 ? getComputedStyle(document.body).backgroundColor : getComputedStyle(document.documentElement).backgroundColor;
|
||
})();
|
||
return {
|
||
title, titleLen: title.length,
|
||
desc, descLen: desc ? desc.length : 0,
|
||
viewport: get('meta[name="viewport"]'),
|
||
lang: document.documentElement.getAttribute("lang"),
|
||
charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"),
|
||
canonical: get('link[rel="canonical"]', "href"),
|
||
ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"),
|
||
twitter: get('meta[name="twitter:card"]'),
|
||
robots: get('meta[name="robots"]'),
|
||
favicon: !!document.querySelector('link[rel~="icon"]'),
|
||
// 표기(#FFF·#ffffff·rgb()·oklch())가 달라도 같은 색이면 같다 — 둘 다 브라우저가 해석한 값으로 비교한다
|
||
themeColor, themeHex: themeColor ? hex(themeColor) : null,
|
||
bodyBg, bodyHex: (() => { const c = window.__rgba(bodyBg); return c && c.a > 0 ? hex(bodyBg) : null; })(),
|
||
h1Count: document.querySelectorAll("h1").length,
|
||
// alt="" 는 장식 이미지 선언(의도)이다 — 실패는 alt 속성 자체가 없는 img 만
|
||
imgNoAlt: imgs.filter((i) => !i.hasAttribute("alt")).length,
|
||
imgDecorative: imgs.filter((i) => i.hasAttribute("alt") && i.getAttribute("alt") === "").length,
|
||
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].every((s) => { try { JSON.parse(s.textContent); return true; } catch { return false; } }),
|
||
jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length,
|
||
};
|
||
});
|
||
const m = [];
|
||
if (!meta.title) m.push("title 없음");
|
||
else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`);
|
||
if (!meta.desc) m.push("description 없음");
|
||
else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`);
|
||
if (!meta.viewport) m.push("viewport 없음");
|
||
if (!meta.lang) m.push("html lang 없음");
|
||
if (!meta.charset) m.push("charset 없음");
|
||
if (meta.ogTitle === null) m.push("og:title 없음");
|
||
if (meta.ogImage === null) m.push("og:image 없음");
|
||
if (meta.twitter === null) m.push("twitter:card 없음");
|
||
if (!meta.favicon) m.push("favicon 없음");
|
||
if (meta.themeColor && meta.themeHex && meta.bodyHex && meta.themeHex !== meta.bodyHex) {
|
||
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
|
||
}
|
||
if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
|
||
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
|
||
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
|
||
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — og:description·og:url·canonical 은 읽기만 하고 단언하지 않는다
|
||
m.length ? fail(`${p.name} SEO/meta`, m.join(" · ")) : pass(`${p.name} SEO/meta`, `title ${meta.titleLen}자·desc ${meta.descLen}자·OG✓·alt 누락 ${meta.imgNoAlt} · 장식(alt="") ${meta.imgDecorative}`);
|
||
}
|
||
|
||
// ── 뷰 전환 — 설정에 적힌 뷰는 실제로 전환됐을 때만 잰다.
|
||
// 예전에는 버튼을 못 찾아도 넘어가서 없는 뷰 이름이 같은 화면의 측정값으로 PASS 를 받았다(실측:
|
||
// views 에 오타 둘을 넣어도 세 뷰 모두 통과). 못 찾으면 그 뷰는 실패 한 줄로 끝낸다(G8). ──
|
||
const badViews = new Set();
|
||
const enterView = async (v) => {
|
||
if (badViews.has(v)) return false;
|
||
if (v === "single") return true;
|
||
let why = null;
|
||
if (!p.viewAttribute) {
|
||
why = "viewAttribute 없음 — views 에 이름을 적으려면 전환 버튼의 속성 이름이 필요하다(전환이 없는 페이지면 views 를 지운다)";
|
||
} else {
|
||
const selector = `[${p.viewAttribute}="${v.replace(/["\\]/g, "\\$&")}"]`;
|
||
const found = await page.evaluate((sel) => {
|
||
let btn = null;
|
||
try { btn = document.querySelector(sel); } catch { return false; }
|
||
if (!btn) return false;
|
||
btn.click();
|
||
return true;
|
||
}, selector);
|
||
if (!found) why = `전환 버튼 없음 (${selector})`;
|
||
}
|
||
if (why) {
|
||
badViews.add(v);
|
||
fail(`${p.name}/${v} 뷰 전환`, why);
|
||
return false;
|
||
}
|
||
await sleep(120);
|
||
return true;
|
||
};
|
||
|
||
for (const v of views) {
|
||
if (!(await enterView(v))) continue;
|
||
// 오버플로 — 전 폭
|
||
const overs = [];
|
||
for (const w of CFG.widths) {
|
||
await page.setViewport({ width: w, height: 900 });
|
||
await sleep(60);
|
||
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||
if (over > 0) overs.push(`+${over}px@${w}`);
|
||
}
|
||
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
|
||
|
||
// 제목은 항상 존재·가시성을 확인하고, 줄 수는 페이지가 명시한 계약일 때만 검사한다.
|
||
await page.setViewport({ width: 1440, height: 900 });
|
||
const h1Selector = p.h1 || "h1";
|
||
const h1 = await page.evaluate((sel) => window.__heading(sel), h1Selector);
|
||
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : null;
|
||
if (h1.state === "missing") fail(`${p.name}/${v} h1`, `선택자 없음 (${h1Selector})`);
|
||
else if (h1.state === "empty") fail(`${p.name}/${v} h1`, "텍스트 없음");
|
||
else if (h1.state === "hidden") fail(`${p.name}/${v} h1`, `선택자가 숨김 (${h1Selector})`);
|
||
else if (h1.state === "invalid") fail(`${p.name}/${v} h1`, `잘못된 선택자 (${h1Selector})`);
|
||
else if (h1.lines < 1) fail(`${p.name}/${v} h1`, "텍스트 없음");
|
||
else if (h1MaxLines !== null && h1.lines > h1MaxLines) fail(`${p.name}/${v} h1`, `${h1.lines}줄(최대 ${h1MaxLines}줄)`);
|
||
else if (h1MaxLines !== null) pass(`${p.name}/${v} h1 최대 ${h1MaxLines}줄`, `${h1.lines}줄`);
|
||
else pass(`${p.name}/${v} h1 보임`, `${h1.lines}줄(줄수 계약 없음)`);
|
||
|
||
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
|
||
if (CFG.checks.stack) {
|
||
const stacked = await page.evaluate((limit) => {
|
||
const out = [];
|
||
document.querySelectorAll("body *").forEach((el) => {
|
||
if (!el.offsetParent || el.querySelector("*")) return;
|
||
const txt = el.textContent.trim();
|
||
if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10));
|
||
});
|
||
return [...new Set(out)].slice(0, 5);
|
||
}, CFG.thresholds.stackLines);
|
||
stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`);
|
||
}
|
||
|
||
// 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다
|
||
// (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙)
|
||
if (CFG.checks.rhythm) {
|
||
const rhythm = await page.evaluate(() => {
|
||
const groups = {};
|
||
document.querySelectorAll("body *").forEach((el) => {
|
||
if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return;
|
||
const cls = el.classList[0];
|
||
const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls;
|
||
const r = el.getBoundingClientRect();
|
||
(groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement });
|
||
});
|
||
const issues = [];
|
||
for (const [key, items] of Object.entries(groups)) {
|
||
if (items.length < 4) continue;
|
||
const spread = (v) => Math.max(...v) - Math.min(...v);
|
||
const cls = key.split("|")[1];
|
||
const hUniform = spread(items.map((i) => i.h)) <= 4;
|
||
const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도
|
||
const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도
|
||
// 1) 트랙 폭: 숫자 라벨·빈 셀은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다.
|
||
// 한국어 라벨은 글자수가 같아도 내용 폭차가 자연스러우므로 대상에서 뺀다(오탐 방지).
|
||
const wSpread = spread(items.map((i) => i.w));
|
||
if (hUniform && wSpread > 6 && (isDigits || isEmpty)) {
|
||
issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`);
|
||
}
|
||
// 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다
|
||
if (spread(items.map((i) => i.x)) < 3 && hUniform) {
|
||
const ys = items.map((i) => i.y).sort((a, b) => a - b);
|
||
const deltas = ys.slice(1).map((y, i) => y - ys[i]);
|
||
if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`);
|
||
} else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) {
|
||
const xs2 = items.map((i) => i.x).sort((a, b) => a - b);
|
||
const d2 = xs2.slice(1).map((x, i) => x - xs2[i]);
|
||
if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`);
|
||
}
|
||
}
|
||
return issues.slice(0, 4);
|
||
});
|
||
rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙");
|
||
}
|
||
|
||
// 대비 — 글자와 그 뒤 화소를 "보이는 대로" 합성해서 잰다.
|
||
// · 색은 __rgba 로 정규화한다(oklch·lab·color()·color-mix 포함). 못 읽으면 건너뛰지 않고 미검증으로 센다.
|
||
// · 배경은 documentElement 까지 올라간다. html 에 건 배경을 빼면 어두운 사이트의 흰 글자가 1:1 로
|
||
// 거짓 실패하고, 어두운 글자는 거짓 통과한다(실측).
|
||
// · 조상 opacity 는 그 조상의 배경과 글자를 한 묶음으로 흐리게 한다. 묶음 단위로 알파를 곱한다.
|
||
// · 뒤에 이미지·그라디언트가 깔리면 계산값으로 단정하지 않는다 — "대비 미검증 n요소"로 센다.
|
||
// · 비활성 컨트롤(disabled·aria-disabled)의 글자는 WCAG 1.4.3 의 예외라 판정에서 빼고 수만 남긴다.
|
||
// 흐리게 한 opacity 를 합성하기 시작하면서 이 예외가 없으면 비활성 버튼이 전부 실패한다(실측).
|
||
if (CFG.checks.contrast) {
|
||
const res = await page.evaluate((cn, cl) => {
|
||
const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
||
const over = (top, under) => {
|
||
const a = top.a + under.a * (1 - top.a);
|
||
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||
const ch = (k) => (top[k] * top.a + under[k] * under.a * (1 - top.a)) / a;
|
||
return { r: ch("r"), g: ch("g"), b: ch("b"), a };
|
||
};
|
||
const WHITE = { r: 255, g: 255, b: 255, a: 1 }; // 아무 배경도 없을 때의 캔버스 기본색
|
||
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION", "OPTGROUP"]);
|
||
const styles = new Map();
|
||
const styleOf = (node) => { let s = styles.get(node); if (!s) { s = getComputedStyle(node); styles.set(node, s); } return s; };
|
||
const seen = new Set(); const out = [];
|
||
let checked = 0, unvImage = 0, unvColor = 0, inactive = 0;
|
||
const isInactive = (el) => {
|
||
if (el.closest(":disabled, [aria-disabled='true']")) return true;
|
||
const label = el.closest("label");
|
||
return !!(label && label.control && label.control.disabled);
|
||
};
|
||
document.querySelectorAll("body *").forEach((el) => {
|
||
if (SKIP_TAGS.has(el.tagName)) return;
|
||
// fixed 요소는 offsetParent 가 null 이다(G13) — 보임은 렌더 상자 유무로 판정한다
|
||
if (!el.getClientRects().length) return;
|
||
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
|
||
const cs = styleOf(el);
|
||
if (cs.visibility !== "visible" || window.__visuallyHidden(el, cs)) return;
|
||
if ((cs.webkitBackgroundClip || cs.backgroundClip) === "text") { unvImage++; return; }
|
||
const fill = cs.webkitTextFillColor;
|
||
const text = window.__rgba(fill && fill !== cs.color ? fill : cs.color);
|
||
if (!text) { unvColor++; return; }
|
||
let fg = { ...text }, bg = { r: 0, g: 0, b: 0, a: 0 };
|
||
let opacity = 1, image = false, unknown = false;
|
||
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
||
const s = styleOf(node);
|
||
if (bg.a < 0.999) {
|
||
if (s.backgroundImage && s.backgroundImage !== "none") { image = true; break; }
|
||
const layer = window.__rgba(s.backgroundColor);
|
||
if (!layer) { unknown = true; break; }
|
||
if (layer.a > 0) { fg = over(fg, layer); bg = over(bg, layer); }
|
||
}
|
||
const o = parseFloat(s.opacity);
|
||
if (o < 1) { fg.a *= o; bg.a *= o; opacity *= o; }
|
||
}
|
||
if (text.a * opacity < 0.02) return; // 사실상 안 보이는 글자(닫힌 상태의 opacity:0 등)
|
||
if (isInactive(el)) { inactive++; return; }
|
||
if (image) { unvImage++; return; }
|
||
if (unknown) { unvColor++; return; }
|
||
const fgFinal = over(fg, WHITE), bgFinal = over(bg, WHITE);
|
||
const l1 = lum(fgFinal), l2 = lum(bgFinal);
|
||
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
|
||
const size = parseFloat(cs.fontSize) || 0; const w = +cs.fontWeight || 400;
|
||
const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn;
|
||
checked++;
|
||
if (ratio < need) {
|
||
const k = cs.color + Math.round(size) + Math.round(ratio * 10);
|
||
if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); }
|
||
}
|
||
});
|
||
return { bad: out.slice(0, 4), total: out.length, checked, unvImage, unvColor, inactive };
|
||
}, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge);
|
||
const unvCount = res.unvImage + res.unvColor;
|
||
const unvNote = unvCount ? `대비 미검증 ${unvCount}요소(이미지·그라디언트 배경 ${res.unvImage}·색 해석 불가 ${res.unvColor})` : "";
|
||
if (res.total) fail(`${p.name}/${v} 대비`, res.bad.join(",") + (res.total > res.bad.length ? ` 외 ${res.total - res.bad.length}종` : "") + (unvNote ? ` · ${unvNote}` : ""));
|
||
else if (unvCount) unverified(`${p.name}/${v} 대비`, `${unvNote} — 실제 렌더를 열어 판정한다. 나머지 ${res.checked}요소는 AA 충족`);
|
||
else pass(`${p.name}/${v} 대비 AA`, `검사 요소 ${res.checked}개${res.inactive ? ` · 비활성 컨트롤 ${res.inactive}요소 제외` : ""}`);
|
||
}
|
||
}
|
||
|
||
// ── 스케일×폭 제목 행렬 (글꼴 확대에서 줄 수 증가와 잘림을 분리한다) ──
|
||
if (CFG.checks.scaleMatrix) {
|
||
const bad = [];
|
||
const measurements = [];
|
||
for (const w of [375, 390]) {
|
||
for (const s of CFG.fontScales) {
|
||
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
|
||
await load();
|
||
const baseRootSize = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize));
|
||
if (!Number.isFinite(baseRootSize) || baseRootSize <= 0) {
|
||
bad.push(`${w}@${s}x:root font-size 측정 실패`);
|
||
continue;
|
||
}
|
||
await page.addStyleTag({ content: `html { font-size: ${baseRootSize * s}px !important; }` });
|
||
const h1 = await page.evaluate((sel) => window.__headingScaleAudit(sel), p.h1 || "h1");
|
||
const point = `${w}@${s}x`;
|
||
if (h1.state === "missing") { bad.push(`${point}:h1 선택자 없음 (${p.h1 || "h1"})`); continue; }
|
||
if (h1.state === "empty") { bad.push(`${point}:h1 텍스트 없음`); continue; }
|
||
if (h1.state === "hidden") { bad.push(`${point}:h1 선택자가 숨김 (${p.h1 || "h1"})`); continue; }
|
||
if (h1.state === "invalid") { bad.push(`${point}:h1 잘못된 선택자 (${p.h1 || "h1"})`); continue; }
|
||
if (h1.lines < 1) { bad.push(`${point}:h1 텍스트 없음`); continue; }
|
||
measurements.push(`${point}:${h1.lines}줄`);
|
||
if (s > 1 && h1.documentOverflow > 1) bad.push(`${point}:문서 가로오버플로 +${h1.documentOverflow}px`);
|
||
if (s > 1 && h1.clipping.length) bad.push(`${point}:제목 텍스트 클리핑 ${h1.clipping.join(",")}`);
|
||
}
|
||
}
|
||
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `줄수 정보 ${measurements.join(", ")}`);
|
||
} else off(`${p.name} 스케일×폭 h1`, "checks.scaleMatrix=false");
|
||
|
||
// ── 브랜드 줄바꿈 — 텍스트 블록별(예: 로고의 로크업 두 줄) 각자 1줄이어야 한다.
|
||
// 여러 텍스트 블록을 한 Range 로 합쳐 재면 자식 인라인 박스가 섞여 과다 계산된다.
|
||
// 예전에는 이 검사가 scaleMatrix 블록 안에 있었고 checks.wrap 은 읽히지 않아, 꺼도 꺼지지 않았다. ──
|
||
if (p.brand) {
|
||
if (CFG.checks.wrap) {
|
||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||
await load();
|
||
// 동일 셀렉터가 여러 개(사이드바+탑바 등)면 보이는 것을 잰다 — display:none 의 rect 는 0
|
||
const blocks = await page.evaluate((sel) => {
|
||
let els;
|
||
try { els = [...document.querySelectorAll(sel)]; } catch { return null; }
|
||
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
|
||
return el ? window.__brandTextBlocks(el) : null;
|
||
}, p.brand);
|
||
if (blocks === null) {
|
||
fail(`${p.name} 브랜드 줄바꿈`, `선택자 매칭 없음 (${p.brand})`);
|
||
} else {
|
||
const wrapped = blocks.find((b) => b.lines >= 2);
|
||
wrapped
|
||
? fail(`${p.name} 브랜드 줄바꿈`, `"${wrapped.text.slice(0, 12)}" ${wrapped.lines}줄(수축 의심)`)
|
||
: pass(`${p.name} 브랜드 줄바꿈 없음`, `텍스트 블록 ${blocks.length}개, 각 1줄`);
|
||
}
|
||
} else off(`${p.name} 브랜드 줄바꿈`, "checks.wrap=false");
|
||
}
|
||
|
||
// ── 터치 타깃 — 두 층으로 나눠 판정한다 (실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는
|
||
// 보고로 시작된 검사).
|
||
// 1) WCAG 2.5.8(하드 게이트, 규범): 인라인이 아닌 대상 중 min(width,height) < 24px 는
|
||
// 간격 예외(24px 지름 원이 다른 대상의 bbox·다른 undersized 대상의 원과 겹치지 않음)를
|
||
// 계산한다. 문장 속 인라인 링크는 WCAG 2.5.8 의 inline 예외라 이 층에서 아예 빼고,
|
||
// 계약 층에서만(24px) 본다.
|
||
// 2) 계약 층(프로젝트·플랫폼 목표): HIG 44pt·M3 48dp 기준 컨트롤 ≥44px, 인라인 ≥24px.
|
||
// tapTargetPolicy:"contract"(기본)면 계약 미달도 실패,
|
||
// "wcag"면 WCAG 층 실패만 실패로 보고하고 계약 미달은 통과 detail 에 참고로만 남긴다.
|
||
// WCAG 층의 인라인 예외: computed display 가 inline 이고, 같은 줄 흐름에 자신 밖의 글자가 있을
|
||
// 때만이다(또는 tapTargetsInline 선택자). 예전에는 <a href> 전부를 인라인으로 봐서, 붙어 있는
|
||
// 16px 아이콘 링크가 WCAG 층에서 빠지고 "계약 참고"로 내려갔다(실측).
|
||
// 계약 층의 문턱 분류는 따로다: 텍스트 흐름 요소(display:inline)와 링크(<a href>)는
|
||
// inlineTargetMin, 나머지 컨트롤은 tapTargetMin 을 쓴다(종전과 같다).
|
||
// 시각적으로 숨긴 스킵 링크·입력은 대상이 아니다 — 예전에는 A14 가 권하는 스킵 링크를 넣으면
|
||
// "1×1"로 이 검사가 실패했다(실측). ──
|
||
if (CFG.checks.tapTargets) {
|
||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||
await load();
|
||
for (const v of views) {
|
||
if (!(await enterView(v))) continue;
|
||
const result = await page.evaluate((min, inlineMin, inlineSel, policy) => {
|
||
const inSentence = (el) => {
|
||
// 부모에 직접 딸린 글자가 있으면 문장 속이다. 부모도 inline(strong·em 등)이면 한 겹 더 올라간다.
|
||
for (let node = el; node.parentElement; node = node.parentElement) {
|
||
const parent = node.parentElement;
|
||
if ([...parent.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim())) return true;
|
||
if (getComputedStyle(parent).display !== "inline") return false;
|
||
}
|
||
return false;
|
||
};
|
||
const labelOf = (el) => {
|
||
if (el.tagName !== "INPUT" || !/^(checkbox|radio)$/i.test(el.type)) return null;
|
||
return el.closest("label") || (el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null);
|
||
};
|
||
const targets = [];
|
||
document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab'], [role='link'], [role='switch'], [role='checkbox']").forEach((el) => {
|
||
const cs = getComputedStyle(el);
|
||
if (cs.display === "none" || cs.visibility === "hidden") return;
|
||
if (el.tagName === "INPUT" && el.type === "hidden") return;
|
||
// 숨긴 체크박스·라디오를 label 이 감싸면 누르는 곳은 label 이다
|
||
const label = labelOf(el);
|
||
const box = label || el;
|
||
const r = box.getBoundingClientRect();
|
||
if (r.width <= 0 || r.height <= 0) return;
|
||
if (window.__visuallyHidden(box)) return;
|
||
const inline = (cs.display === "inline" && !label && inSentence(el)) || (inlineSel || []).some((s) => { try { return el.matches(s); } catch { return false; } });
|
||
const linkLike = inline || (!label && (cs.display === "inline" || (el.tagName === "A" && el.hasAttribute("href"))));
|
||
const name = box.textContent.trim().slice(0, 8) || el.getAttribute("aria-label") || el.tagName.toLowerCase();
|
||
targets.push({ x: r.left, y: r.top, w: r.width, h: r.height, inline, linkLike, label: name });
|
||
});
|
||
// 간격 예외: 반지름 12 원(중심=bbox 중심)이 (a) 다른 모든 대상의 bbox 와 교차하지 않고
|
||
// (b) 다른 undersized 대상의 원과 교차하지 않으면(중심 간 거리 ≥24) 충족.
|
||
const exceptionMet = (t) => {
|
||
const cx = t.x + t.w / 2, cy = t.y + t.h / 2;
|
||
for (const o of targets) {
|
||
if (o === t) continue;
|
||
const nx = Math.max(o.x, Math.min(cx, o.x + o.w));
|
||
const ny = Math.max(o.y, Math.min(cy, o.y + o.h));
|
||
if (Math.hypot(cx - nx, cy - ny) < 12) return false;
|
||
}
|
||
for (const o of targets) {
|
||
if (o === t || Math.min(o.w, o.h) >= 24) continue;
|
||
const ocx = o.x + o.w / 2, ocy = o.y + o.h / 2;
|
||
if (Math.hypot(cx - ocx, cy - ocy) < 24) return false;
|
||
}
|
||
return true;
|
||
};
|
||
const fails = []; const notes = [];
|
||
for (const t of targets) {
|
||
const size = Math.min(t.w, t.h);
|
||
const wcagFail = !t.inline && size < 24 && !exceptionMet(t);
|
||
const contractMin = t.linkLike ? inlineMin : min;
|
||
const contractFail = size < contractMin;
|
||
const sizeLabel = `"${t.label}" ${Math.round(t.w)}×${Math.round(t.h)}${t.inline ? "(인라인)" : t.linkLike ? "(링크)" : ""}`;
|
||
if (wcagFail) {
|
||
fails.push(`WCAG 2.5.8: ${sizeLabel}`);
|
||
} else if (contractFail) {
|
||
if (policy === "contract") {
|
||
fails.push(`계약 ${contractMin}px: ${sizeLabel}`);
|
||
} else {
|
||
const note = !t.inline && size < 24 ? "(WCAG 간격 예외 충족)" : "";
|
||
notes.push(`계약 ${contractMin}px: ${sizeLabel}${note}`);
|
||
}
|
||
}
|
||
}
|
||
const uniqueFails = [...new Set(fails)], uniqueNotes = [...new Set(notes)];
|
||
return { fails: uniqueFails.slice(0, 5), failTotal: uniqueFails.length, notes: uniqueNotes.slice(0, 5), count: targets.length };
|
||
}, CFG.thresholds.tapTargetMin, CFG.thresholds.inlineTargetMin, CFG.tapTargetsInline || [], CFG.tapTargetPolicy);
|
||
const summary = `컨트롤 ≥${CFG.thresholds.tapTargetMin}px·링크/인라인 ≥${CFG.thresholds.inlineTargetMin}px · 대상 ${result.count}개`;
|
||
if (result.fails.length) fail(`${p.name}/${v} 터치타깃`, result.fails.join(" · ") + (result.failTotal > result.fails.length ? ` 외 ${result.failTotal - result.fails.length}건` : ""));
|
||
else if (result.notes.length) pass(`${p.name}/${v} 터치타깃`, `${summary} — 계약 참고: ${result.notes.join(" · ")}`);
|
||
else pass(`${p.name}/${v} 터치타깃`, summary);
|
||
}
|
||
} else off(`${p.name} 터치타깃`, "checks.tapTargets=false");
|
||
|
||
// ── axe-core 접근성 (선택 — checks.axe:"auto" 기본. 해석되면 각 view 에서 WCAG 2.x AA
|
||
// 태그로 실행하고, 위반은 규칙 id·영향·대상 수로 보고한다. 해석 안 되면 미검증으로 보고하고
|
||
// 통과로 추정하지 않는다) ──
|
||
if (CFG.checks.axe === false) {
|
||
off(`${p.name} axe`, "checks.axe=false");
|
||
} else if (!axeSource) {
|
||
unverified(`${p.name} axe`, "axe-core 를 찾지 못했다 — 실행한 디렉터리(cwd)나 이 스크립트 위치의 node_modules 에 있어야 한다");
|
||
} else {
|
||
await page.setViewport({ width: 1440, height: 900 });
|
||
await load();
|
||
for (const v of views) {
|
||
if (!(await enterView(v))) continue;
|
||
await page.addScriptTag({ content: axeSource });
|
||
const report = await page.evaluate(async () => {
|
||
const r = await window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } });
|
||
return r.violations.map((v) => ({ id: v.id, impact: v.impact, count: v.nodes.length }));
|
||
});
|
||
report.length
|
||
? fail(`${p.name}/${v} axe`, report.map((r) => `${r.id}(${r.impact}) ${r.count}건`).join(" · "))
|
||
: pass(`${p.name}/${v} axe`, "WCAG 2.x AA 위반 0건");
|
||
}
|
||
}
|
||
|
||
// ── 왼쪽 인셋(모바일) — 오버플로 검사는 오른쪽만 본다.
|
||
// 실측 사고: 제목 16px vs 앱바·카드 24px 혼재가 모든 게이트를 통과했고
|
||
// 사용자에게 '가장자리에 붙어 깨졌다'고 보고됐다. 텍스트 노드 Range 로 글자 시작점을 잰다
|
||
// (전폭 컨테이너의 보더박스가 아니라. display:none 의 rect=0 은 보임 검사로 제외). ──
|
||
if (CFG.checks.leftInset) {
|
||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||
await load();
|
||
for (const v of views) {
|
||
if (!(await enterView(v))) continue;
|
||
const inset = await page.evaluate(() => {
|
||
let min = null;
|
||
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||
let n;
|
||
while ((n = walk.nextNode())) {
|
||
if (!n.textContent.trim() || !n.parentElement) continue;
|
||
const cs = getComputedStyle(n.parentElement);
|
||
if (cs.display === "none" || cs.visibility === "hidden" || cs.position === "fixed") continue;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(n);
|
||
const r = range.getBoundingClientRect();
|
||
if (r.width < 2 || r.height < 4 || r.left < 0) continue;
|
||
const l = Math.round(r.left);
|
||
if (min === null || l < min) min = l;
|
||
}
|
||
return min;
|
||
});
|
||
inset === null
|
||
? unverified(`${p.name}/${v} 왼쪽 인셋`, "잴 텍스트가 없다")
|
||
: inset >= CFG.thresholds.leftInsetMin
|
||
? pass(`${p.name}/${v} 왼쪽 인셋`, `최소 텍스트 시작 ${inset}px`)
|
||
: fail(`${p.name}/${v} 왼쪽 인셋`, `텍스트가 ${inset}px에서 시작 — 기준 ≥${CFG.thresholds.leftInsetMin}px(가장자리에 붙어 보인다)`);
|
||
}
|
||
} else off(`${p.name} 왼쪽 인셋`, "checks.leftInset=false");
|
||
|
||
// ── 런타임 — 위 검사들이 여러 번 다시 여는 동안 모인 스크립트 예외·로컬 요청 실패·깨진 이미지.
|
||
// 깨진 화면의 측정값은 통과여도 의미가 없다. 외부 출처(CDN 폰트 등)의 실패는 세지 않는다. ──
|
||
if (CFG.checks.runtime) {
|
||
const brokenImages = await page.evaluate(() => [...document.querySelectorAll("img")]
|
||
.filter((img) => img.getAttribute("src") && img.complete && img.naturalWidth === 0)
|
||
.map((img) => String(img.getAttribute("src")).split("/").pop().slice(0, 40)));
|
||
const bad = [];
|
||
if (runtime.errors.size) bad.push(`스크립트 예외 ${runtime.errors.size}종: ${[...runtime.errors].slice(0, 2).join(" | ")}`);
|
||
if (runtime.requests.size) bad.push(`로컬 요청 실패 ${runtime.requests.size}건: ${[...runtime.requests].slice(0, 3).join(", ")}`);
|
||
if (brokenImages.length) bad.push(`깨진 이미지 ${brokenImages.length}개: ${[...new Set(brokenImages)].slice(0, 3).join(", ")}`);
|
||
bad.length ? fail(`${p.name} 런타임`, bad.join(" · ")) : pass(`${p.name} 런타임`, "스크립트 예외 0·로컬 요청 실패 0·깨진 이미지 0");
|
||
} else off(`${p.name} 런타임`, "checks.runtime=false");
|
||
};
|
||
|
||
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
|
||
const visualLayer = async (browser) => {
|
||
const pixelmatch = exportOf(await loadOptional("pixelmatch"), (m) => typeof m === "function");
|
||
const PNG = exportOf(await loadOptional("pngjs"), (m) => typeof m.PNG === "function")?.PNG;
|
||
if (!pixelmatch || !PNG) return unverified("L3 시각 회귀", "pixelmatch·pngjs 를 찾지 못했다");
|
||
const BASE = path.join(OUT_DIR, "__gate__/baseline");
|
||
fs.mkdirSync(BASE, { recursive: true });
|
||
const page = await browser.newPage();
|
||
let diffs = 0, compared = 0, created = 0;
|
||
try {
|
||
for (const p of CFG.pages) {
|
||
for (const w of [390, 1440]) {
|
||
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
|
||
await page.goto(pageUrl(p), { waitUntil: CFG.load.waitUntil, timeout: CFG.load.timeoutMs });
|
||
await sleep(250);
|
||
// name 은 설정 값이다 — 경로 구분자·.. 로 기준 디렉터리 밖에 쓰지 않게 파일명 문자만 남긴다
|
||
const name = `${String(p.name).replace(/[^A-Za-z0-9_-]/g, "_")}-${w}.png`;
|
||
const buf = await page.screenshot();
|
||
const cur = PNG.sync.read(Buffer.from(buf));
|
||
const bf = path.join(BASE, name);
|
||
if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); created++; continue; }
|
||
const base = PNG.sync.read(fs.readFileSync(bf));
|
||
if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; }
|
||
const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 });
|
||
const pct = (cnt / (base.width * base.height)) * 100;
|
||
compared++;
|
||
if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); }
|
||
}
|
||
}
|
||
} finally {
|
||
await page.close().catch(() => {});
|
||
}
|
||
if (diffs) return;
|
||
// 기준이 없어 방금 만든 화면은 비교한 것이 아니다. 예전에는 첫 실행이 "0화면 일치"로 통과했다.
|
||
if (UPDATE) pass("L3 시각 회귀", `${created}화면 기준갱신`);
|
||
else if (created) unverified("L3 시각 회귀", `기준 없음(${created}장 생성 — ${path.relative(CWD, BASE) || BASE}) — 눈으로 확인한 뒤 기준으로 삼는다. 비교한 화면 ${compared}장`);
|
||
else pass("L3 시각 회귀", `${compared}화면 일치`);
|
||
};
|
||
|
||
// ── L4 WebKit (옵션: playwright 설치 시) ──
|
||
const webkitLayer = async () => {
|
||
const webkit = exportOf(await loadOptional("playwright"), (m) => m.webkit && typeof m.webkit.launch === "function")?.webkit;
|
||
if (!webkit) return unverified("L4 WebKit", "playwright 를 찾지 못했다");
|
||
let b2;
|
||
try { b2 = await webkit.launch(); }
|
||
catch (e) { return unverified("L4 WebKit", `WebKit 실행 실패 — ${errText(e)}`); }
|
||
try {
|
||
const pg = await b2.newPage();
|
||
await pg.setViewportSize({ width: 390, height: 844 });
|
||
let bad = 0;
|
||
for (const p of CFG.pages) {
|
||
try {
|
||
await pg.goto(pageUrl(p), { waitUntil: "networkidle", timeout: CFG.load.timeoutMs });
|
||
const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||
if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; }
|
||
} catch (e) { fail(`L4 WebKit ${p.name} 로드`, errText(e)); bad++; }
|
||
}
|
||
!bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`);
|
||
} finally {
|
||
await b2.close().catch(() => {});
|
||
}
|
||
};
|
||
|
||
await browserLayer();
|
||
if (!CFG.checks.visual) off("L3 시각 회귀", "checks.visual=false");
|
||
if (!CFG.checks.webkit) off("L4 WebKit", "checks.webkit=false");
|
||
else if (STATIC_ONLY) unverified("L4 WebKit", "--static-only");
|
||
else await webkitLayer();
|
||
|
||
// ── 리포트 ──
|
||
// 줄 머리는 넷이다. 미검증을 PASS 로 찍지 않는다 — 읽는 쪽이 PASS 줄 속의 "SKIP"을 찾아 세야 했다.
|
||
const LABEL = { pass: "PASS ", fail: "FAIL ", unverified: "미검증 ", off: "OFF " };
|
||
const count = (state) => results.filter((r) => r.state === state).length;
|
||
const [passes, fails, unverifieds, offs] = [count("pass"), count("fail"), count("unverified"), count("off")];
|
||
const lines = results.map((r) => `${LABEL[r.state]}${r.name}${r.detail ? " — " + r.detail : ""}`);
|
||
console.log(lines.join("\n"));
|
||
const tally = `충족 ${passes} / 미검증 ${unverifieds} / 실패 ${fails}`;
|
||
const headline = fails
|
||
? `**${fails}건 실패 — 배포 금지**`
|
||
: unverifieds
|
||
? `**검증 미완료 — 미검증 ${unverifieds}건(충족으로 세지 않는다). 아래 "미검증" 줄의 도구·입력을 갖춰 다시 돌리거나, 항목과 이유를 보고에 적는다**`
|
||
: "**전 항목 통과 — 배포 가능**";
|
||
let reportNote = "리포트 미작성";
|
||
if (WRITE_REPORT) {
|
||
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
||
const reportFile = path.join(OUT_DIR, "gate-report.md");
|
||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||
fs.writeFileSync(reportFile, `# 게이트 리포트 — ${stamp}\n\n${headline}\n\n집계: ${tally} (꺼짐 ${offs})\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`);
|
||
reportNote = `${path.relative(CWD, reportFile) || reportFile} 생성`;
|
||
}
|
||
const verdict = fails ? `게이트 실패(${fails})` : unverifieds ? "검증 미완료" : "게이트 통과";
|
||
console.log(`\n${verdict} — ${tally} (꺼짐 ${offs}) — ${reportNote}`);
|
||
process.exit(fails ? 1 : STRICT && unverifieds ? 3 : 0);
|