This commit is contained in:
parent
cb3c323c9f
commit
9529e3747c
29 changed files with 8906 additions and 1326 deletions
48
apps/site/tools/build-showcase-assets.mjs
Normal file
48
apps/site/tools/build-showcase-assets.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { build } from "esbuild";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const entry = path.join(root, "src", "showcases", "twentyoz-core.js");
|
||||
const outDir = path.join(root, "public", "work", "twentyoz-corp", "assets");
|
||||
const outputFile = path.join(outDir, "core.iife.js");
|
||||
const check = process.argv.includes("--check");
|
||||
|
||||
const result = await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [entry],
|
||||
outfile: outputFile,
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
globalName: "TwentyozCore",
|
||||
target: ["es2020"],
|
||||
minify: true,
|
||||
treeShaking: true,
|
||||
legalComments: "eof",
|
||||
sourcemap: false,
|
||||
write: false,
|
||||
});
|
||||
|
||||
const output = result.outputFiles?.find((item) => path.resolve(item.path) === path.resolve(outputFile));
|
||||
if (!output) throw new Error("twentyoz core bundle output not found");
|
||||
const generated = Buffer.from(output.contents);
|
||||
const gzipBytes = zlib.gzipSync(generated, { level: 9 }).byteLength;
|
||||
const maxGzipBytes = 200 * 1024;
|
||||
if (gzipBytes > maxGzipBytes) {
|
||||
throw new Error(`twentyoz WebGL bundle ${gzipBytes}B gzip exceeds ${maxGzipBytes}B budget`);
|
||||
}
|
||||
|
||||
if (check) {
|
||||
if (!fs.existsSync(outputFile)) throw new Error("twentyoz core bundle is missing; run npm run build:showcase-assets");
|
||||
const current = fs.readFileSync(outputFile);
|
||||
if (!current.equals(generated)) throw new Error("twentyoz core bundle is stale; run npm run build:showcase-assets");
|
||||
} else {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(outputFile, generated);
|
||||
}
|
||||
|
||||
console.log(`twentyoz core ${generated.byteLength}B raw · ${gzipBytes}B gzip · budget ${maxGzipBytes}B`);
|
||||
517
apps/site/tools/design-gate.mjs
Normal file
517
apps/site/tools/design-gate.mjs
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
#!/usr/bin/env node
|
||||
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
|
||||
//
|
||||
// 조사 기반 7계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션 + 죽은 선택자·토큰 위생) + L2(불변식·SEO/meta·theme-color 정합·터치타깃) + L3(시각회귀·옵션) + L4(WebKit·옵션).
|
||||
// 프로젝트 고유의 L1(단위)·L5(탐색)·L6(시나리오 E2E)는 각 프로젝트의 calc/테스트로 보강한다.
|
||||
//
|
||||
// 사용:
|
||||
// node design-gate.mjs --init # 설정파일 게이트 초안 생성
|
||||
// node design-gate.mjs # gate.config.json 기준 실행
|
||||
// node design-gate.mjs --update-baseline # 시각 기준화면 갱신(검증된 배포 후에만)
|
||||
//
|
||||
// 철학: 없는 도구는 SKIP(실패 아님), 검사 가능한 것은 전부 검사(실패면 exit 1).
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const cfgFile = path.resolve(process.cwd(), argv.includes("--init") ? "gate.config.json" : (argv.find((a) => a.startsWith("--config=")) || "gate.config.json").replace("--config=", ""));
|
||||
|
||||
const DEFAULT_CFG = {
|
||||
pages: [
|
||||
{
|
||||
name: "app",
|
||||
path: "index.html",
|
||||
viewAttribute: "data-view",
|
||||
views: ["main"],
|
||||
h1: "h1",
|
||||
brand: ".brand",
|
||||
},
|
||||
],
|
||||
widths: [320, 375, 390, 768, 1024, 1440],
|
||||
fontScales: [1, 1.3],
|
||||
thresholds: { contrastNormal: 4.5, contrastLarge: 3, visualDiffPct: 0.1, stackLines: 3, radiusLiterals: 3, tapTargetMin: 44, inlineTargetMin: 24, leftInsetMin: 12 },
|
||||
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, tapTargets: true, leftInset: true, static: false, visual: false, webkit: false, deadCss: true, cssHygiene: true },
|
||||
deadCssIgnore: ["is-"], // 동적으로 조립되는 클래스 접두사(is-${type} 등). 오탐 방지 — 프로젝트마다 추가한다
|
||||
chromePath: null,
|
||||
};
|
||||
|
||||
if (argv.includes("--init")) {
|
||||
fs.writeFileSync(cfgFile, JSON.stringify(DEFAULT_CFG, null, 2) + "\n");
|
||||
console.log("gate.config.json 초안 생성 — pages/views 를 프로젝트에 맞게 고치세요.");
|
||||
console.log("package.json: \"scripts\": { \"verify\": \"node <이 스크립트 경로>\" } 등록을 권장합니다.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const _user = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
|
||||
// checks·thresholds 는 깊은 병합 — 새 검사·임계값이 기본 켜지도록(실측: 프로젝트 구형 설정이 새 기본값을 지우고 "≥undefinedpx"를 찍었다)
|
||||
const CFG = { ...DEFAULT_CFG, ..._user, checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) }, thresholds: { ...DEFAULT_CFG.thresholds, ...(_user.thresholds || {}) } };
|
||||
const UPDATE = argv.includes("--update-baseline");
|
||||
const results = [];
|
||||
const pass = (name, detail = "") => results.push({ ok: true, name, detail });
|
||||
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
|
||||
const skip = (name, detail) => results.push({ ok: true, name, detail: "SKIP — " + detail });
|
||||
|
||||
// ── 브라우저 확보 (puppeteer-core + 크롬 경로 자동 탐색) ──
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.CHROME_PATH,
|
||||
CFG.chromePath,
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
].filter(Boolean);
|
||||
const CHROME = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
|
||||
|
||||
const pageUrl = (p) => (p.url ? p.url : url.pathToFileURL(path.resolve(process.cwd(), p.path)).href);
|
||||
|
||||
// ── L0 정적 (옵션) ──
|
||||
if (CFG.checks.static) {
|
||||
const css = spawnSync("npx", ["stylelint", "**/*.css"], { shell: true, encoding: "utf8" });
|
||||
css.status === 0 ? pass("L0 stylelint") : fail("L0 stylelint", (css.stdout || "").slice(0, 200));
|
||||
const html = spawnSync("npx", ["html-validate", ...CFG.pages.map((p) => p.path)], { shell: true, encoding: "utf8" });
|
||||
html.status === 0 ? pass("L0 html-validate") : fail("L0 html-validate", (html.stdout || "").slice(0, 200));
|
||||
} else skip("L0 정적", "checks.static=false");
|
||||
|
||||
// ── L0 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
|
||||
// 컴포넌트 제거 시 CSS 만 남아 12종의 미사용 선택자가 쌓였고, 토큰 밖 상수색 8건이 눈 검사를 통과했다) ──
|
||||
const pageDir = (p) => path.dirname(path.resolve(process.cwd(), p.path));
|
||||
const cssFilesOf = (p) => {
|
||||
const out = [];
|
||||
let html = "";
|
||||
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
|
||||
for (const tag of html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/g)) {
|
||||
const href = /href=["']([^"']+)["']/.exec(tag[0])?.[1];
|
||||
if (href && !/^https?:/.test(href)) {
|
||||
const f = path.resolve(pageDir(p), href.split("?")[0]);
|
||||
if (fs.existsSync(f)) out.push(f);
|
||||
}
|
||||
}
|
||||
// @import 로 딸려오는 로컬 파일(토큰 등)까지 따라간다
|
||||
for (const f of [...out]) {
|
||||
try {
|
||||
for (const im of fs.readFileSync(f, "utf8").matchAll(/@import\s+(?:url\()?["']?([^"')]+)["']?\)?/g)) {
|
||||
if (!/^https?:/.test(im[1])) {
|
||||
const g = path.resolve(path.dirname(f), im[1]);
|
||||
if (fs.existsSync(g) && !out.includes(g)) out.push(g);
|
||||
}
|
||||
}
|
||||
} catch { /* 읽기 실패는 아래 검사가 각자 보고 */ }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (CFG.checks.deadCss) {
|
||||
const ignore = CFG.deadCssIgnore || [];
|
||||
for (const p of CFG.pages) {
|
||||
let html = "";
|
||||
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); }
|
||||
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; }
|
||||
const files = cssFilesOf(p);
|
||||
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
|
||||
const dead = new Set();
|
||||
for (const f of files) {
|
||||
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
|
||||
const cls = m[1];
|
||||
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
|
||||
if (!html.includes(cls)) dead.add(cls);
|
||||
}
|
||||
}
|
||||
dead.size
|
||||
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
|
||||
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS 전 클래스 사용 중(무시 ${ignore.length}접두사)`);
|
||||
}
|
||||
} else skip("죽은 선택자", "checks.deadCss=false");
|
||||
|
||||
if (CFG.checks.cssHygiene) {
|
||||
for (const p of CFG.pages) {
|
||||
const files = cssFilesOf(p);
|
||||
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
|
||||
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다
|
||||
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f)));
|
||||
const others = files.filter((f) => !tokenFiles.includes(f));
|
||||
const hexes = []; const radii = new Set();
|
||||
for (const f of others) {
|
||||
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
|
||||
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
|
||||
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
|
||||
}
|
||||
const bad = [];
|
||||
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`);
|
||||
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`);
|
||||
bad.length
|
||||
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : ""))
|
||||
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`);
|
||||
}
|
||||
} else skip("토큰 위생", "checks.cssHygiene=false");
|
||||
|
||||
// ── 브라우저 계층 ──
|
||||
if (!CHROME) {
|
||||
console.log("chrome/puppeteer-core 없음 — CHROME_PATH 를 설정하세요.");
|
||||
process.exit(1);
|
||||
}
|
||||
let puppeteer;
|
||||
try { puppeteer = await import("puppeteer-core"); }
|
||||
catch { console.log("puppeteer-core 미설치 — npm i -D puppeteer-core 필요"); process.exit(1); }
|
||||
|
||||
const UTILS = `
|
||||
window.__lines = (sel) => {
|
||||
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
|
||||
if (!el) return -1;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
|
||||
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
|
||||
const ls = [];
|
||||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||||
return ls.length;
|
||||
};
|
||||
`;
|
||||
|
||||
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
|
||||
for (const p of CFG.pages) {
|
||||
const href = pageUrl(p);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.evaluate(UTILS);
|
||||
|
||||
// ── SEO / meta (조사 기반 체크리스트) ──
|
||||
if (CFG.checks.meta) {
|
||||
const meta = await page.evaluate(() => {
|
||||
const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; };
|
||||
const title = document.title.trim();
|
||||
const desc = get('meta[name="description"]');
|
||||
const og = (k) => get(`meta[property="og:${k}"]`);
|
||||
const imgs = [...document.querySelectorAll("img")];
|
||||
return {
|
||||
title, titleLen: title.length,
|
||||
desc, descLen: desc ? desc.length : 0,
|
||||
viewport: get('meta[name="viewport"]'),
|
||||
lang: document.documentElement.getAttribute("lang"),
|
||||
charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"),
|
||||
canonical: get('link[rel="canonical"]', "href"),
|
||||
ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"),
|
||||
twitter: get('meta[name="twitter:card"]'),
|
||||
robots: get('meta[name="robots"]'),
|
||||
favicon: !!document.querySelector('link[rel~="icon"]'),
|
||||
themeColor: get('meta[name="theme-color"]'),
|
||||
bodyBg: (() => {
|
||||
// 프리셋 전환에서 브랜드 표면이 누락되면 theme-color 만 옛 값으로 남는다 — 실제 배경과 비교해 잡는다
|
||||
const b = getComputedStyle(document.body).backgroundColor;
|
||||
return b && b !== "rgba(0, 0, 0, 0)" ? b : getComputedStyle(document.documentElement).backgroundColor;
|
||||
})(),
|
||||
h1Count: document.querySelectorAll("h1").length,
|
||||
imgNoAlt: imgs.filter((i) => !i.getAttribute("alt")).length,
|
||||
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].every((s) => { try { JSON.parse(s.textContent); return true; } catch { return false; } }),
|
||||
jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length,
|
||||
};
|
||||
});
|
||||
const m = [];
|
||||
if (!meta.title) m.push("title 없음");
|
||||
else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`);
|
||||
if (!meta.desc) m.push("description 없음");
|
||||
else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`);
|
||||
if (!meta.viewport) m.push("viewport 없음");
|
||||
if (!meta.lang) m.push("html lang 없음");
|
||||
if (!meta.charset) m.push("charset 없음");
|
||||
if (meta.ogTitle === null) m.push("og:title 없음");
|
||||
if (meta.ogImage === null) m.push("og:image 없음");
|
||||
if (meta.twitter === null) m.push("twitter:card 없음");
|
||||
if (!meta.favicon) m.push("favicon 없음");
|
||||
if (meta.themeColor && meta.bodyBg) {
|
||||
const hexOf = (v) => {
|
||||
const mm = /rgba?\((\d+),(\d+),(\d+)/.exec(v.replace(/\s+/g, ""));
|
||||
return mm ? "#" + [1, 2, 3].map((i) => (+mm[i]).toString(16).padStart(2, "0")).join("") : v.replace(/\s+/g, "").toLowerCase();
|
||||
};
|
||||
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
|
||||
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
|
||||
}
|
||||
if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`);
|
||||
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
|
||||
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
|
||||
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
|
||||
m.length ? fail(`${p.name} SEO/meta`, m.join(" · ")) : pass(`${p.name} SEO/meta`, `title ${meta.titleLen}자·desc ${meta.descLen}자·OG✓·alt ${meta.imgNoAlt}결`);
|
||||
}
|
||||
|
||||
// ── 각 뷰 불변식 ──
|
||||
const goView = async (v) => {
|
||||
if (!p.viewAttribute || v === "single") return;
|
||||
await page.evaluate((attr, name) => {
|
||||
const btn = document.querySelector(`[${attr}="${name}"]`);
|
||||
btn && btn.click();
|
||||
}, p.viewAttribute, v);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
};
|
||||
|
||||
for (const v of p.views || ["single"]) {
|
||||
await goView(v);
|
||||
// 오버플로 — 전 폭
|
||||
let overs = [];
|
||||
for (const w of CFG.widths) {
|
||||
await page.setViewport({ width: w, height: 900 });
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
if (over > 0) overs.push(`+${over}px@${w}`);
|
||||
}
|
||||
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
|
||||
|
||||
// h1 1줄
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
|
||||
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음"));
|
||||
|
||||
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
|
||||
if (CFG.checks.stack) {
|
||||
const stacked = await page.evaluate((limit) => {
|
||||
const out = [];
|
||||
document.querySelectorAll("body *").forEach((el) => {
|
||||
if (!el.offsetParent || el.querySelector("*")) return;
|
||||
const txt = el.textContent.trim();
|
||||
if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10));
|
||||
});
|
||||
return [...new Set(out)].slice(0, 5);
|
||||
}, CFG.thresholds.stackLines);
|
||||
stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`);
|
||||
}
|
||||
|
||||
// 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다
|
||||
// (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙)
|
||||
if (CFG.checks.rhythm) {
|
||||
const rhythm = await page.evaluate(() => {
|
||||
const groups = {};
|
||||
document.querySelectorAll("body *").forEach((el) => {
|
||||
if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return;
|
||||
const cls = el.classList[0];
|
||||
const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls;
|
||||
const r = el.getBoundingClientRect();
|
||||
(groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement });
|
||||
});
|
||||
const issues = [];
|
||||
for (const [key, items] of Object.entries(groups)) {
|
||||
if (items.length < 4) continue;
|
||||
const spread = (v) => Math.max(...v) - Math.min(...v);
|
||||
const cls = key.split("|")[1];
|
||||
const hUniform = spread(items.map((i) => i.h)) <= 4;
|
||||
const txtUniform = spread(items.map((i) => i.t.length)) <= 1;
|
||||
const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도
|
||||
const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도
|
||||
// 1) 트랙 폭: 숫자 라벨·빈 셀·한 축 정렬 라벨은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다.
|
||||
// 한국어 라벨은 글자수가 같아도 폭이 달라지므로, 한 축 정렬 또는 숫자 전용일 때만 검사한다(오탐 방지).
|
||||
const wSpread = spread(items.map((i) => i.w));
|
||||
const alignedCol = spread(items.map((i) => i.x)) < 3;
|
||||
if (hUniform && wSpread > 6 && (isDigits || isEmpty)) { // 숫자 라벨·빈 셀만 — 한국어 라벨은 내용 폭차가 자연스럽다
|
||||
issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`);
|
||||
}
|
||||
// 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다
|
||||
if (spread(items.map((i) => i.x)) < 3 && hUniform) {
|
||||
const ys = items.map((i) => i.y).sort((a, b) => a - b);
|
||||
const deltas = ys.slice(1).map((y, i) => y - ys[i]);
|
||||
if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`);
|
||||
} else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) {
|
||||
const xs2 = items.map((i) => i.x).sort((a, b) => a - b);
|
||||
const d2 = xs2.slice(1).map((x, i) => x - xs2[i]);
|
||||
if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`);
|
||||
}
|
||||
}
|
||||
return issues.slice(0, 4);
|
||||
});
|
||||
rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙");
|
||||
}
|
||||
|
||||
// 대비
|
||||
if (CFG.checks.contrast) {
|
||||
const bad = await page.evaluate((cn, cl) => {
|
||||
const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
||||
const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; };
|
||||
const blend = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 });
|
||||
const seen = new Set(); const out = [];
|
||||
document.querySelectorAll("body *").forEach((el) => {
|
||||
if (!el.offsetParent) return;
|
||||
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
|
||||
const cs = getComputedStyle(el);
|
||||
const fg = rgb(cs.color); if (!fg) return;
|
||||
let node = el; const stack = [];
|
||||
while (node && node !== document.documentElement) {
|
||||
const c = rgb(getComputedStyle(node).backgroundColor);
|
||||
if (c && c.a >= 0.95) { stack.unshift(c); break; }
|
||||
if (c && c.a > 0) stack.unshift(c);
|
||||
node = node.parentElement;
|
||||
}
|
||||
let bg = { r: 255, g: 255, b: 255, a: 1 };
|
||||
for (const s of stack) bg = blend(s, bg);
|
||||
const fgb = blend(fg, bg);
|
||||
const l1 = lum(fgb), l2 = lum(bg);
|
||||
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
|
||||
const size = parseFloat(cs.fontSize); const w = +cs.fontWeight || 400;
|
||||
const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn;
|
||||
if (ratio < need) {
|
||||
const k = cs.color + Math.round(size);
|
||||
if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); }
|
||||
}
|
||||
});
|
||||
return out.slice(0, 4);
|
||||
}, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge);
|
||||
bad.length ? fail(`${p.name}/${v} 대비`, bad.join(",")) : pass(`${p.name}/${v} 대비 AA`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
|
||||
if (CFG.checks.scaleMatrix) {
|
||||
const bad = [];
|
||||
for (const w of [375, 390]) {
|
||||
for (const s of CFG.fontScales) {
|
||||
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(UTILS);
|
||||
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
|
||||
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
|
||||
if (l > 1) bad.push(`${w}@${s}x:${l}줄`);
|
||||
}
|
||||
}
|
||||
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`);
|
||||
// 브랜드 1줄
|
||||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(UTILS);
|
||||
if (p.brand) {
|
||||
// 동일 셀렉터가 여러 개(사이드바+탑바 등)면 보이는 것을 잰다 — display:none 의 rect 는 0
|
||||
const bl = await page.evaluate((sel) => {
|
||||
const els = [...document.querySelectorAll(sel)];
|
||||
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
|
||||
return window.__lines(el);
|
||||
}, p.brand);
|
||||
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 터치 타깃 (모바일 — HIG 44pt·M3 48dp, WCAG 2.5.8 인라인 최소 24px.
|
||||
// 실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는 보고로 시작된 검사.
|
||||
// 기준: 컨트롤(button·input·role) 44px — 본문 흐름 속 텍스트 링크(a)·예외 선택자는 24px) ──
|
||||
if (CFG.checks.tapTargets) {
|
||||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(UTILS);
|
||||
for (const v of p.views || ["single"]) {
|
||||
await goView(v);
|
||||
const bad = await page.evaluate((min, inlineMin, inlineSel) => {
|
||||
const out = [];
|
||||
document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab'], [role='link'], [role='switch'], [role='checkbox']").forEach((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden") return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
const inline = cs.display === "inline" || (el.tagName === "A" && el.hasAttribute("href")) || (inlineSel || []).some((s) => el.matches(s));
|
||||
const need = inline ? inlineMin : min;
|
||||
if (Math.min(r.width, r.height) < need) {
|
||||
const label = el.textContent.trim().slice(0, 8) || el.getAttribute("aria-label") || el.tagName.toLowerCase();
|
||||
out.push(`"${label}" ${Math.round(r.width)}×${Math.round(r.height)}${inline ? "(인라인)" : ""}`);
|
||||
}
|
||||
});
|
||||
return [...new Set(out)].slice(0, 5);
|
||||
}, CFG.thresholds.tapTargetMin, CFG.thresholds.inlineTargetMin, CFG.tapTargetsInline || []);
|
||||
bad.length ? fail(`${p.name}/${v} 터치타깃`, bad.join(" · ")) : pass(`${p.name}/${v} 터치타깃`, `컨트롤 ≥${CFG.thresholds.tapTargetMin}px·인라인 ≥${CFG.thresholds.inlineTargetMin}px`);
|
||||
}
|
||||
} else skip("터치타깃", "checks.tapTargets=false");
|
||||
|
||||
// ── 왼쪽 인셋(모바일) — 오버플로 검사는 오른쪽만 본다.
|
||||
// 실측 사고: 제목 16px vs 앱바·카드 24px 혼재가 모든 게이트를 통과했고
|
||||
// 사용자에게 '가장자리에 붙어 깨졌다'고 보고됐다. 텍스트 노드 Range 로 글자 시작점을 잰다
|
||||
// (전폭 컨테이너의 보더박스가 아니라. display:none 의 rect=0 은 보임 검사로 제외). ──
|
||||
if (CFG.checks.leftInset) {
|
||||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await page.goto(href, { waitUntil: "networkidle0" });
|
||||
for (const v of p.views || ["single"]) {
|
||||
await goView(v);
|
||||
const inset = await page.evaluate(() => {
|
||||
let min = null;
|
||||
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let n;
|
||||
while ((n = walk.nextNode())) {
|
||||
if (!n.textContent.trim() || !n.parentElement) continue;
|
||||
const cs = getComputedStyle(n.parentElement);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || cs.position === "fixed") continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(n);
|
||||
const r = range.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 4 || r.left < 0) continue;
|
||||
const l = Math.round(r.left);
|
||||
if (min === null || l < min) min = l;
|
||||
}
|
||||
return min;
|
||||
});
|
||||
inset === null
|
||||
? skip(`${p.name}/${v} 왼쪽 인셋`, "텍스트 없음")
|
||||
: inset >= CFG.thresholds.leftInsetMin
|
||||
? pass(`${p.name}/${v} 왼쪽 인셋`, `최소 텍스트 시작 ${inset}px`)
|
||||
: fail(`${p.name}/${v} 왼쪽 인셋`, `텍스트가 ${inset}px에서 시작 — 기준 ≥${CFG.thresholds.leftInsetMin}px(가장자리에 붙어 보인다)`);
|
||||
}
|
||||
} else skip("왼쪽 인셋", "checks.leftInset=false");
|
||||
}
|
||||
|
||||
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
|
||||
if (CFG.checks.visual) {
|
||||
try {
|
||||
const { default: pixelmatch } = await import("pixelmatch");
|
||||
const { PNG } = await import("pngjs");
|
||||
const BASE = path.resolve(process.cwd(), "__gate__/baseline");
|
||||
fs.mkdirSync(BASE, { recursive: true });
|
||||
let diffs = 0, n = 0;
|
||||
for (const p of CFG.pages) {
|
||||
for (const w of [390, 1440]) {
|
||||
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
|
||||
await page.goto(pageUrl(p), { waitUntil: "networkidle0" });
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const name = `${p.name}-${w}.png`;
|
||||
const buf = await page.screenshot();
|
||||
const cur = PNG.sync.read(buf);
|
||||
const bf = path.join(BASE, name);
|
||||
if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); continue; }
|
||||
const base = PNG.sync.read(fs.readFileSync(bf));
|
||||
if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; }
|
||||
const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 });
|
||||
const pct = (cnt / (base.width * base.height)) * 100;
|
||||
n++;
|
||||
if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); }
|
||||
}
|
||||
}
|
||||
!diffs && pass("L3 시각 회귀", `${n}화면 ${UPDATE ? "기준갱신" : "일치"}`);
|
||||
} catch (e) { skip("L3 시각 회귀", "pixelmatch/pngjs 미설치"); }
|
||||
} else skip("L3 시각 회귀", "checks.visual=false");
|
||||
|
||||
await browser.close();
|
||||
|
||||
// ── L4 WebKit (옵션: playwright 설치 시) ──
|
||||
if (CFG.checks.webkit) {
|
||||
try {
|
||||
const { webkit } = await import("playwright");
|
||||
const b2 = await webkit.launch();
|
||||
const pg = await b2.newPage();
|
||||
await pg.setViewportSize({ width: 390, height: 844 });
|
||||
let bad = 0;
|
||||
for (const p of CFG.pages) {
|
||||
await pg.goto(pageUrl(p), { waitUntil: "networkidle" });
|
||||
const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; }
|
||||
}
|
||||
await b2.close();
|
||||
!bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`);
|
||||
} catch { skip("L4 WebKit", "playwright/webkit 미설치"); }
|
||||
} else skip("L4 WebKit", "checks.webkit=false");
|
||||
|
||||
// ── 리포트 ──
|
||||
const fails = results.filter((r) => !r.ok).length;
|
||||
const lines = results.map((r) => `${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? " — " + r.detail : ""}`);
|
||||
console.log(lines.join("\n"));
|
||||
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
||||
fs.writeFileSync(path.resolve(process.cwd(), "gate-report.md"),
|
||||
`# 게이트 리포트 — ${stamp}\n\n${fails ? `**${fails}건 실패 — 배포 금지**` : "**전 항목 통과 — 배포 가능**"}\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`);
|
||||
console.log(`\n${fails ? `게이트 실패(${fails})` : "게이트 통과"} — gate-report.md 생성`);
|
||||
process.exit(fails ? 1 : 0);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# E2E 시나리오 게이트 — 2026-08-24 05:13
|
||||
# E2E 시나리오 게이트 — 2026-08-24 17:13
|
||||
|
||||
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**
|
||||
|
||||
|
|
|
|||
|
|
@ -145,8 +145,10 @@ const PROBE = `() => {
|
|||
if (!visible(el) || !el.textContent.trim()) return;
|
||||
const cs = getComputedStyle(el);
|
||||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
|
||||
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
||||
if (el.getBoundingClientRect().height > lh * 1.6 + pad) wrapped.push(el.textContent.trim().slice(0, 26));
|
||||
const textRange = document.createRange();
|
||||
textRange.selectNodeContents(el);
|
||||
const textHeight = textRange.getBoundingClientRect().height;
|
||||
if (textHeight > lh * 1.6) wrapped.push(el.textContent.trim().slice(0, 26));
|
||||
});
|
||||
|
||||
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
|
||||
|
|
@ -181,7 +183,13 @@ const browser = await puppeteer.launch({
|
|||
headless: "new",
|
||||
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
|
||||
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
|
||||
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--allow-file-access-from-files",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--force-device-scale-factor=1",
|
||||
],
|
||||
});
|
||||
|
||||
let totalFail = 0;
|
||||
|
|
@ -191,6 +199,7 @@ for (const slug of slugs) {
|
|||
for (const file of pagesOf(slug)) {
|
||||
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
|
||||
for (const width of WIDTHS) {
|
||||
console.log(`RUN ${rel} @${width}`);
|
||||
const page = await browser.newPage();
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));
|
||||
|
|
|
|||
25
apps/site/tools/twentyoz-e2e-report.md
Normal file
25
apps/site/tools/twentyoz-e2e-report.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# TWENTYOZ E2E — 2026-08-24 17:13
|
||||
|
||||
**148 assertions passed · GREEN**
|
||||
|
||||
- PASS responsive 320 — 13 passed
|
||||
- PASS responsive 390 — 13 passed
|
||||
- PASS responsive 768 — 13 passed
|
||||
- PASS responsive 1440 — 13 passed
|
||||
- PASS field interactions — 17 passed
|
||||
- PASS content controls — 5 passed
|
||||
- PASS official content — 2 passed
|
||||
- PASS axe light — 3 passed
|
||||
- PASS axe dark — 3 passed
|
||||
- PASS fallbacks — 5 passed
|
||||
- PASS WebGL — 13 passed
|
||||
- PASS WebGL layout 390 — 6 passed
|
||||
- PASS WebGL layout 768 — 6 passed
|
||||
- PASS WebGL layout 1024 — 6 passed
|
||||
- PASS WebGL layout 1440 — 6 passed
|
||||
- PASS catalog 320 — 7 passed
|
||||
- PASS catalog 1440 — 7 passed
|
||||
- PASS catalog axe light — 1 passed
|
||||
- PASS catalog axe dark — 1 passed
|
||||
- PASS performance — 7 passed
|
||||
- PASS runtime — 1 passed
|
||||
415
apps/site/tools/twentyoz-e2e.mjs
Normal file
415
apps/site/tools/twentyoz-e2e.mjs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
// TWENTYOZ L6 — actual Chromium scenarios for the standalone showcase.
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const publicRoot = path.join(root, "public");
|
||||
const targetRoot = path.join(publicRoot, "work", "twentyoz-corp");
|
||||
const chrome = [
|
||||
process.env.CHROME_PATH,
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
].find((candidate) => candidate && fs.existsSync(candidate));
|
||||
if (!chrome) throw new Error("Chrome executable not found");
|
||||
|
||||
const MIME = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".webp", "image/webp"],
|
||||
[".png", "image/png"],
|
||||
[".woff2", "font/woff2"],
|
||||
[".glb", "model/gltf-binary"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
]);
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
|
||||
const candidate = path.resolve(publicRoot, `.${pathname}`);
|
||||
if (!candidate.startsWith(publicRoot + path.sep)) {
|
||||
response.writeHead(403).end("forbidden");
|
||||
return;
|
||||
}
|
||||
const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? path.join(candidate, "index.html") : candidate;
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
response.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
fs.createReadStream(file).pipe(response);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/index.html`;
|
||||
const catalogUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/products.html`;
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
const suites = new Map();
|
||||
const assert = (condition, suite, name, detail = "") => {
|
||||
const current = suites.get(suite) || { pass: 0, fail: 0 };
|
||||
if (condition) {
|
||||
pass += 1;
|
||||
current.pass += 1;
|
||||
} else {
|
||||
fail += 1;
|
||||
current.fail += 1;
|
||||
failures.push(`${suite} · ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
suites.set(suite, current);
|
||||
};
|
||||
const assertEvery = (items, predicate, suite, name, describe = (item) => JSON.stringify(item)) => {
|
||||
for (const item of items) assert(predicate(item), suite, name, describe(item));
|
||||
};
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
||||
});
|
||||
|
||||
const errors = [];
|
||||
async function newPage() {
|
||||
const page = await browser.newPage();
|
||||
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const location = message.location();
|
||||
errors.push(`console: ${message.text()}${location.url ? ` @ ${location.url}` : ""}`);
|
||||
}
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) errors.push(`response: ${response.status()} ${response.url()}`);
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__twentyozPerf = { cls: 0, lcp: 0 };
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__twentyozPerf.cls += entry.value;
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length) window.__twentyozPerf.lcp = entries.at(-1).startTime;
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
});
|
||||
return page;
|
||||
}
|
||||
|
||||
async function goto(page, suffix = "") {
|
||||
await page.goto(baseUrl + suffix, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Semantic structure, progressive fallback and responsive geometry.
|
||||
const page = await newPage();
|
||||
for (const width of [320, 390, 768, 1440]) {
|
||||
await page.setViewport({ width, height: width < 500 ? 844 : 900, isMobile: width < 500, hasTouch: width < 500 });
|
||||
await goto(page);
|
||||
const layout = await page.evaluate(() => {
|
||||
const visible = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const h1 = document.querySelector("h1");
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(h1).lineHeight);
|
||||
const navTops = [...document.querySelectorAll(".nav a")].map((link) => Math.round(link.getBoundingClientRect().top));
|
||||
return {
|
||||
width: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
h1Count: document.querySelectorAll("h1").length,
|
||||
h1Lines: Math.round(h1.getBoundingClientRect().height / lineHeight),
|
||||
navTopCount: new Set(navTops).size,
|
||||
checked: document.querySelector('input[name="field"]:checked')?.value,
|
||||
visiblePanels: [...document.querySelectorAll(".field-panel")].filter(visible).map((panel) => panel.dataset.panel),
|
||||
fieldTargets: [...document.querySelectorAll(".field-tabs label")].map((label) => {
|
||||
const rect = label.getBoundingClientRect();
|
||||
return { width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||
}),
|
||||
brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).length,
|
||||
webgl: document.querySelector(".hero").dataset.webgl,
|
||||
fallbackOpacity: Number.parseFloat(getComputedStyle(document.querySelector(".core-poster")).opacity),
|
||||
panelWidth: Math.round(document.querySelector(".field-panels").getBoundingClientRect().width),
|
||||
tabsWidth: Math.round(document.querySelector(".field-tabs").getBoundingClientRect().width),
|
||||
};
|
||||
});
|
||||
const suite = `responsive ${width}`;
|
||||
assert(layout.scrollWidth <= layout.width, suite, "no horizontal document overflow", `${layout.scrollWidth}/${layout.width}`);
|
||||
assert(layout.h1Count === 1, suite, "one h1", String(layout.h1Count));
|
||||
assert(layout.h1Lines <= 2, suite, "hero title at most two lines", String(layout.h1Lines));
|
||||
assert(layout.navTopCount === 1, suite, "navigation remains one row", String(layout.navTopCount));
|
||||
assert(layout.checked === "edu", suite, "default field is education", String(layout.checked));
|
||||
assert(layout.visiblePanels.length === 1 && layout.visiblePanels[0] === "edu", suite, "one matching panel visible", layout.visiblePanels.join(","));
|
||||
assertEvery(layout.fieldTargets, (target) => target.height >= 44, suite, "field target height >=44", (target) => `${target.width}x${target.height}`);
|
||||
assert(layout.brokenImages === 0, suite, "all images decode", String(layout.brokenImages));
|
||||
assert(layout.webgl === "fallback" && layout.fallbackOpacity === 1, suite, "automation fallback is complete", `${layout.webgl}/${layout.fallbackOpacity}`);
|
||||
assert(Math.abs(layout.panelWidth - layout.tabsWidth) <= 1, suite, "field panel fills the full tab width", `${layout.panelWidth}/${layout.tabsWidth}`);
|
||||
}
|
||||
|
||||
// 2. All field controls: mouse and native keyboard radio behavior.
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await goto(page);
|
||||
const fieldCases = [
|
||||
["field-edu", "edu", "VIVEN"],
|
||||
["field-dc", "dc", "XD Hub"],
|
||||
["field-gov", "gov", "EduBill"],
|
||||
["field-plant", "plant", "ACS"],
|
||||
];
|
||||
for (const [id, value, proof] of fieldCases) {
|
||||
await page.click(`label[for="${id}"]`);
|
||||
const state = await page.evaluate((value) => {
|
||||
const panel = document.querySelector(`[data-panel="${value}"]`);
|
||||
return {
|
||||
checked: document.querySelector('input[name="field"]:checked')?.value,
|
||||
display: getComputedStyle(panel).display,
|
||||
text: panel.textContent,
|
||||
coreState: document.querySelector(".hero").dataset.coreState,
|
||||
};
|
||||
}, value);
|
||||
assert(state.checked === value, "field interactions", `${value} radio checked`, state.checked);
|
||||
assert(state.display !== "none", "field interactions", `${value} panel visible`, state.display);
|
||||
assert(state.text.includes(proof), "field interactions", `${value} proof content`, state.text.slice(0, 80));
|
||||
assert(state.coreState === value, "field interactions", `${value} core state synced`, state.coreState);
|
||||
}
|
||||
await page.focus("#field-edu");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
assert(await page.$eval("#field-dc", (input) => input.checked), "field interactions", "arrow key changes native radio");
|
||||
|
||||
// 3. Archive disclosure and anchor navigation.
|
||||
assert(!(await page.$eval("#career", (details) => details.open)), "content controls", "career starts closed");
|
||||
await page.click("#career summary");
|
||||
assert(await page.$eval("#career", (details) => details.open), "content controls", "career opens by summary");
|
||||
assert((await page.$$eval(".jobs li", (items) => items.length)) === 5, "content controls", "five closed job records remain available");
|
||||
assert((await page.$$eval(".record-list > li", (items) => items.length)) === 7, "official content", "seven current official records remain available");
|
||||
assert((await page.$$eval('.record-list a[href="https://twentyoz.kr/news/111"]', (items) => items.length)) === 1, "official content", "latest official award record links to its source");
|
||||
await page.click('.nav a[href="#ledger"]');
|
||||
await wait(120);
|
||||
assert((await page.evaluate(() => location.hash)) === "#ledger", "content controls", "work anchor updates URL");
|
||||
assert((await page.evaluate(() => scrollY)) > 0, "content controls", "work anchor scrolls page");
|
||||
|
||||
// 4. Axe accessibility in light and dark fallback modes.
|
||||
const axePath = require.resolve("axe-core/axe.min.js");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
|
||||
await goto(page);
|
||||
await page.addScriptTag({ path: axePath });
|
||||
const axe = await page.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
|
||||
const axeDetail = axe.violations
|
||||
.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`)
|
||||
.join("; ");
|
||||
assert(axe.violations.length === 0, `axe ${theme}`, "zero WCAG violations", axeDetail);
|
||||
const themeProof = await page.evaluate(() => ({
|
||||
background: getComputedStyle(document.body).backgroundColor,
|
||||
poster: document.querySelector(".core-poster img").currentSrc,
|
||||
}));
|
||||
assert(theme === "light" ? themeProof.background === "rgb(233, 231, 226)" : themeProof.background === "rgb(13, 15, 15)", `axe ${theme}`, "theme background role applied", themeProof.background);
|
||||
assert(theme === "light" ? themeProof.poster.includes("core-light.webp") : themeProof.poster.includes("core-dark.webp"), `axe ${theme}`, "matching poster selected", themeProof.poster);
|
||||
}
|
||||
await page.emulateMediaFeatures([]);
|
||||
|
||||
// 5. Reduced motion and explicit WebGL failure stay complete.
|
||||
const reducedPage = await newPage();
|
||||
await reducedPage.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
|
||||
await reducedPage.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await goto(reducedPage, "?webgl=1");
|
||||
const reduced = await reducedPage.evaluate(() => ({
|
||||
webgl: document.querySelector(".hero").dataset.webgl,
|
||||
reason: document.querySelector(".hero").dataset.webglReason,
|
||||
canvasDisplay: getComputedStyle(document.querySelector("#coreCanvas")).display,
|
||||
posterOpacity: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
||||
toggleHidden: document.querySelector("#motionToggle").hidden,
|
||||
}));
|
||||
assert(reduced.webgl === "fallback" && reduced.reason === "reduced-motion", "fallbacks", "reduced motion blocks WebGL", `${reduced.webgl}/${reduced.reason}`);
|
||||
assert(reduced.canvasDisplay === "none" && reduced.posterOpacity === "1", "fallbacks", "reduced motion shows static poster", `${reduced.canvasDisplay}/${reduced.posterOpacity}`);
|
||||
assert(reduced.toggleHidden, "fallbacks", "motion control hidden when no motion");
|
||||
await reducedPage.close();
|
||||
|
||||
const failedGlPage = await newPage();
|
||||
await failedGlPage.evaluateOnNewDocument(() => {
|
||||
const original = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function getContext(type, ...args) {
|
||||
if (type === "webgl2") return null;
|
||||
return original.call(this, type, ...args);
|
||||
};
|
||||
});
|
||||
await goto(failedGlPage, "?webgl=1");
|
||||
const noGl = await failedGlPage.evaluate(() => ({
|
||||
webgl: document.querySelector(".hero").dataset.webgl,
|
||||
reason: document.querySelector(".hero").dataset.webglReason,
|
||||
title: document.querySelector("h1").textContent.trim(),
|
||||
poster: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
||||
}));
|
||||
assert(noGl.webgl === "fallback" && noGl.reason === "no-webgl2", "fallbacks", "WebGL2 creation failure falls back", `${noGl.webgl}/${noGl.reason}`);
|
||||
assert(noGl.title === "네 현장을 하나로" && noGl.poster === "1", "fallbacks", "DOM and poster survive WebGL failure", `${noGl.title}/${noGl.poster}`);
|
||||
await failedGlPage.close();
|
||||
|
||||
// 6. Explicit real WebGL path, state sync, pause/replay, budgets and context loss.
|
||||
const glPage = await newPage();
|
||||
await glPage.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await goto(glPage, "?webgl=1");
|
||||
await glPage.waitForSelector('.hero[data-webgl="ready"]', { timeout: 45000 });
|
||||
await glPage.waitForSelector('.hero[data-model="ready"]', { timeout: 45000 });
|
||||
const glReady = await glPage.evaluate(() => ({
|
||||
canvasOpacity: getComputedStyle(document.querySelector("#coreCanvas")).opacity,
|
||||
posterOpacity: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
||||
drawCalls: Number(document.querySelector("#coreCanvas").dataset.drawCalls),
|
||||
dpr: Number(document.querySelector("#coreCanvas").dataset.dpr),
|
||||
toggleHidden: document.querySelector("#motionToggle").hidden,
|
||||
paused: document.querySelector("#motionToggle").getAttribute("aria-pressed"),
|
||||
model: document.querySelector("#coreCanvas").dataset.model,
|
||||
effects: document.querySelector("#coreCanvas").dataset.effects,
|
||||
glowRings: document.querySelector("#coreCanvas").dataset.glowRings,
|
||||
}));
|
||||
assert(glReady.canvasOpacity === "1" && glReady.posterOpacity === "0", "WebGL", "canvas replaces poster after first frame", `${glReady.canvasOpacity}/${glReady.posterOpacity}`);
|
||||
assert(Number.isFinite(glReady.drawCalls) && glReady.drawCalls <= 60, "WebGL", "mobile draw calls <=60", String(glReady.drawCalls));
|
||||
assert(Number.isFinite(glReady.dpr) && glReady.dpr <= 1.35, "WebGL", "mobile DPR <=1.35", String(glReady.dpr));
|
||||
assert(!glReady.toggleHidden && glReady.paused === "true", "WebGL", "automation begins paused with control", `${glReady.toggleHidden}/${glReady.paused}`);
|
||||
assert(glReady.model === "ready", "WebGL", "licensed hard-surface GLB is rendered", glReady.model);
|
||||
assert(glReady.effects.includes("field-shader") && glReady.effects.includes("bloom") && glReady.effects.includes("grain"), "WebGL", "shader and post-processing stack is active", glReady.effects);
|
||||
assert(glReady.glowRings === "2", "WebGL", "two depth-separated glow rings are active", glReady.glowRings);
|
||||
|
||||
const coreLayoutCases = [
|
||||
{ width: 390, height: 844, mobile: true, tier: "mobile", offset: 0, minRatio: 0.495, maxRatio: 0.505 },
|
||||
{ width: 768, height: 900, mobile: false, tier: "tablet", offset: 0.9, minRatio: 0.72, maxRatio: 0.75 },
|
||||
{ width: 1024, height: 768, mobile: false, tier: "compact", offset: 0.75, minRatio: 0.61, maxRatio: 0.64 },
|
||||
{ width: 1440, height: 900, mobile: false, tier: "wide", offset: 0.72, minRatio: 0.58, maxRatio: 0.62 },
|
||||
];
|
||||
for (const expected of coreLayoutCases) {
|
||||
await glPage.setViewport({ width: expected.width, height: expected.height, isMobile: expected.mobile, hasTouch: expected.mobile });
|
||||
await glPage.waitForFunction(
|
||||
(tier) => document.querySelector("#coreCanvas")?.dataset.layoutTier === tier,
|
||||
{ timeout: 10000 },
|
||||
expected.tier,
|
||||
);
|
||||
const placement = await glPage.$eval("#coreCanvas", (canvas) => ({
|
||||
tier: canvas.dataset.layoutTier,
|
||||
offset: Number(canvas.dataset.coreOffsetX),
|
||||
ratio: Number(canvas.dataset.coreScreenRatio),
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
actionRows: new Set(
|
||||
[...document.querySelectorAll(".hero-actions > *")]
|
||||
.filter((element) => getComputedStyle(element).display !== "none")
|
||||
.map((element) => Math.round(element.getBoundingClientRect().top)),
|
||||
).size,
|
||||
motionHeight: document.querySelector("#motionToggle").getBoundingClientRect().height,
|
||||
}));
|
||||
const suite = `WebGL layout ${expected.width}`;
|
||||
assert(placement.tier === expected.tier, suite, "responsive layout tier is selected", placement.tier);
|
||||
assert(Math.abs(placement.offset - expected.offset) <= 0.01, suite, "core offset matches composition", String(placement.offset));
|
||||
assert(placement.ratio >= expected.minRatio && placement.ratio <= expected.maxRatio, suite, "core screen position fills the hero center", String(placement.ratio));
|
||||
assert(placement.overflow <= 0, suite, "visible 3D controls create no horizontal overflow", String(placement.overflow));
|
||||
assert(placement.actionRows === 1, suite, "hero actions stay on one row", String(placement.actionRows));
|
||||
assert(placement.motionHeight >= 44, suite, "motion control keeps a 44px target", String(placement.motionHeight));
|
||||
}
|
||||
|
||||
await glPage.click('label[for="field-plant"]');
|
||||
await glPage.waitForFunction(() => document.querySelector("#coreCanvas").dataset.coreState === "plant");
|
||||
assert(await glPage.$eval("#coreCanvas", (canvas) => canvas.dataset.coreState === "plant"), "WebGL", "field selection reaches 3D state");
|
||||
assert(await glPage.$eval("#coreStatus", (status) => status.textContent === "선택한 현장: 공장과 설비 현장" && status.classList.contains("sr-only")), "WebGL", "field state remains explicit for assistive technology");
|
||||
await glPage.click("#motionToggle");
|
||||
assert((await glPage.$eval("#motionToggle", (button) => button.getAttribute("aria-pressed"))) === "false", "WebGL", "motion replay control works");
|
||||
assert((await glPage.$eval("#motionToggle", (button) => button.textContent.trim())) === "3D 모션 끄기", "WebGL", "motion control names the available action");
|
||||
await wait(180);
|
||||
await glPage.click("#motionToggle");
|
||||
assert((await glPage.$eval("#motionToggle", (button) => button.getAttribute("aria-pressed"))) === "true", "WebGL", "motion pause control works");
|
||||
|
||||
await glPage.evaluate(() => {
|
||||
const gl = document.querySelector("#coreCanvas").getContext("webgl2");
|
||||
gl.getExtension("WEBGL_lose_context").loseContext();
|
||||
});
|
||||
await glPage.waitForFunction(() => document.querySelector(".hero").dataset.webgl === "fallback", { timeout: 5000 });
|
||||
const lost = await glPage.evaluate(() => ({
|
||||
reason: document.querySelector(".hero").dataset.webglReason,
|
||||
poster: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
||||
hidden: document.querySelector("#motionToggle").hidden,
|
||||
}));
|
||||
assert(lost.reason === "context-lost" && lost.poster === "1" && lost.hidden, "WebGL", "context loss restores complete fallback", JSON.stringify(lost));
|
||||
await glPage.close();
|
||||
|
||||
// 7. Product catalog: all four detailed systems, responsive layout and accessibility.
|
||||
const catalogPage = await newPage();
|
||||
for (const width of [320, 1440]) {
|
||||
await catalogPage.setViewport({ width, height: width < 500 ? 844 : 900, isMobile: width < 500, hasTouch: width < 500 });
|
||||
await catalogPage.goto(catalogUrl, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await catalogPage.evaluate(() => document.fonts.ready);
|
||||
const catalog = await catalogPage.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
h1: document.querySelectorAll("h1").length,
|
||||
specimens: [...document.querySelectorAll(".specimen")].map((item) => item.id),
|
||||
capabilities: document.querySelectorAll(".capability-list li").length,
|
||||
steps: document.querySelectorAll(".operation-flow li").length,
|
||||
mailto: [...document.querySelectorAll('.specimen-action a[href^="mailto:"]')].length,
|
||||
text: document.body.textContent,
|
||||
}));
|
||||
const suite = `catalog ${width}`;
|
||||
assert(catalog.scrollWidth <= catalog.width, suite, "no horizontal overflow", `${catalog.scrollWidth}/${catalog.width}`);
|
||||
assert(catalog.h1 === 1, suite, "one h1", String(catalog.h1));
|
||||
assert(catalog.specimens.join(",") === "viven,edubill,xd-hub,xd-works", suite, "four product details are present", catalog.specimens.join(","));
|
||||
assert(catalog.capabilities === 16, suite, "sixteen verified capabilities", String(catalog.capabilities));
|
||||
assert(catalog.steps === 16, suite, "sixteen operating steps", String(catalog.steps));
|
||||
assert(catalog.mailto === 4, suite, "four product inquiry actions", String(catalog.mailto));
|
||||
assert(catalog.text.includes("1,000,000개") && catalog.text.includes("단일 공간 최대 100명") && catalog.text.includes("다통화 · 다과금 정책"), suite, "published product specifications remain available");
|
||||
}
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await catalogPage.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
|
||||
await catalogPage.goto(catalogUrl, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await catalogPage.addScriptTag({ path: axePath });
|
||||
const axe = await catalogPage.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
|
||||
const axeDetail = axe.violations.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`).join("; ");
|
||||
assert(axe.violations.length === 0, `catalog axe ${theme}`, "zero WCAG violations", axeDetail);
|
||||
}
|
||||
await catalogPage.close();
|
||||
|
||||
// 8. Runtime and transfer budgets.
|
||||
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await goto(page);
|
||||
await wait(900);
|
||||
const perf = await page.evaluate(() => window.__twentyozPerf);
|
||||
assert(perf.cls <= 0.1, "performance", "CLS <=0.1", String(perf.cls));
|
||||
assert(perf.lcp > 0 && perf.lcp <= 2500, "performance", "LCP <=2.5s", String(perf.lcp));
|
||||
|
||||
const gzip = (relative) => zlib.gzipSync(fs.readFileSync(path.join(targetRoot, relative)), { level: 9 }).byteLength;
|
||||
const initialAssets = ["index.html", "styles/main.css", "styles/tokens.css", "scripts/core-boot.js", "assets/core-light.webp", "assets/pretendard-twentyoz.woff2"];
|
||||
const firstViewportBytes = initialAssets.reduce((sum, relative) => sum + fs.statSync(path.join(targetRoot, relative)).size, 0);
|
||||
const coreGzip = gzip("assets/core.iife.js");
|
||||
const modelBytes = fs.statSync(path.join(targetRoot, "assets/twentyoz-field-core.glb")).size;
|
||||
const bootGzip = gzip("scripts/core-boot.js");
|
||||
assert(firstViewportBytes <= 1024 * 1024, "performance", "first viewport assets <=1MB", `${firstViewportBytes}B`);
|
||||
assert(coreGzip <= 200 * 1024, "performance", "WebGL bundle <=200KB gzip", `${coreGzip}B`);
|
||||
assert(modelBytes <= 750 * 1024, "performance", "optimized GLB <=750KB", `${modelBytes}B`);
|
||||
assert(coreGzip + modelBytes <= 1024 * 1024, "performance", "lazy 3D payload <=1MB", `${coreGzip + modelBytes}B`);
|
||||
assert(bootGzip <= 35 * 1024, "performance", "hero bootstrap <=35KB gzip", `${bootGzip}B`);
|
||||
|
||||
assert(errors.length === 0, "runtime", "zero console and page errors", errors.join(" | "));
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
const lines = [...suites.entries()].map(([name, result]) => `${result.fail ? "FAIL" : "PASS"} ${name} — ${result.pass} passed${result.fail ? ` · ${result.fail} failed` : ""}`);
|
||||
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
||||
fs.writeFileSync(
|
||||
path.join(here, "twentyoz-e2e-report.md"),
|
||||
[`# TWENTYOZ E2E — ${stamp}`, "", fail ? `**${fail} failed · deploy blocked**` : `**${pass} assertions passed · GREEN**`, "", ...lines.map((line) => `- ${line}`), ...(failures.length ? ["", "## Failures", "", ...failures.map((item) => `- ${item}`)] : [])].join("\n") + "\n",
|
||||
);
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
if (failures.length) {
|
||||
console.log("\nFailures:");
|
||||
failures.forEach((failure) => console.log(`FAIL ${failure}`));
|
||||
}
|
||||
console.log(`\nTWENTYOZ E2E — PASS ${pass} / FAIL ${fail}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
# 검증 리포트 — 2026-08-24 05:13
|
||||
# 검증 리포트 — 2026-08-24 17:13
|
||||
|
||||
게이트: **전 계층 통과 — 배포 가능** · 총 103.0초
|
||||
게이트: **전 계층 통과 — 배포 가능** · 총 165.5초
|
||||
|
||||
| 계층 | 결과 | 항목 | 소요 |
|
||||
|---|---|---|---|
|
||||
| L0-정적/stylelint | PASS | PASS — | 2.7s |
|
||||
| L0-정식/html-validate | PASS | PASS — | 2.8s |
|
||||
| L0-무결성/변수·경로·조판 | PASS | PASS 1 | 0.1s |
|
||||
| L1-단위/계산로직 | PASS | PASS 41 | 0.0s |
|
||||
| L2-기능/감사 | PASS | PASS 72 | 9.8s |
|
||||
| L2-불변식/렌더 | PASS | PASS 10 | 16.7s |
|
||||
| L3-시각회귀/diff | PASS | PASS 30 | 18.7s |
|
||||
| L4-WebKit | PASS | PASS 8 | 3.8s |
|
||||
| L5-탐색/키보드 | PASS | PASS 14 | 6.0s |
|
||||
| L6-시나리오/E2E | PASS | PASS — | 42.3s |
|
||||
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.2s |
|
||||
| L0-정적/stylelint | PASS | PASS — | 5.2s |
|
||||
| L0-정식/html-validate | PASS | PASS — | 2.9s |
|
||||
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.3s |
|
||||
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
|
||||
| L2-기능/감사 | PASS | PASS 72 | 10.9s |
|
||||
| L2-불변식/렌더 | PASS | PASS 10 | 16.9s |
|
||||
| L2-디자인 게이트/메인 | PASS | PASS 130 | 33.0s |
|
||||
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.0s |
|
||||
| L3-시각회귀/diff | PASS | PASS 30 | 12.0s |
|
||||
| L4-WebKit | PASS | PASS 8 | 4.5s |
|
||||
| L5-탐색/키보드 | PASS | PASS 14 | 6.4s |
|
||||
| L6-시나리오/E2E | PASS | PASS — | 43.2s |
|
||||
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 23.0s |
|
||||
|
|
|
|||
|
|
@ -11,13 +11,17 @@ const root = path.resolve(here, "..");
|
|||
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
layer: "L0-자산/트웬티온스 3D",
|
||||
cmd: () => spawnSync(process.execPath, ["tools/build-showcase-assets.mjs", "--check"], { cwd: root, encoding: "utf8" }),
|
||||
},
|
||||
{
|
||||
layer: "L0-정적/stylelint",
|
||||
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),
|
||||
},
|
||||
{
|
||||
layer: "L0-정식/html-validate",
|
||||
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html"], { cwd: root, encoding: "utf8", shell: true }),
|
||||
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html", "public/work/twentyoz-corp/products.html"], { cwd: root, encoding: "utf8", shell: true }),
|
||||
},
|
||||
{
|
||||
// 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도
|
||||
|
|
@ -28,10 +32,13 @@ const steps = [
|
|||
{ layer: "L1-단위/계산로직", cmd: () => spawnSync(process.execPath, ["tools/unit/calc.test.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L2-기능/감사", cmd: () => spawnSync(process.execPath, ["tools/audit.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L2-불변식/렌더", cmd: () => spawnSync(process.execPath, ["tools/invariants.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L2-디자인 게이트/메인", cmd: () => spawnSync(process.execPath, ["tools/design-gate.mjs", "--config=gate.config.json"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L2-디자인 게이트/제품", cmd: () => spawnSync(process.execPath, ["tools/design-gate.mjs", "--config=gate.twentyoz-products.config.json"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L3-시각회귀/diff", cmd: () => spawnSync(process.execPath, ["tools/visual.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L4-WebKit", cmd: () => spawnSync(process.execPath, ["tools/webkit.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L5-탐색/키보드", cmd: () => spawnSync(process.execPath, ["tools/exploratory.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
];
|
||||
|
||||
const report = [];
|
||||
|
|
@ -43,8 +50,10 @@ for (const step of steps) {
|
|||
const ok = r.status === 0;
|
||||
if (!ok) failed++;
|
||||
const out = (r.stdout || "") + (r.stderr || "");
|
||||
const passLine = (out.match(/PASS\s+[^\n]/g) || []).length;
|
||||
const failLines = (out.match(/FAIL\s+[^\n]/g) || []).length;
|
||||
const summaries = [...out.matchAll(/PASS\s+(\d+)\s*\/\s*FAIL\s+(\d+)/g)];
|
||||
const summary = summaries.at(-1);
|
||||
const passLine = summary ? Number(summary[1]) : (out.match(/^PASS\b/gm) || []).length;
|
||||
const failLines = summary ? Number(summary[2]) : (out.match(/^FAIL\b/gm) || []).length;
|
||||
const tail = out.trim().split("\n").slice(-3).join(" | ").slice(0, 180);
|
||||
report.push({ layer: step.layer, ok, ms: Date.now() - s, passLine, failLines, tail });
|
||||
console.log(`${ok ? "✓" : "✗"} ${step.layer} (${((Date.now() - s) / 1000).toFixed(1)}s)${passLine ? ` — PASS ${passLine}` : ""}${failLines ? ` FAIL ${failLines}` : ""}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue