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
This commit is contained in:
parent
9bcb45cbd3
commit
88c11682b1
51 changed files with 5834 additions and 2259 deletions
|
|
@ -1,203 +1,638 @@
|
|||
// 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 { spawnSync } from "node:child_process";
|
||||
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");
|
||||
|
||||
const normalizedSource = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
||||
assert.equal(
|
||||
normalizedSource(gate),
|
||||
normalizedSource(packagedGate),
|
||||
"apps/site/tools/design-gate.mjs must match packages/skill/tools/design-gate.mjs",
|
||||
);
|
||||
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
||||
|
||||
const configFor = (page, extra = {}) => ({
|
||||
pages: [{ name: "fixture", path: page, h1: "h1", ...extra }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1, 1.3],
|
||||
checks: {
|
||||
meta: false,
|
||||
contrast: false,
|
||||
stack: false,
|
||||
rhythm: false,
|
||||
scaleMatrix: true,
|
||||
tapTargets: false,
|
||||
leftInset: false,
|
||||
deadCss: false,
|
||||
cssHygiene: false,
|
||||
},
|
||||
// 게이트는 실행마다 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 run = (name, html, extra = {}) => {
|
||||
const page = path.join(work, `${name}.html`);
|
||||
const config = path.join(work, `${name}.json`);
|
||||
fs.writeFileSync(page, html.replace("<!doctype html>", "<!doctype html><style>html { overflow-x: clip; } body { margin: 0; }</style>"));
|
||||
fs.writeFileSync(config, JSON.stringify(configFor(page, extra)));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
const caseDir = (name) => {
|
||||
const dir = path.join(work, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
// 터치 타깃 두 층(WCAG 2.5.8 하드 게이트 / 44px 프로젝트 계약) 전용 설정 —
|
||||
// configFor 는 extra 를 page 객체에 병합하므로, 최상위 tapTargetPolicy 를 얹으려면 따로 조립한다.
|
||||
const runTap = (name, html, { policy, thresholds } = {}) => {
|
||||
const page = path.join(work, `${name}.html`);
|
||||
const config = path.join(work, `${name}.json`);
|
||||
fs.writeFileSync(page, html.replace("<!doctype html>", "<!doctype html><style>html { overflow-x: clip; } body { margin: 0; }</style>"));
|
||||
fs.writeFileSync(config, JSON.stringify({
|
||||
pages: [{ name: "fixture", path: page, h1: "h1" }],
|
||||
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],
|
||||
checks: { meta: false, contrast: false, stack: false, rhythm: false, scaleMatrix: false, tapTargets: true, leftInset: false, deadCss: false, cssHygiene: false },
|
||||
...(policy ? { tapTargetPolicy: policy } : {}),
|
||||
...(thresholds ? { thresholds } : {}),
|
||||
fontScales: [1, 1.3],
|
||||
checks: { ...ALL_OFF, ...checks },
|
||||
...top,
|
||||
}));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
const result = await runGate(args, { cwd: dir, env });
|
||||
return { ...result, dir };
|
||||
};
|
||||
|
||||
// SEO/meta 검사만 켠 고정 헤더 — alt 판정(G1) 외 다른 meta 실패로 노이즈가 섞이지 않게
|
||||
// title/description/viewport/lang/charset/OG/favicon 계약을 전부 채운다.
|
||||
const runMeta = (name, bodyHtml) => {
|
||||
const page = path.join(work, `${name}.html`);
|
||||
const config = path.join(work, `${name}.json`);
|
||||
const html = `<!doctype html><html lang="en"><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:,"></head><body style="margin:0"><h1>Heading</h1>${bodyHtml}</body></html>`;
|
||||
fs.writeFileSync(page, html);
|
||||
fs.writeFileSync(config, JSON.stringify({
|
||||
pages: [{ name: "fixture", path: page, h1: "h1" }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1],
|
||||
checks: { meta: true, contrast: false, stack: false, rhythm: false, scaleMatrix: false, tapTargets: false, leftInset: false, deadCss: false, cssHygiene: false, axe: false },
|
||||
}));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
const lineOf = (out, re) => out.split(/\r?\n/).find((line) => re.test(line)) || "";
|
||||
|
||||
// path 없이 url(data: URL)만 있는 페이지 — G3 크래시 재현·SKIP 확인용
|
||||
const runUrlOnly = (name, bodyHtml) => {
|
||||
const config = path.join(work, `${name}.json`);
|
||||
const dataUrl = `data:text/html,${encodeURIComponent(bodyHtml)}`;
|
||||
fs.writeFileSync(config, JSON.stringify({
|
||||
pages: [{ name: "fixture", url: dataUrl, h1: "h1" }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1],
|
||||
checks: { meta: false, contrast: false, stack: false, rhythm: false, scaleMatrix: false, tapTargets: false, leftInset: false, static: false, deadCss: true, cssHygiene: true, axe: false },
|
||||
}));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
// ───────────────────────── 브라우저 없이 확정되는 계약 ─────────────────────────
|
||||
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");
|
||||
});
|
||||
|
||||
const outputOf = (result) => result.stdout + result.stderr;
|
||||
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), []);
|
||||
});
|
||||
|
||||
try {
|
||||
const flexible = run("flexible", `<!doctype html><style>h1 { width: 9ch; font-size: 2rem; }</style><h1>A flexible display heading</h1>`);
|
||||
assert.equal(flexible.status, 0, outputOf(flexible));
|
||||
assert.match(outputOf(flexible), /h1 보임 — [2-9]줄\(줄수 계약 없음\)/);
|
||||
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/);
|
||||
});
|
||||
|
||||
const capped = run("capped", `<!doctype html><style>h1 { width: 100px; font-size: 2rem; }</style><h1>Heading that must wrap</h1>`, { h1MaxLines: 1 });
|
||||
assert.equal(capped.status, 1, outputOf(capped));
|
||||
assert.match(outputOf(capped), /h1 — \d+줄\(최대 1줄\)/);
|
||||
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 /);
|
||||
});
|
||||
|
||||
const expanded = run("expanded", `<!doctype html><style>h1 { width: 260px; font-size: 1.5rem; }</style><h1>Scale tolerant design</h1>`, { h1MaxLines: 1 });
|
||||
assert.equal(expanded.status, 0, outputOf(expanded));
|
||||
assert.match(outputOf(expanded), /375@1x:1줄/);
|
||||
assert.match(outputOf(expanded), /375@1\.3x:[2-9]줄/);
|
||||
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/);
|
||||
});
|
||||
|
||||
const clipped = run("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(clipped.status, 1, outputOf(clipped));
|
||||
assert.match(outputOf(clipped), /제목 텍스트 클리핑/);
|
||||
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);
|
||||
});
|
||||
|
||||
const verticallyClipped = run("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(verticallyClipped.status, 1, outputOf(verticallyClipped));
|
||||
assert.match(outputOf(verticallyClipped), /제목 텍스트 클리핑 .+\(y\)/);
|
||||
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);
|
||||
});
|
||||
|
||||
const missing = run("missing", `<!doctype html><h1>Existing heading</h1>`, { h1: ".missing" });
|
||||
assert.equal(missing.status, 1, outputOf(missing));
|
||||
assert.match(outputOf(missing), /h1 선택자 없음 \(\.missing\)/);
|
||||
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} 를 코드가 읽지 않는다`);
|
||||
});
|
||||
|
||||
const empty = run("empty", `<!doctype html><h1></h1>`);
|
||||
assert.equal(empty.status, 1, outputOf(empty));
|
||||
assert.match(outputOf(empty), /h1 — 텍스트 없음/);
|
||||
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/);
|
||||
});
|
||||
|
||||
const isolated = runTap(
|
||||
"tap-isolated",
|
||||
`<!doctype html><style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; width: 20px; height: 20px; } #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(isolated.status, 0, outputOf(isolated));
|
||||
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);
|
||||
});
|
||||
|
||||
const adjacent = runTap(
|
||||
"tap-adjacent",
|
||||
`<!doctype html><style>h1 { position: absolute; top: 0; left: 0; } .btn { position: absolute; top: 400px; width: 20px; height: 20px; } #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(adjacent.status, 1, outputOf(adjacent));
|
||||
assert.match(outputOf(adjacent), /WCAG 2\.5\.8/);
|
||||
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);
|
||||
});
|
||||
|
||||
const contractFail = runTap(
|
||||
"tap-contract-fail",
|
||||
`<!doctype html><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>`,
|
||||
);
|
||||
assert.equal(contractFail.status, 1, outputOf(contractFail));
|
||||
assert.match(outputOf(contractFail), /계약 44px/);
|
||||
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);
|
||||
});
|
||||
|
||||
const contractNote = runTap(
|
||||
"tap-contract-note",
|
||||
`<!doctype html><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>`,
|
||||
{ policy: "wcag" },
|
||||
);
|
||||
assert.equal(contractNote.status, 0, outputOf(contractNote));
|
||||
assert.match(outputOf(contractNote), /계약 참고/);
|
||||
assert.match(outputOf(contractNote), /계약 44px/);
|
||||
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);
|
||||
});
|
||||
|
||||
const axeSkip = run("axe-skip", `<!doctype html><style>h1 { width: 9ch; font-size: 2rem; }</style><h1>A flexible display heading</h1>`);
|
||||
assert.equal(axeSkip.status, 0, outputOf(axeSkip));
|
||||
assert.match(outputOf(axeSkip), /미검증/);
|
||||
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);
|
||||
});
|
||||
|
||||
// G1: alt="" 는 장식 선언 — alt 속성 자체가 없을 때만 실패
|
||||
const altDecorative = runMeta("alt-decorative", `<img src="a.png" alt="">`);
|
||||
assert.equal(altDecorative.status, 0, outputOf(altDecorative));
|
||||
assert.match(outputOf(altDecorative), /alt 누락 0 · 장식\(alt=""\) 1/);
|
||||
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 altMissing = runMeta("alt-missing", `<img src="b.png">`);
|
||||
assert.equal(altMissing.status, 1, outputOf(altMissing));
|
||||
assert.match(outputOf(altMissing), /img alt 없음 1개/);
|
||||
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"],
|
||||
});
|
||||
|
||||
// G2: 브랜드가 텍스트 블록 여러 개(로크업)로 나뉘어 있어도 블록마다 1줄이면 통과 —
|
||||
// 전체 Range 를 한 번에 재던 예전 방식은 조각달 ".wordmark"(inline-grid span+small)에서
|
||||
// "3줄"로 오카운트했다.
|
||||
const brandTwoBlocks = run(
|
||||
"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>`,
|
||||
{ brand: ".brand" },
|
||||
);
|
||||
assert.equal(brandTwoBlocks.status, 0, outputOf(brandTwoBlocks));
|
||||
assert.match(outputOf(brandTwoBlocks), /브랜드 줄바꿈 없음 — 텍스트 블록 2개, 각 1줄/);
|
||||
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);
|
||||
});
|
||||
|
||||
// 회귀 금지: 텍스트 노드가 하나뿐인 브랜드가 실제로 수축(줄바꿈)하면 여전히 실패해야 한다
|
||||
const brandWrapped = run(
|
||||
"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>`,
|
||||
{ brand: ".brand" },
|
||||
);
|
||||
assert.equal(brandWrapped.status, 1, outputOf(brandWrapped));
|
||||
assert.match(outputOf(brandWrapped), /브랜드 줄바꿈 — "Shrink Wrap.*"\s*\d+줄\(수축 의심\)/);
|
||||
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);
|
||||
});
|
||||
|
||||
// G3: url 만 있고 path 가 없는 페이지 — 파일 기반 검사는 크래시 없이 SKIP, 렌더 검사는 그대로 통과
|
||||
const urlOnly = runUrlOnly("url-only", `<!doctype html><h1>URL only fixture</h1>`);
|
||||
assert.equal(urlOnly.status, 0, outputOf(urlOnly));
|
||||
assert.match(outputOf(urlOnly), /fixture 죽은 선택자 — SKIP — path 없음\(url 전용 페이지\)/);
|
||||
assert.match(outputOf(urlOnly), /fixture 토큰 위생 — SKIP — path 없음\(url 전용 페이지\)/);
|
||||
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);
|
||||
});
|
||||
|
||||
// path·url 둘 다 없으면 설정 오류로 exit 2
|
||||
const noSourceConfig = path.join(work, "no-source.json");
|
||||
fs.writeFileSync(noSourceConfig, JSON.stringify({ pages: [{ name: "nosrc", h1: "h1" }], checks: { meta: false } }));
|
||||
const noSource = spawnSync(process.execPath, [gate, `--config=${noSourceConfig}`], { cwd: work, encoding: "utf8" });
|
||||
assert.equal(noSource.status, 2, outputOf(noSource));
|
||||
assert.match(outputOf(noSource), /path 또는 url/);
|
||||
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);
|
||||
});
|
||||
|
||||
console.log("design-gate-contract: 18 passed");
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue