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("", "")); 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("", "")); 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 = `Design gate meta fixture page

Heading

${bodyHtml}`; 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", `

A flexible display heading

`); assert.equal(flexible.status, 0, outputOf(flexible)); assert.match(outputOf(flexible), /h1 보임 — [2-9]줄\(줄수 계약 없음\)/); const capped = run("capped", `

Heading that must wrap

`, { h1MaxLines: 1 }); assert.equal(capped.status, 1, outputOf(capped)); assert.match(outputOf(capped), /h1 — \d+줄\(최대 1줄\)/); const expanded = run("expanded", `

Scale tolerant design

`, { 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", `

Heading that is clipped when enlarged

`); assert.equal(clipped.status, 1, outputOf(clipped)); assert.match(outputOf(clipped), /제목 텍스트 클리핑/); const verticallyClipped = run("vertically-clipped", `

Title that becomes vertically clipped

`); assert.equal(verticallyClipped.status, 1, outputOf(verticallyClipped)); assert.match(outputOf(verticallyClipped), /제목 텍스트 클리핑 .+\(y\)/); const missing = run("missing", `

Existing heading

`, { h1: ".missing" }); assert.equal(missing.status, 1, outputOf(missing)); assert.match(outputOf(missing), /h1 선택자 없음 \(\.missing\)/); const empty = run("empty", `

`); assert.equal(empty.status, 1, outputOf(empty)); assert.match(outputOf(empty), /h1 — 텍스트 없음/); const isolated = runTap( "tap-isolated", `

Isolated tap targets

`, { policy: "wcag" }, ); assert.equal(isolated.status, 0, outputOf(isolated)); const adjacent = runTap( "tap-adjacent", `

Adjacent tap targets

`, { policy: "wcag" }, ); assert.equal(adjacent.status, 1, outputOf(adjacent)); assert.match(outputOf(adjacent), /WCAG 2\.5\.8/); const contractFail = runTap( "tap-contract-fail", `

Contract only target

`, ); assert.equal(contractFail.status, 1, outputOf(contractFail)); assert.match(outputOf(contractFail), /계약 44px/); const contractNote = runTap( "tap-contract-note", `

Contract only target

`, { policy: "wcag" }, ); assert.equal(contractNote.status, 0, outputOf(contractNote)); assert.match(outputOf(contractNote), /계약 참고/); assert.match(outputOf(contractNote), /계약 44px/); const axeSkip = run("axe-skip", `

A flexible display heading

`); assert.equal(axeSkip.status, 0, outputOf(axeSkip)); assert.match(outputOf(axeSkip), /미검증/); // G1: alt="" 는 장식 선언 — alt 속성 자체가 없을 때만 실패 const altDecorative = runMeta("alt-decorative", ``); assert.equal(altDecorative.status, 0, outputOf(altDecorative)); assert.match(outputOf(altDecorative), /alt 누락 0 · 장식\(alt=""\) 1/); const altMissing = runMeta("alt-missing", ``); 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", `

Fixture heading

FooBar`, { brand: ".brand" }, ); assert.equal(brandTwoBlocks.status, 0, outputOf(brandTwoBlocks)); assert.match(outputOf(brandTwoBlocks), /브랜드 줄바꿈 없음 — 텍스트 블록 2개, 각 1줄/); // 회귀 금지: 텍스트 노드가 하나뿐인 브랜드가 실제로 수축(줄바꿈)하면 여전히 실패해야 한다 const brandWrapped = run( "brand-wrapped", `

Fixture heading

Shrink Wrap Text`, { 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", `

URL only fixture

`); 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 }); }