// 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.click(`[data-view="${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);