// 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 = "";
// 픽스처 한 건: 디렉터리 하나에 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("", "" + 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"), "
x ");
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", `x `, {
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 = `x `;
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", `x `, { 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", `x `, {
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", `x `, { 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", `x `, { 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", `x `, {
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", `discard this `, {
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, `x `, {
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"), "x ");
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", `Sanity `, { 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", `A flexible display heading `);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /h1 보임 — [2-9]줄\(줄수 계약 없음\)/);
});
test("h1: h1MaxLines 초과는 실패", async () => {
const r = await h1Case("capped", `Heading that must wrap `, { h1MaxLines: 1 });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /h1 — \d+줄\(최대 1줄\)/);
});
test("h1: 글꼴 확대에서 줄 수가 늘어도 잘리지 않으면 통과", async () => {
const r = await h1Case("expanded", `Scale tolerant design `, { 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", `
Heading that is clipped when enlarged `);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /제목 텍스트 클리핑/);
});
test("h1: 확대 시 세로 클리핑은 실패", async () => {
const r = await h1Case("vertically-clipped", `
Title that becomes vertically clipped `);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /제목 텍스트 클리핑 .+\(y\)/);
});
test("h1: 없는 선택자는 실패", async () => {
const r = await h1Case("missing", `Existing heading `, { h1: ".missing" });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /h1 선택자 없음 \(\.missing\)/);
});
test("h1: 빈 제목은 실패", async () => {
const r = await h1Case("empty", ` `);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /h1 — 텍스트 없음/);
});
// ── 터치 타깃 ──
const tap = (name, body, { policy, thresholds } = {}) => fixture(name, `${body}`, {
checks: { tapTargets: true },
top: { ...(policy ? { tapTargetPolicy: policy } : {}), ...(thresholds ? { thresholds } : {}) },
});
const TAP_BASE = `Isolated tap targets A B `, { 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; }Adjacent tap targets A B `, { policy: "wcag" });
assert.equal(r.status, 1, r.out);
assert.match(r.out, /WCAG 2\.5\.8/);
});
const CONTRACT_ONLY = `Contract only target C `;
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", `Icon links `, { 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", `Footer links `, { 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", `Sentence link 본문 문장 안에 링크 하나 와 강조 링크 가 있다.
`, { 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", `본문으로 건너뛰기 Skip link 확인 `);
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", `Custom checkbox 알림 받기 `);
assert.equal(r.status, 0, r.out);
const small = await tap("tap-label-small", `Custom checkbox A `);
assert.equal(small.status, 1, small.out);
assert.match(small.out, /계약 44px: "A" 30×30/);
});
// ── meta ──
const META_HEAD = `Design gate meta fixture page `;
const meta = (name, body, head = "") => {
const dir = caseDir(name);
fs.writeFileSync(path.join(dir, "index.html"), `${META_HEAD}${head}${body}`);
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", `Heading `);
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", `Heading `);
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", `Heading `, ` `);
assert.equal(same.status, 0, same.out);
const stale = await meta("theme-stale", `Heading `, ` `);
assert.equal(stale.status, 1, stale.out);
assert.match(stale.out, /theme-color #f4efe6 ≠ 실제 배경/);
});
// ── 브랜드 줄바꿈 ──
test("브랜드: 텍스트 블록 둘(로크업)이 각자 1줄이면 통과", async () => {
const r = await fixture("brand-two-blocks", `Fixture heading Foo Bar `, { checks: { wrap: true }, page: { brand: ".brand" } });
assert.equal(r.status, 0, r.out);
assert.match(r.out, /브랜드 줄바꿈 없음 — 텍스트 블록 2개, 각 1줄/);
});
const BRAND_WRAPPED = `Fixture heading Shrink Wrap Text `;
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", `Fixture heading `, { 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", `main Views `, { 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", `Views `, { 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, `${body}`, { checks: { contrast: true } });
test("V-02 oklch()·color-mix() 글자색을 건너뛰지 않는다", async () => {
const oklch = await contrast("contrast-oklch", `Title low contrast oklch
`);
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", `Title low contrast mix
`);
assert.equal(mix.status, 1, mix.out);
assert.match(mix.out, /"low cont" 1\.\d+:1/);
const lab = await contrast("contrast-lab-bg", `Title `);
assert.equal(lab.status, 1, lab.out);
});
test("V-02 정상 대비의 oklch·color-mix 는 통과하고 검사 요소 수를 남긴다", async () => {
const r = await contrast("contrast-oklch-ok", `Title `);
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", `Title white on dark root
`);
assert.equal(ok.status, 0, ok.out);
const bad = await contrast("contrast-html-bg-bad", `Title dark on dark root
`);
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", `Title `);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /"faded bo" 1\.\d+:1/);
const opaqueInside = await contrast("contrast-opacity-group", `Title `);
assert.equal(opaqueInside.status, 1, opaqueInside.out);
});
test("V-03 반투명 배경은 아래에서 위로 합성한다", async () => {
const r = await contrast("contrast-translucent", `Title `);
assert.equal(r.status, 0, r.out);
});
test("V-03 이미지·그라디언트 배경 위 텍스트는 통과가 아니라 대비 미검증이다", async () => {
const r = await contrast("contrast-gradient", `Title `);
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", `Title fixed pale text
`);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /"fixed pa" 1\.\d+:1/);
});
test("대비: 시각적으로 숨긴 텍스트·투명 텍스트는 대상이 아니다", async () => {
const r = await contrast("contrast-hidden", `Title screen reader only invisible
hidden
`);
assert.equal(r.status, 0, r.out);
});
test("대비: 비활성 컨트롤의 흐린 글자는 WCAG 예외라 제외하고 수를 남긴다", async () => {
const disabled = await contrast("contrast-disabled", `Title 다음 주 곁들임 없음 `);
assert.equal(disabled.status, 0, disabled.out);
assert.match(disabled.out, /대비 AA — 검사 요소 1개 · 비활성 컨트롤 2요소 제외/);
const active = await contrast("contrast-faded-active", `Title 다음 주 `);
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", `URL only fixture `);
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", `All pass `);
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", `All pass `, { 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(`t Review target `)}`;
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"), `${RESET}Second page `);
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", `Broken image `, { 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", `Page error `, { 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", `Clean `, { 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"), `Axe fixture Axe `);
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", `Visual `, { 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);
});
});
}