designpaca/packages/skill/tools/design-gate-contract.test.mjs
Yun Chan 88c11682b1 feat(skill): route fixes, delivery report, composition diversity, platform refresh
- 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
2026-10-06 16:55:38 +09:00

638 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// design-gate 계약 테스트.
//
// 실행 범위는 둘이다.
// pnpm --filter @designpaca/skill test:gate 전부(브라우저 필요). 브라우저가 없으면 건너뛰지 않고 실패한다.
// node design-gate-contract.test.mjs --scope=static 브라우저 없이 확정되는 계약만(인자·설정·L0 정적·모듈 해석).
// 패키지의 `test` 스크립트가 이 범위를 돌린다(GATE_TEST_SCOPE=static 도 같다).
// CI 러너에는 Chrome 이 없을 수 있다(워크플로 어디에도 브라우저 설치·verify 호출이 없다). 그래서 루트
// `pnpm test` 에는 정적 범위만 태우고, 브라우저 범위는 Chrome 이 있는 곳에서 test:gate 로 돌린다.
// static 범위에서 브라우저 계약은 "건너뜀"이 아니라 등록하지 않는다 — CI 는 `# SKIP` 이 한 줄이라도
// 있으면 빌드를 죽이고, 건너뛴 테스트가 통과처럼 보이는 것이 그 규칙이 막으려는 사고다.
// 대신 무엇을 돌리지 않았는지 한 줄로 출력한다.
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, describe, test } from "node:test";
import url from "node:url";
const repo = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "../../..");
const gate = path.join(repo, "apps/site/tools/design-gate.mjs");
const packagedGate = path.join(repo, "packages/skill/tools/design-gate.mjs");
const work = fs.mkdtempSync(path.join(os.tmpdir(), "design-gate-contract-"));
const STATIC_ONLY_SCOPE = process.env.GATE_TEST_SCOPE === "static" || process.argv.includes("--scope=static");
after(() => fs.rmSync(work, { recursive: true, force: true }));
// 게이트는 실행마다 Chrome 을 띄운다(약 4초). 직렬이면 수 분이 걸려 병렬로 돌린다.
const CONCURRENCY = Math.max(2, Math.min(6, os.availableParallelism?.() ?? 4));
const runGate = (args, { cwd, env, script = gate } = {}) => new Promise((resolve) => {
execFile(process.execPath, [script, ...args], { cwd, env: { ...process.env, ...env }, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
resolve({ status: error ? (typeof error.code === "number" ? error.code : 1) : 0, out: String(stdout) + String(stderr) });
});
});
const caseDir = (name) => {
const dir = path.join(work, name);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
const ALL_OFF = { meta: false, contrast: false, stack: false, wrap: false, rhythm: false, scaleMatrix: false, tapTargets: false, leftInset: false, runtime: false, static: false, visual: false, webkit: false, deadCss: false, cssHygiene: false, axe: false };
const RESET = "<style>html { overflow-x: clip; } body { margin: 0; }</style>";
// 픽스처 한 건: 디렉터리 하나에 index.html·설정을 쓰고 그 디렉터리를 cwd 로 게이트를 돌린다.
const fixture = async (name, html, { checks = {}, page = {}, top = {}, args = [], files = {}, env } = {}) => {
const dir = caseDir(name);
fs.writeFileSync(path.join(dir, "index.html"), html.replace("<!doctype html>", "<!doctype html>" + RESET));
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
fs.writeFileSync(path.join(dir, "gate.config.json"), JSON.stringify({
pages: [{ name: "fixture", path: "index.html", h1: "h1", ...page }],
widths: [375, 390],
fontScales: [1, 1.3],
checks: { ...ALL_OFF, ...checks },
...top,
}));
const result = await runGate(args, { cwd: dir, env });
return { ...result, dir };
};
const lineOf = (out, re) => out.split(/\r?\n/).find((line) => re.test(line)) || "";
// ───────────────────────── 브라우저 없이 확정되는 계약 ─────────────────────────
describe("static", { concurrency: CONCURRENCY }, () => {
test("두 게이트 사본은 바이트 동일하다(LF 정규화)", () => {
const normalized = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
assert.equal(normalized(gate), normalized(packagedGate), "apps/site/tools/design-gate.mjs must match packages/skill/tools/design-gate.mjs");
});
test("V-10 --help 는 사용법·종료 코드를 내고 아무것도 쓰지 않는다", async () => {
const dir = caseDir("help");
const r = await runGate(["--help"], { cwd: dir });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /사용법/);
assert.match(r.out, /--out-dir/);
assert.match(r.out, /종료 코드/);
assert.deepEqual(fs.readdirSync(dir), []);
});
test("V-10 모르는 인자는 exit 2", async () => {
const dir = caseDir("unknown-arg");
const r = await runGate(["--nope"], { cwd: dir });
assert.equal(r.status, 2, r.out);
assert.match(r.out, /모르는 인자: --nope/);
});
test("V-10 설정 파일이 없으면 스택 트레이스 없이 exit 2", async () => {
const dir = caseDir("no-config");
const r = await runGate([], { cwd: dir });
assert.equal(r.status, 2, r.out);
assert.match(r.out, /설정 파일을 찾지 못했다/);
assert.doesNotMatch(r.out, /\n\s+at /);
});
test("설정 오류: path·url 둘 다 없는 페이지는 exit 2", async () => {
const dir = caseDir("no-source");
fs.writeFileSync(path.join(dir, "gate.config.json"), JSON.stringify({ pages: [{ name: "nosrc", h1: "h1" }], checks: { meta: false } }));
const r = await runGate([], { cwd: dir });
assert.equal(r.status, 2, r.out);
assert.match(r.out, /path 또는 url/);
});
test("V-10 --config 는 공백형과 등호형을 둘 다 받는다", async () => {
const dir = caseDir("config-forms");
fs.writeFileSync(path.join(dir, "index.html"), "<!doctype html><h1>x</h1>");
fs.writeFileSync(path.join(dir, "other.json"), JSON.stringify({ pages: [{ name: "from-other", path: "index.html" }], checks: ALL_OFF }));
const spaced = await runGate(["--config", "other.json", "--static-only", "--no-report"], { cwd: dir });
assert.match(spaced.out, /브라우저 계층/, spaced.out);
assert.equal(spaced.status, 0, spaced.out);
const equals = await runGate(["--config=other.json", "--static-only", "--no-report"], { cwd: dir });
assert.equal(equals.status, 0, equals.out);
const missingValue = await runGate(["--config"], { cwd: dir });
assert.equal(missingValue.status, 2, missingValue.out);
});
test("V-09 --init 은 기존 설정을 덮어쓰지 않고 exit 2, --force 일 때만 덮어쓴다", async () => {
const dir = caseDir("init");
const file = path.join(dir, "gate.config.json");
const first = await runGate(["--init"], { cwd: dir });
assert.equal(first.status, 0, first.out);
fs.writeFileSync(file, JSON.stringify({ custom: "USER-EDITED" }));
const second = await runGate(["--init"], { cwd: dir });
assert.equal(second.status, 2, second.out);
assert.match(second.out, /--force/);
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).custom, "USER-EDITED");
const forced = await runGate(["--init", "--force"], { cwd: dir });
assert.equal(forced.status, 0, forced.out);
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).custom, undefined);
});
test("V-06·V-07 --init 기본값: 문서의 폭 매트릭스, 뷰 전환·브랜드 가정 없음, 죽은 키 없음", async () => {
const dir = caseDir("init-defaults");
const r = await runGate(["--init", "--config", "custom.json"], { cwd: dir });
assert.equal(r.status, 0, r.out);
const cfg = JSON.parse(fs.readFileSync(path.join(dir, "custom.json"), "utf8"));
assert.deepEqual(cfg.widths, [320, 390, 768, 1440, 1920, 2560]);
assert.equal(cfg.pages[0].views, undefined);
assert.equal(cfg.pages[0].viewAttribute, undefined);
assert.equal(cfg.pages[0].brand, undefined);
// 설정 키는 전부 코드가 읽는다 — 읽히지 않는 키(예전 checks.wrap)는 끄는 것처럼 보이고 꺼지지 않는다
const source = fs.readFileSync(gate, "utf8");
for (const key of Object.keys(cfg.checks)) assert.match(source, new RegExp(`CFG\\.checks\\.${key}\\b`), `checks.${key} 를 코드가 읽지 않는다`);
for (const key of Object.keys(cfg.thresholds)) assert.match(source, new RegExp(`CFG\\.thresholds\\.${key}\\b`), `thresholds.${key} 를 코드가 읽지 않는다`);
});
test("V-01·V-10 브라우저 계층을 못 돌려도 L0 결과와 리포트를 남기고 미검증으로 집계한다", async () => {
const r = await fixture("static-keep", `<!doctype html><link rel="stylesheet" href="main.css"><h1 class="used">x</h1>`, {
checks: { deadCss: true },
files: { "main.css": ".used{color:red}.ghost{color:blue}" },
args: ["--static-only"],
});
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^FAIL {2}fixture 죽은 선택자 1종 — \.ghost/m);
assert.match(r.out, /^미검증 {2}브라우저 계층/m);
const report = fs.readFileSync(path.join(r.dir, "gate-report.md"), "utf8");
assert.match(report, /1건 실패/);
assert.match(report, /집계: 충족 \d+ \/ 미검증 \d+ \/ 실패 1/);
});
test("V-01 미검증이 있으면 '전 항목 통과'라고 쓰지 않고, --strict 는 exit 3", async () => {
const html = `<!doctype html><link rel="stylesheet" href="main.css"><h1 class="used">x</h1>`;
const opts = { checks: { deadCss: true }, files: { "main.css": ".used{color:red}" } };
const loose = await fixture("unverified-loose", html, { ...opts, args: ["--static-only"] });
assert.equal(loose.status, 0, loose.out);
assert.match(loose.out, /^PASS {2}fixture 죽은 선택자/m);
assert.match(loose.out, /검증 미완료 — 충족 1 \/ 미검증 1 \/ 실패 0/);
const report = fs.readFileSync(path.join(loose.dir, "gate-report.md"), "utf8");
assert.match(report, /\*\*검증 미완료/);
assert.doesNotMatch(report, /전 항목 통과/);
const strict = await fixture("unverified-strict", html, { ...opts, args: ["--static-only", "--strict"] });
assert.equal(strict.status, 3, strict.out);
});
test("V-01 설정으로 끈 검사는 PASS 가 아니라 OFF 로 찍는다", async () => {
const r = await fixture("off-state", `<!doctype html><h1>x</h1>`, { args: ["--static-only", "--no-report"] });
assert.match(r.out, /^OFF {3}죽은 선택자 — checks\.deadCss=false/m);
assert.doesNotMatch(r.out, /^PASS .*SKIP/m);
});
test("V-01 로컬 CSS 가 없어 못 잰 정적 검사는 미검증이다", async () => {
const r = await fixture("inline-style", `<!doctype html><style>.ghost{color:#123456}</style><h1>x</h1>`, {
checks: { deadCss: true, cssHygiene: true },
args: ["--static-only", "--no-report"],
});
assert.match(r.out, /^미검증 {2}fixture 죽은 선택자 — 로컬 CSS 없음/m);
assert.match(r.out, /^미검증 {2}fixture 토큰 위생 — 로컬 CSS 없음/m);
});
test("V-11 --no-report·--out-dir: 프로젝트 디렉터리에 쓰지 않는다", async () => {
const none = await fixture("no-report", `<!doctype html><h1>x</h1>`, { args: ["--static-only", "--no-report"] });
assert.equal(fs.existsSync(path.join(none.dir, "gate-report.md")), false, none.out);
const out = caseDir("out-dir-target");
const moved = await fixture("out-dir", `<!doctype html><h1>x</h1>`, { args: ["--static-only", "--out-dir", out] });
assert.equal(fs.existsSync(path.join(moved.dir, "gate-report.md")), false, moved.out);
assert.equal(fs.existsSync(path.join(out, "gate-report.md")), true, moved.out);
});
test("V-12 url(x.woff2)·문자열 속 점은 죽은 선택자가 아니다", async () => {
const r = await fixture("dead-url", `<!doctype html><link rel="stylesheet" href="main.css"><h1 class="title">x</h1>`, {
checks: { deadCss: true },
files: { "main.css": `@font-face{font-family:B;src:url(fonts/Brand.woff2) format("woff2")}.title{background:url(img/hero.avif);content:"a.zip"}a[href$=".pdf"]{color:red}` },
args: ["--static-only", "--no-report"],
});
assert.match(r.out, /^PASS {2}fixture 죽은 선택자/m, r.out);
});
test("V-12 클래스 사용 판정은 부분 문자열이 아니라 토큰 일치다", async () => {
const r = await fixture("dead-substring", `<!doctype html><link rel="stylesheet" href="main.css"><h1 class="title">discard this</h1>`, {
checks: { deadCss: true },
files: { "main.css": ".title{color:red}.card{color:blue}" },
args: ["--static-only", "--no-report"],
});
assert.match(r.out, /^FAIL {2}fixture 죽은 선택자 1종 — \.card/m, r.out);
});
const hygiene = (name, mainCss, tokensCss = ":root{--ink:#111;}") => fixture(name, `<!doctype html><link rel="stylesheet" href="tokens.css"><link rel="stylesheet" href="main.css"><h1>x</h1>`, {
checks: { cssHygiene: true },
files: { "main.css": mainCss, "tokens.css": tokensCss },
args: ["--static-only", "--no-report"],
});
test("V-12 #fade{} ID 선택자·url(#id) 는 색 리터럴이 아니다", async () => {
const r = await hygiene("hygiene-id", `#fade{opacity:0}#abc:hover{opacity:1}.x{filter:url(#bad)}`);
assert.match(r.out, /^PASS {2}fixture 토큰 위생 — 색 0\/0건/m, r.out);
});
test("V-12 세미콜론 없는 마지막 커스텀 프로퍼티는 토큰 선언이다", async () => {
const r = await hygiene("hygiene-last-prop", `.x{color:var(--fg)}`, `:root{--bg:#fff;--fg:#111}`);
assert.match(r.out, /^PASS {2}fixture 토큰 위생 — 색 0\/0건/m, r.out);
});
test("V-12 선언값의 색 리터럴은 여전히 실패한다(세미콜론 없는 마지막 선언 포함)", async () => {
const r = await hygiene("hygiene-literal", `.x{color:#ff0000}.y{background:oklch(0.7 0.1 20)}`);
assert.match(r.out, /^FAIL {2}fixture 토큰 위생 — 색 리터럴 2건 > 계약 0건/m, r.out);
});
test("V-12 반지름: 0·50%·키워드는 리터럴이 아니고, 세미콜론 없는 마지막 선언도 센다", async () => {
const zero = await hygiene("radius-zero", `.a{border-radius:0}.b{border-radius:50%}.c{border-radius:inherit}.d{border-radius:999px}.e{border-radius:var(--r)}`);
assert.match(zero.out, /^PASS {2}fixture 토큰 위생 — .*radius 0종/m, zero.out);
const three = await hygiene("radius-three", `.a{border-radius:4px}.b{border-radius:6px;}.c{border-radius:8px}`);
assert.match(three.out, /^FAIL {2}fixture 토큰 위생 — radius 리터럴 3종/m, three.out);
});
test("V-04 puppeteer-core 는 프로젝트 cwd 의 node_modules 에서 먼저 찾는다", async () => {
// 스킬 설치 경로(프로젝트 밖)의 사본 + 프로젝트에만 있는 puppeteer-core. launch 가 표식 예외를 던지는
// 가짜 모듈이 실제로 불렸는지로 해석 순서를 확인한다. CHROME_PATH 는 존재하는 아무 실행 파일이면 된다.
const skillDir = caseDir("resolve-skill/tools");
const isolated = path.join(skillDir, "design-gate.mjs");
fs.copyFileSync(gate, isolated);
const project = caseDir("resolve-project");
const fake = path.join(project, "node_modules/puppeteer-core");
fs.mkdirSync(fake, { recursive: true });
fs.writeFileSync(path.join(fake, "package.json"), JSON.stringify({ name: "puppeteer-core", version: "0.0.0", main: "index.js" }));
fs.writeFileSync(path.join(fake, "index.js"), `exports.launch = async () => { throw new Error("FAKE_PUPPETEER_FROM_PROJECT"); };\n`);
fs.writeFileSync(path.join(project, "index.html"), "<!doctype html><h1>x</h1>");
fs.writeFileSync(path.join(project, "gate.config.json"), JSON.stringify({ pages: [{ name: "fixture", path: "index.html" }], checks: ALL_OFF }));
const r = await runGate(["--no-report"], { cwd: project, script: isolated, env: { CHROME_PATH: process.execPath } });
assert.match(r.out, /FAKE_PUPPETEER_FROM_PROJECT/, r.out);
assert.match(r.out, /^미검증 {2}브라우저 계층/m, r.out);
assert.equal(r.status, 0, r.out);
});
});
// ───────────────────────── 브라우저가 있어야 확정되는 계약 ─────────────────────────
if (STATIC_ONLY_SCOPE) {
console.log("design-gate-contract: 정적 범위(--scope=static) — 브라우저 계층 계약은 이 실행에 등록하지 않았다(`test:gate` 로 돌린다).");
} else {
describe("browser", { concurrency: CONCURRENCY }, () => {
test("브라우저가 실제로 떴다(없으면 건너뛰지 않고 실패한다)", async () => {
const r = await fixture("sanity", `<!doctype html><h1>Sanity</h1>`, { args: ["--no-report"] });
assert.doesNotMatch(r.out, /^미검증 {2}브라우저 계층/m, "이 계약 테스트는 Chrome(또는 Edge)과 puppeteer-core 가 필요하다. 없으면 통과할 수 없다 — CHROME_PATH 를 지정하거나 --scope=static 으로 정적 범위만 돌린다.\n" + r.out);
assert.match(r.out, /^PASS {2}fixture\/single h1 보임/m, r.out);
assert.equal(r.status, 0, r.out);
});
// ── 제목 ──
const h1Case = (name, html, page = {}) => fixture(name, html, { checks: { scaleMatrix: true }, page });
test("h1: 줄 수 계약이 없으면 여러 줄도 통과", async () => {
const r = await h1Case("flexible", `<!doctype html><style>h1 { width: 9ch; font-size: 2rem; }</style><h1>A flexible display heading</h1>`);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /h1 보임 — [2-9]줄\(줄수 계약 없음\)/);
});
test("h1: h1MaxLines 초과는 실패", async () => {
const r = await h1Case("capped", `<!doctype html><style>h1 { width: 100px; font-size: 2rem; }</style><h1>Heading that must wrap</h1>`, { h1MaxLines: 1 });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /h1 — \d+줄\(최대 1줄\)/);
});
test("h1: 글꼴 확대에서 줄 수가 늘어도 잘리지 않으면 통과", async () => {
const r = await h1Case("expanded", `<!doctype html><style>h1 { width: 260px; font-size: 1.5rem; }</style><h1>Scale tolerant design</h1>`, { h1MaxLines: 1 });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /375@1x:1줄/);
assert.match(r.out, /375@1\.3x:[2-9]줄/);
});
test("h1: 확대 시 가로 클리핑은 실패", async () => {
const r = await h1Case("clipped", `<!doctype html><style>.clip { width: 150px; overflow: hidden; } h1 { white-space: nowrap; font-size: 1.5rem; }</style><div class="clip"><h1>Heading that is clipped when enlarged</h1></div>`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /제목 텍스트 클리핑/);
});
test("h1: 확대 시 세로 클리핑은 실패", async () => {
const r = await h1Case("vertically-clipped", `<!doctype html><style>.clip { width: 300px; height: 32px; overflow-y: hidden; } h1 { margin: 0; font-size: 1.5rem; }</style><div class="clip"><h1>Title that becomes vertically clipped</h1></div>`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /제목 텍스트 클리핑 .+\(y\)/);
});
test("h1: 없는 선택자는 실패", async () => {
const r = await h1Case("missing", `<!doctype html><h1>Existing heading</h1>`, { h1: ".missing" });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /h1 선택자 없음 \(\.missing\)/);
});
test("h1: 빈 제목은 실패", async () => {
const r = await h1Case("empty", `<!doctype html><h1></h1>`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /h1 — 텍스트 없음/);
});
// ── 터치 타깃 ──
const tap = (name, body, { policy, thresholds } = {}) => fixture(name, `<!doctype html>${body}`, {
checks: { tapTargets: true },
top: { ...(policy ? { tapTargetPolicy: policy } : {}), ...(thresholds ? { thresholds } : {}) },
});
const TAP_BASE = `<style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; width: 20px; height: 20px; }`;
test("터치: 떨어진 20px 버튼 둘은 WCAG 간격 예외로 통과", async () => {
const r = await tap("tap-isolated", `${TAP_BASE} #a { left: 20px; } #b { left: 300px; }</style><h1>Isolated tap targets</h1><button id="a" class="btn">A</button><button id="b" class="btn">B</button>`, { policy: "wcag" });
assert.equal(r.status, 0, r.out);
});
test("터치: 붙은 20px 버튼 둘은 WCAG 2.5.8 실패", async () => {
const r = await tap("tap-adjacent", `${TAP_BASE} #a { left: 20px; } #b { left: 42px; }</style><h1>Adjacent tap targets</h1><button id="a" class="btn">A</button><button id="b" class="btn">B</button>`, { policy: "wcag" });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /WCAG 2\.5\.8/);
});
const CONTRACT_ONLY = `<style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; left: 20px; width: 36px; height: 36px; }</style><h1>Contract only target</h1><button class="btn">C</button>`;
test("터치: 36px 버튼은 contract 정책에서 계약 44px 실패", async () => {
const r = await tap("tap-contract-fail", CONTRACT_ONLY);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /계약 44px/);
});
test("터치: 36px 버튼은 wcag 정책에서 계약 참고로만 남는다", async () => {
const r = await tap("tap-contract-note", CONTRACT_ONLY, { policy: "wcag" });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /계약 참고/);
assert.match(r.out, /계약 44px/);
});
test("V-05 붙어 있는 16px 아이콘 링크는 인라인 예외가 아니다 — WCAG 2.5.8 실패", async () => {
const r = await tap("tap-icon-links", `<style>nav { display: flex; gap: 2px; margin-top: 300px; } nav a { display: block; width: 16px; height: 16px; background: #000; }</style><h1>Icon links</h1><nav><a href="#a" aria-label="A"></a><a href="#b" aria-label="B"></a><a href="#c" aria-label="C"></a></nav>`, { policy: "wcag" });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /WCAG 2\.5\.8: "A" 16×16/);
});
test("V-05 문장 밖에 홀로 선 display:inline 링크도 인라인 예외가 아니다", async () => {
const r = await tap("tap-standalone-inline", `<style>ul { margin: 300px 0 0; padding: 0; list-style: none; } li { line-height: 18px; font-size: 12px; }</style><h1>Footer links</h1><ul><li><a href="#a">약관</a></li><li><a href="#b">개인정보</a></li><li><a href="#c">문의</a></li></ul>`, { policy: "wcag" });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /WCAG 2\.5\.8: "약관"/);
});
test("V-05 문장 속 인라인 링크는 WCAG 층에서 빠지고 계약 24px 참고로만 남는다", async () => {
const r = await tap("tap-sentence-link", `<style>p { margin-top: 300px; font-size: 12px; line-height: 1.5; }</style><h1>Sentence link</h1><p>본문 문장 안에 <a href="#a">링크 하나</a>와 <strong><a href="#b">강조 링크</a></strong>가 있다.</p>`, { policy: "wcag" });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /계약 24px: "링크 하나" \d+×\d+\(인라인\)/);
assert.match(r.out, /계약 24px: "강조 링크" \d+×\d+\(인라인\)/);
});
const SR_ONLY = `.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }`;
test("V-05 시각적으로 숨긴 스킵 링크·입력은 터치 타깃이 아니다", async () => {
const r = await tap("tap-sr-only", `<style>${SR_ONLY} .big { display: block; margin-top: 300px; width: 200px; height: 48px; }</style><a class="sr-only" href="#main">본문으로 건너뛰기</a><h1 id="main">Skip link</h1><input class="sr-only" type="text" aria-label="hidden"><button class="big">확인</button>`);
assert.equal(r.status, 0, r.out);
assert.doesNotMatch(r.out, /1×1/);
});
test("V-05 label 이 감싼 숨긴 체크박스는 label 영역으로 잰다", async () => {
const r = await tap("tap-label-checkbox", `<style>${SR_ONLY} label { display: flex; align-items: center; margin-top: 300px; width: 240px; min-height: 48px; }</style><h1>Custom checkbox</h1><label><input class="sr-only" type="checkbox"><span>알림 받기</span></label>`);
assert.equal(r.status, 0, r.out);
const small = await tap("tap-label-small", `<style>${SR_ONLY} label { display: flex; margin-top: 300px; width: 30px; height: 30px; }</style><h1>Custom checkbox</h1><label><input class="sr-only" type="checkbox"><span>A</span></label>`);
assert.equal(small.status, 1, small.out);
assert.match(small.out, /계약 44px: "A" 30×30/);
});
// ── meta ──
const META_HEAD = `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Design gate meta fixture page</title><meta name="description" content="${"D".repeat(80)}"><meta property="og:title" content="t"><meta property="og:image" content="https://example.com/o.png"><meta name="twitter:card" content="summary"><link rel="icon" href="data:,">`;
const meta = (name, body, head = "") => {
const dir = caseDir(name);
fs.writeFileSync(path.join(dir, "index.html"), `<!doctype html><html lang="en"><head>${META_HEAD}${head}</head><body style="margin:0">${body}</body></html>`);
fs.writeFileSync(path.join(dir, "gate.config.json"), JSON.stringify({ pages: [{ name: "fixture", path: "index.html", h1: "h1" }], widths: [375, 390], checks: { ...ALL_OFF, meta: true } }));
return runGate(["--no-report"], { cwd: dir });
};
test("meta: alt=\"\" 는 장식 선언이라 통과", async () => {
const r = await meta("alt-decorative", `<h1>Heading</h1><img src="a.png" alt="">`);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /alt 누락 0 · 장식\(alt=""\) 1/);
});
test("meta: alt 속성이 없으면 실패", async () => {
const r = await meta("alt-missing", `<h1>Heading</h1><img src="b.png">`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /img alt 없음 1개/);
});
test("V-02 theme-color 정합은 표기가 달라도 같은 색이면 통과, 다른 색이면 실패", async () => {
const same = await meta("theme-same", `<h1>Heading</h1>`, `<meta name="theme-color" content="#FFF"><style>body { background: oklch(1 0 0); }</style>`);
assert.equal(same.status, 0, same.out);
const stale = await meta("theme-stale", `<h1>Heading</h1>`, `<meta name="theme-color" content="#f4efe6"><style>body { background: #101214; }</style>`);
assert.equal(stale.status, 1, stale.out);
assert.match(stale.out, /theme-color #f4efe6 ≠ 실제 배경/);
});
// ── 브랜드 줄바꿈 ──
test("브랜드: 텍스트 블록 둘(로크업)이 각자 1줄이면 통과", async () => {
const r = await fixture("brand-two-blocks", `<!doctype html><style>.brand { display: inline-grid; min-height: 44px; align-content: center; line-height: 1; } .brand > span { font-size: 26px; } .brand small { margin-top: 4px; font-size: 14px; display: block; }</style><h1>Fixture heading</h1><a class="brand"><span>Foo</span><small>Bar</small></a>`, { checks: { wrap: true }, page: { brand: ".brand" } });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /브랜드 줄바꿈 없음 — 텍스트 블록 2개, 각 1줄/);
});
const BRAND_WRAPPED = `<!doctype html><style>.brand { display: inline-block; width: 60px; font-size: 20px; }</style><h1>Fixture heading</h1><a class="brand">Shrink Wrap Text</a>`;
test("브랜드: 실제로 수축하면 실패", async () => {
const r = await fixture("brand-wrapped", BRAND_WRAPPED, { checks: { wrap: true }, page: { brand: ".brand" } });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /브랜드 줄바꿈 — "Shrink Wrap.*"\s*\d+줄\(수축 의심\)/);
});
test("C-07 checks.wrap=false 는 브랜드 줄바꿈 검사를 실제로 끈다", async () => {
const r = await fixture("brand-off", BRAND_WRAPPED, { checks: { wrap: false, scaleMatrix: true }, page: { brand: ".brand" } });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^OFF {3}fixture 브랜드 줄바꿈 — checks\.wrap=false/m);
});
test("V-07 설정에 적힌 브랜드 선택자가 없으면 실패", async () => {
const r = await fixture("brand-missing", `<!doctype html><h1>Fixture heading</h1>`, { checks: { wrap: true }, page: { brand: ".brand" } });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /브랜드 줄바꿈 — 선택자 매칭 없음 \(\.brand\)/);
});
// ── 뷰 전환 ──
test("V-07 없는 뷰 이름은 PASS 가 아니라 뷰 전환 실패다", async () => {
const r = await fixture("view-missing", `<!doctype html><button data-view="main">main</button><h1>Views</h1>`, { page: { viewAttribute: "data-view", views: ["main", "settigns-typo"] } });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^PASS {2}fixture\/main 오버플로/m);
assert.match(r.out, /^FAIL {2}fixture\/settigns-typo 뷰 전환 — 전환 버튼 없음 \(\[data-view="settigns-typo"\]\)/m);
assert.doesNotMatch(r.out, /^PASS {2}fixture\/settigns-typo/m);
assert.equal(r.out.match(/settigns-typo 뷰 전환/g).length, 1, "같은 뷰 전환 실패는 한 번만 보고한다");
});
test("V-07 viewAttribute 없이 뷰 이름만 적으면 실패", async () => {
const r = await fixture("view-no-attr", `<!doctype html><h1>Views</h1>`, { page: { views: ["main"] } });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^FAIL {2}fixture\/main 뷰 전환 — viewAttribute 없음/m);
});
// ── 대비 ──
const contrast = (name, body) => fixture(name, `<!doctype html>${body}`, { checks: { contrast: true } });
test("V-02 oklch()·color-mix() 글자색을 건너뛰지 않는다", async () => {
const oklch = await contrast("contrast-oklch", `<h1 style="color:#111">Title</h1><p style="color: oklch(0.92 0 0)">low contrast oklch</p>`);
assert.equal(oklch.status, 1, oklch.out);
assert.match(oklch.out, /^FAIL {2}fixture\/single 대비 — "low cont" 1\.\d+:1/m);
const mix = await contrast("contrast-color-mix", `<h1 style="color:#111">Title</h1><p style="color: color-mix(in srgb, white 90%, black)">low contrast mix</p>`);
assert.equal(mix.status, 1, mix.out);
assert.match(mix.out, /"low cont" 1\.\d+:1/);
const lab = await contrast("contrast-lab-bg", `<h1 style="color:#111">Title</h1><div style="background: lab(20 0 0)"><p style="color: oklch(0.3 0 0)">dark on dark</p></div>`);
assert.equal(lab.status, 1, lab.out);
});
test("V-02 정상 대비의 oklch·color-mix 는 통과하고 검사 요소 수를 남긴다", async () => {
const r = await contrast("contrast-oklch-ok", `<h1 style="color: oklch(0.2 0 0)">Title</h1><div style="background: color-mix(in srgb, black 90%, white)"><p style="color: oklch(0.97 0 0)">light on dark</p></div>`);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^PASS {2}fixture\/single 대비 AA — 검사 요소 2개/m);
});
test("V-03 html 에 건 배경을 읽는다(거짓 실패·거짓 통과 둘 다)", async () => {
const ok = await contrast("contrast-html-bg-ok", `<style>html { background: #111; }</style><h1 style="color:#fff">Title</h1><p style="color:#fff">white on dark root</p>`);
assert.equal(ok.status, 0, ok.out);
const bad = await contrast("contrast-html-bg-bad", `<style>html { background: #111; }</style><h1 style="color:#fff">Title</h1><p style="color:#333">dark on dark root</p>`);
assert.equal(bad.status, 1, bad.out);
assert.match(bad.out, /"dark on " 1\.\d+:1/);
});
test("V-03 조상 opacity 를 합성한다", async () => {
const r = await contrast("contrast-opacity", `<h1 style="color:#111">Title</h1><div style="opacity:.15"><p style="color:#000">faded body text</p></div>`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /"faded bo" 1\.\d+:1/);
const opaqueInside = await contrast("contrast-opacity-group", `<h1 style="color:#111">Title</h1><div style="opacity:.15"><p style="color:#000;background:#fff">faded group text</p></div>`);
assert.equal(opaqueInside.status, 1, opaqueInside.out);
});
test("V-03 반투명 배경은 아래에서 위로 합성한다", async () => {
const r = await contrast("contrast-translucent", `<h1 style="color:#111">Title</h1><div style="background: rgb(0 0 0 / 0.6)"><p style="color:#fff">white on 60% black</p></div><div style="background:#000"><div style="background: rgb(255 255 255 / 0.085)"><p style="color:#fff">glass tint</p></div></div>`);
assert.equal(r.status, 0, r.out);
});
test("V-03 이미지·그라디언트 배경 위 텍스트는 통과가 아니라 대비 미검증이다", async () => {
const r = await contrast("contrast-gradient", `<h1 style="color:#111">Title</h1><div style="background-image: linear-gradient(#111, #111)"><p style="color:#222">on gradient</p><p style="color:#222;background:#fff">own opaque bg</p></div>`);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^미검증 {2}fixture\/single 대비 — 대비 미검증 1요소\(이미지·그라디언트 배경 1/m);
assert.match(r.out, /검증 미완료/);
});
test("V-03 position:fixed 요소의 직계 텍스트도 잰다(G13)", async () => {
const r = await contrast("contrast-fixed", `<h1 style="color:#111">Title</h1><div style="position:fixed;top:0;right:0;color:#eee">fixed pale text</div>`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /"fixed pa" 1\.\d+:1/);
});
test("대비: 시각적으로 숨긴 텍스트·투명 텍스트는 대상이 아니다", async () => {
const r = await contrast("contrast-hidden", `<style>${SR_ONLY}</style><h1 style="color:#111">Title</h1><span class="sr-only" style="color:#fff">screen reader only</span><p style="opacity:0;color:#fff">invisible</p><p style="visibility:hidden;color:#fff">hidden</p>`);
assert.equal(r.status, 0, r.out);
});
test("대비: 비활성 컨트롤의 흐린 글자는 WCAG 예외라 제외하고 수를 남긴다", async () => {
const disabled = await contrast("contrast-disabled", `<h1 style="color:#111">Title</h1><button disabled style="opacity:.4;color:#111;background:#fff;border:0">다음 주</button><fieldset disabled style="opacity:.45;border:0"><label style="color:#111">곁들임 <select><option>없음</option></select></label></fieldset>`);
assert.equal(disabled.status, 0, disabled.out);
assert.match(disabled.out, /대비 AA — 검사 요소 1개 · 비활성 컨트롤 2요소 제외/);
const active = await contrast("contrast-faded-active", `<h1 style="color:#111">Title</h1><button style="opacity:.4;color:#111;background:#fff;border:0">다음 주</button>`);
assert.equal(active.status, 1, active.out);
});
// ── 미검증 집계·url 전용 ──
const urlOnlyArgs = (name, body) => {
const dir = caseDir(name);
fs.writeFileSync(path.join(dir, "gate.config.json"), JSON.stringify({
pages: [{ name: "fixture", url: `data:text/html,${encodeURIComponent(body)}`, h1: "h1" }],
widths: [375, 390],
checks: { ...ALL_OFF, deadCss: true, cssHygiene: true },
}));
return dir;
};
test("V-01 url 전용 페이지의 파일 검사는 미검증이고, 리포트는 검증 미완료다", async () => {
const dir = urlOnlyArgs("url-only", `<!doctype html><h1>URL only fixture</h1>`);
const r = await runGate([], { cwd: dir });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^미검증 {2}fixture 죽은 선택자 — path 없음\(url 전용 페이지\)/m);
assert.match(r.out, /^미검증 {2}fixture 토큰 위생 — path 없음\(url 전용 페이지\)/m);
assert.match(r.out, /^PASS {2}fixture\/single h1 보임/m);
assert.match(r.out, /검증 미완료 — 충족 2 \/ 미검증 2 \/ 실패 0/);
const report = fs.readFileSync(path.join(dir, "gate-report.md"), "utf8");
assert.doesNotMatch(report, /전 항목 통과/);
assert.match(report, /집계: 충족 2 \/ 미검증 2 \/ 실패 0/);
const strict = await runGate(["--strict", "--no-report"], { cwd: dir });
assert.equal(strict.status, 3, strict.out);
});
test("V-01 미검증이 없으면 전 항목 통과로 쓴다", async () => {
const r = await fixture("all-pass", `<!doctype html><h1>All pass</h1>`);
assert.equal(r.status, 0, r.out);
const report = fs.readFileSync(path.join(r.dir, "gate-report.md"), "utf8");
assert.match(report, /\*\*전 항목 통과 — 배포 가능\*\*/);
assert.match(report, /집계: 충족 2 \/ 미검증 0 \/ 실패 0/);
const strict = await fixture("all-pass-strict", `<!doctype html><h1>All pass</h1>`, { args: ["--strict"] });
assert.equal(strict.status, 0, strict.out);
});
// ── 읽기 전용 실행 ──
test("V-11 --url 은 설정 파일 없이 돌고 프로젝트 디렉터리에 아무것도 쓰지 않는다", async () => {
const project = caseDir("url-mode-project");
fs.writeFileSync(path.join(project, "keep.txt"), "x");
const page = `data:text/html,${encodeURIComponent(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>t</title></head><body><h1>Review target</h1></body></html>`)}`;
const r = await runGate(["--url", page], { cwd: project });
assert.match(r.out, /url1\/single h1 보임/, r.out);
assert.match(r.out, /리포트 미작성/);
assert.deepEqual(fs.readdirSync(project), ["keep.txt"]);
const out = caseDir("url-mode-out");
const withOut = await runGate([`--url=${page}`, "--out-dir", out], { cwd: project });
assert.match(withOut.out, /url1\/single h1 보임/, withOut.out);
assert.deepEqual(fs.readdirSync(project), ["keep.txt"]);
assert.equal(fs.existsSync(path.join(out, "gate-report.md")), true);
});
// ── 페이지 단위 격리 ──
test("V-10 한 페이지가 열리지 않아도 나머지 페이지 결과를 버리지 않는다", async () => {
const dir = caseDir("page-isolation");
fs.writeFileSync(path.join(dir, "index.html"), `<!doctype html>${RESET}<h1>Second page</h1>`);
fs.writeFileSync(path.join(dir, "gate.config.json"), JSON.stringify({
pages: [{ name: "down", url: "http://127.0.0.1:9/" }, { name: "up", path: "index.html" }],
widths: [375, 390],
checks: ALL_OFF,
load: { timeoutMs: 8000 },
}));
const r = await runGate([], { cwd: dir });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^FAIL {2}down 로드 — /m);
assert.match(r.out, /^PASS {2}up\/single h1 보임/m);
assert.doesNotMatch(r.out, /\n\s+at /);
assert.equal(fs.existsSync(path.join(dir, "gate-report.md")), true);
});
// ── 런타임 ──
test("V-17 pageerror·깨진 이미지는 실패로 보고한다", async () => {
const broken = await fixture("runtime-broken-img", `<!doctype html><h1>Broken image</h1><img src="missing.png" alt="">`, { checks: { runtime: true } });
assert.equal(broken.status, 1, broken.out);
assert.match(broken.out, /^FAIL {2}fixture 런타임 — .*깨진 이미지 1/m);
const thrown = await fixture("runtime-pageerror", `<!doctype html><h1>Page error</h1><script>throw new Error("boom-from-page")</script>`, { checks: { runtime: true } });
assert.equal(thrown.status, 1, thrown.out);
assert.match(thrown.out, /^FAIL {2}fixture 런타임 — .*boom-from-page/m);
const clean = await fixture("runtime-clean", `<!doctype html><h1>Clean</h1><img alt="" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==">`, { checks: { runtime: true } });
assert.equal(clean.status, 0, clean.out);
assert.match(clean.out, /^PASS {2}fixture 런타임/m);
});
// ── axe (선택 도구: cwd → 스크립트 위치 순으로 해석) ──
test("V-04 axe-core 도 cwd 에 없으면 스크립트 위치에서 해석해 실행한다", async () => {
const dir = caseDir("axe-run");
fs.writeFileSync(path.join(dir, "index.html"), `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Axe fixture</title></head><body><main><h1>Axe</h1><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="></main></body></html>`);
fs.writeFileSync(path.join(dir, "gate.config.json"), JSON.stringify({ pages: [{ name: "fixture", path: "index.html" }], widths: [390], checks: { ...ALL_OFF, axe: "auto" } }));
const r = await runGate(["--no-report"], { cwd: dir });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^FAIL {2}fixture\/single axe — .*image-alt/m);
});
// ── 시각 회귀 ──
test("V-19 기준 화면이 없던 첫 실행은 '일치'가 아니라 미검증이고, 기준은 --out-dir 아래에 쓴다", async () => {
const out = caseDir("visual-out");
const first = await fixture("visual-first", `<!doctype html><h1>Visual</h1>`, { checks: { visual: true }, args: ["--out-dir", out] });
assert.match(lineOf(first.out, /L3 시각 회귀/), /^미검증 {2}L3 시각 회귀 — 기준 없음\(2장 생성/, first.out);
assert.equal(first.status, 0, first.out);
assert.equal(fs.existsSync(path.join(out, "__gate__/baseline/fixture-390.png")), true);
assert.equal(fs.existsSync(path.join(first.dir, "__gate__")), false);
const second = await runGate(["--out-dir", out], { cwd: first.dir });
assert.match(lineOf(second.out, /L3 시각 회귀/), /^PASS {2}L3 시각 회귀 — 2화면 일치/, second.out);
});
});
}