designpaca/packages/skill/tools/design-gate-contract.test.mjs
Yun Chan 6805fb2be7 feat(skill): absorb external design skills, restore interview gate, add review route
- Restore the step-0 interview as a mechanical gate the skill explicitly
  depends on; add harness.md (per-harness question tools, limits,
  fallbacks) and brief-interview.md (slots, question cards, rounds).
- Add 10 reference docs absorbed from external design skills
  (accessibility, interaction-feel, elevation, color, icons, product-copy,
  component-systems, critique, change-review, print-email) and extend
  existing references.
- Add a review-only route and two hard-gate clauses (truncated content
  reachability, three-flashes limit).
- design-gate: split tap targets into WCAG 2.5.8 and 44px contract layers,
  run axe-core when available, and fix false positives found on a real
  site (decorative alt="", stacked wordmark line count, url-only pages).
- lint-skill: fail if the interview gate section or its links disappear.
- Ship agents/openai.yaml and THIRD_PARTY_NOTICES.md.
2026-09-24 13:26:03 +09:00

203 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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 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",
);
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,
},
});
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" });
};
// 터치 타깃 두 층(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" }],
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 } : {}),
}));
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
};
// 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" });
};
// 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" });
};
const outputOf = (result) => result.stdout + result.stderr;
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]줄\(줄수 계약 없음\)/);
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줄\)/);
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]줄/);
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), /제목 텍스트 클리핑/);
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\)/);
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\)/);
const empty = run("empty", `<!doctype html><h1></h1>`);
assert.equal(empty.status, 1, outputOf(empty));
assert.match(outputOf(empty), /h1 — 텍스트 없음/);
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));
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/);
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/);
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/);
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), /미검증/);
// 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/);
const altMissing = runMeta("alt-missing", `<img src="b.png">`);
assert.equal(altMissing.status, 1, outputOf(altMissing));
assert.match(outputOf(altMissing), /img alt 없음 1개/);
// 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줄/);
// 회귀 금지: 텍스트 노드가 하나뿐인 브랜드가 실제로 수축(줄바꿈)하면 여전히 실패해야 한다
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+줄\(수축 의심\)/);
// 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 전용 페이지\)/);
// 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/);
console.log("design-gate-contract: 18 passed");
} finally {
fs.rmSync(work, { recursive: true, force: true });
}