designpaca/apps/site/tools/webkit.mjs
Yun Chan 14cb138e1a feat(site,skill): 모바일 앱 수준 UX — 바텀내비·백 키 히스토리·바텀 시트·safe area·PWA 와 L6 시나리오 E2E
- HIG(탭바 5~6·시트)·M3(바텀내비 3~5·48dp)·NN/g·CSS-Tricks 조사(research/mobile-app-ux-v1.md) 후 Nielsen 10 감사로 위반 도출
- 바텀 내비 5탭 + 더보기 바텀 시트(그룹 헤더), 뷰·다이얼로그 pushState/popstate — 안드로이드 백 키가 앱 내 뒤로 작동
- safe area(env+max)·입력 16px(iOS 확대 방지)·coarse 44px 타깃·tap-highlight·overscroll 위생, 인쇄에서 모바일 크롬 제거
- PWA: manifest + sharp 생성 아이콘(가온=장부 문항, 두레=시간표 블록)
- tools/e2e.mjs L6 시나리오: RED 1,742 단언 318 실패 → GREEN 1,762/0, verify 체인 7계층 통과
- 실기기(안드로이드 에뮬레이터·Chrome) 텍스트 근거 검증 — 백 키·시트·탭 전환, iOS 는 WebKit(L4) 대체
- 스킬 내재화: mobile-app-ux.md, audit-gate 7계층·하니스 12~14, design-gate 터치타깃 검사, SKILL.md 규칙 7
- 도그푸드: 게이트가 죽은 선택자 8종·thresholds 병합 버그 적발 — CSS v22(양앱) 범프 후 전체 재검증
2026-08-23 14:33:15 +09:00

82 lines
3.7 KiB
JavaScript

// L4 — WebKit(사파리 엔진) 크로스 브라우저 검증.
// 핵심 불변식(h1 줄수·브랜드 1줄·수축 탐지·콘텐츠 정합·오버플로)을 WebKit 에서 재실행한다.
import { webkit } from "playwright";
import path from "node:path";
import url from "node:url";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, "../public/work");
const results = [];
const pass = (name, detail = "") => results.push({ ok: true, name, detail });
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
const APPS = {
"gaon-lms": ["dashboard", "students", "attendance", "grades", "notice"],
"dure-enrollment": ["catalog", "starred", "enrollments", "credits"],
};
const UTILS = `
window.__lines = (sel) => {
const el = document.querySelector(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 webkit.launch();
const page = await browser.newPage();
await page.setViewportSize({ width: 390, height: 844 });
for (const [app, views] of Object.entries(APPS)) {
const appUrl = url.pathToFileURL(path.join(ROOT, app, "index.html")).href;
await page.goto(appUrl, { waitUntil: "networkidle" });
await page.evaluate(UTILS);
await page.evaluate(() => document.fonts.ready);
// 브랜드 1줄 + h1 1줄 + 페이지 오버플로
const brand = await page.evaluate(`__lines('.brand')`);
if (brand > 1) fail(`${app} WebKit 브랜드 세로`, brand + "줄"); else pass(`${app} WebKit 브랜드`);
const h1 = await page.evaluate(`__lines('h1')`);
if (h1 > 1) fail(`${app} WebKit h1 여러 줄`, h1 + "줄"); else pass(`${app} WebKit h1`);
for (const v of views) {
await page.evaluate((v) => { const b = document.querySelector(`[data-view="${v}"]`); b && b.click(); }, v);
await page.waitForTimeout(120);
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) fail(`${app}/${v} WebKit 오버플로`, `+${over}px`);
const vh1 = await page.evaluate(`__lines('.view.is-on h1, h1')`);
if (vh1 > 1) fail(`${app}/${v} WebKit h1`, vh1 + "줄");
}
pass(`${app} WebKit 전 뷰 오버플로·h1`);
// 콘텐츠 정합 (JS 계산 동일성)
const cons = await page.evaluate(() => {
const out = [];
if (window.GAON && document.getElementById("rep-stats")) {
const s = GAON.rosterStats(STUDENTS);
const txt = document.getElementById("rep-stats").textContent;
if (!txt.includes(s.active + "명")) out.push("재학 " + s.active);
if (!txt.includes(s.avgAtt + "%")) out.push("평균 " + s.avgAtt);
}
if (window.DURE && document.getElementById("en-credits")) {
const list = [...enrolled].map((id) => COURSES.find((c) => c.id === id));
const want = String(DURE.credits(list));
const got = document.getElementById("en-credits").textContent.trim();
if (got !== want) out.push("학점 " + got + "≠" + want);
}
return out;
});
if (cons.length) fail(`${app} WebKit 콘텐츠 정합`, cons.join(",")); else pass(`${app} WebKit 콘텐츠 정합`);
}
await browser.close();
const fails = results.filter((r) => !r.ok).length;
for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + " " + r.name + (r.detail ? ` — ${r.detail}` : ""));
console.log(fails ? `\nWEBKIT: ${fails} FAIL` : "\nWEBKIT ALL PASS");
process.exit(fails ? 1 : 0);