// L6 — 시나리오 E2E 게이트 (TDD RED→GREEN). node tools/e2e.mjs // // 실제 사용자 시나리오로 앱을 종단 검사한다. 단언 단위 = 개별 검사(요소별 터치타깃·대비는 요소마다 1단언). // 목표: 총 단언 1000+ 전 통과(GREEN). RED: 신규 모바일 앱 기능 단언은 구현 전 실패해야 정상. // 근거: apps/site/research/mobile-app-ux-v1.md (HIG·M3·NN/g·Baymard·WebKit 표준) import puppeteer from "puppeteer-core"; import fs from "node:fs"; 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 CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const APPS = { "gaon-lms": { views: ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"], tabs: ["dashboard", "students", "attendance", "grades"], more: ["files", "calendar", "counsel", "report", "notice"], }, "dure-enrollment": { views: ["catalog", "starred", "enrollments", "credits", "archive", "notices"], tabs: ["catalog", "starred", "enrollments", "credits"], more: ["archive", "notices"], }, }; let passCount = 0; let failCount = 0; const failures = []; const suites = new Map(); const suite = (name) => { if (!suites.has(name)) suites.set(name, { pass: 0, fail: 0 }); return suites.get(name); }; const ok = (cond, sname, name, detail = "") => { const S = suite(sname); if (cond) { passCount++; S.pass++; } else { failCount++; S.fail++; failures.push(`${name}${detail ? " — " + detail : ""}`); } }; const okN = (good, total, sname, name, detail = "") => { const S = suite(sname); passCount += good; S.pass += good; if (good < total) { failCount += total - good; S.fail += total - good; failures.push(`${name} — ${total - good}건 미달${detail ? ": " + detail : ""}`); } }; 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; }; window.__textLines = (sel) => { const el = typeof sel === "string" ? document.querySelector(sel) : sel; if (!el) return -1; const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim()); return window.__lines(text || el); }; window.__tapSweep = () => { const EXEMPT_STRICT = ".undo-btn, .row-open"; const out = []; document.querySelectorAll("button, input, select, textarea, a[href]").forEach((el) => { if (!el.offsetParent) return; const r = el.getBoundingClientRect(); const inline = el.matches(EXEMPT_STRICT) || el.tagName === "A"; const min = inline ? 24 : 44; out.push({ key: (el.id || el.className || el.tagName).toString().slice(0, 24), w: Math.round(r.width), h: Math.round(r.height), min, bad: r.width < min || r.height < min }); }); return out; }; window.__contrastSweep = () => { 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 bgOf = (el) => { let node = el; const stack = []; while (node && node !== document.documentElement) { const c = rgb(getComputedStyle(node).backgroundColor); if (c && c.a >= 0.95) return c; if (c && c.a > 0) stack.push(c); node = node.parentElement; } let base = { r: 255, g: 255, b: 255, a: 1 }; for (let i = stack.length - 1; i >= 0; i--) base = blend(stack[i], base); return base; }; let checked = 0; const bad = []; document.querySelectorAll("body *").forEach((el) => { if (!el.offsetParent) return; const txt = el.textContent.trim(); const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); if (!txt || !ownText) return; const cs = getComputedStyle(el); const fg = rgb(cs.color); if (!fg) return; const bg = bgOf(el); const fgb = blend(fg, bg); const l1 = lum(fgb); const 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) ? 3 : 4.5; checked++; if (ratio < need) bad.push({ txt: txt.slice(0, 10), ratio, need }); }); return { checked, bad }; }; `; const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); const page = await browser.newPage(); await page.setCacheEnabled(false); const openApp = async (app) => { const href = url.pathToFileURL(path.join(ROOT, app, "index.html")).href; try { await page.goto(href, { waitUntil: "networkidle0", timeout: 45000 }); } catch { await page.goto(href, { waitUntil: "domcontentloaded", timeout: 30000 }); } await page.evaluate(() => document.fonts.ready); await page.evaluate(UTILS); }; const switchView = async (app, v) => { const direct = await page.evaluate((v) => { const bn = [...document.querySelectorAll(".bnav-item[data-view]")].find((b) => b.dataset.view === v && b.offsetParent); if (bn) { bn.click(); return true; } return false; }, v); if (direct) { await sleep(90); return; } const viaMore = await page.evaluate((v) => { const more = document.querySelector("[data-more]"); if (!more || !more.offsetParent) return false; more.click(); const item = [...document.querySelectorAll(".more-list [data-view]")].find((b) => b.dataset.view === v); if (!item) return false; item.click(); return true; }, v); if (viaMore) { await sleep(140); return; } await page.evaluate((v) => { const b = document.querySelector('[data-view="' + v + '"]'); if (b) b.click(); }, v); await sleep(90); }; const jsclick = async (expr, S, name) => { const done = await page.evaluate("(function(){try{const el=" + expr + ";if(el){el.click();return true}return false}catch(e){return false}})()"); ok(done, S, name); return done; }; const jswait = async (sel, S, name, t = 4000) => { try { await page.waitForSelector(sel, { timeout: t }); ok(true, S, name); return true; } catch { ok(false, S, name); return false; } }; const isOn = async (v) => page.evaluate((v) => { const el = document.querySelector("#view-" + v); return !!el && el.classList.contains("is-on"); }, v).catch(() => false); const stillHere = async (app) => page.url().includes(app); // ════════ M4 — 앱 기반(표준·설치성) ════════ for (const [app] of Object.entries(APPS)) { const S = "M4 앱 기반"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp(app); const viewMeta = await page.evaluate(() => document.querySelector('meta[name="viewport"]')?.content || ""); ok(viewMeta.includes("viewport-fit=cover"), S, `${app} viewport-fit=cover`, viewMeta); const manifest = await page.evaluate(() => document.querySelector('link[rel="manifest"]')?.getAttribute("href") || ""); ok(!!manifest && fs.existsSync(path.join(ROOT, app, manifest)), S, `${app} manifest 존재`, manifest); for (const icon of ["icon-192.png", "icon-512.png"]) { ok(fs.existsSync(path.join(ROOT, app, icon)), S, `${app} ${icon} 존재`); } ok(await page.evaluate(() => !!document.querySelector('link[rel="apple-touch-icon"]')), S, `${app} apple-touch-icon`); ok((await page.evaluate(() => getComputedStyle(document.documentElement).webkitTapHighlightColor)) === "rgba(0, 0, 0, 0)", S, `${app} 탭 하이라이트 제거`); const overscroll = await page.evaluate(() => getComputedStyle(document.body).overscrollBehaviorY || getComputedStyle(document.documentElement).overscrollBehaviorY || ""); ok(overscroll === "none" || overscroll === "contain", S, `${app} overscroll-behavior-y 위생`, overscroll); const inputs = await page.evaluate(() => [...document.querySelectorAll("input, select, textarea")].map((el) => parseFloat(getComputedStyle(el).fontSize) || 0)); okN(inputs.filter((f) => f >= 16).length, Math.max(inputs.length, 1), S, `${app} 모바일 입력 16px+(iOS 확대 방지)`, inputs.join("/") + "px"); ok(await page.evaluate(() => !!document.querySelector(".bottom-nav")), S, `${app} 바텀 내비 존재`); } // ════════ M5 — 헤더·스크롤(탭 전환은 화면 맨 위에서, 헤더는 상단에 남는다) ════════ // 실측 사고: 이전 뷰의 스크롤이 남아 짧은 뷰에서 클램프되면 헤더가 반쯤 잘리고, // 뷰마다 크롬 위치가 제각각이 되어 "상단 바가 왔다갔다한다"는 보고로 나타났다 for (const [app, cfg] of Object.entries(APPS)) { const S = "M5 헤더·스크롤"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp(app); const headerSel = app === "gaon-lms" ? ".topbar" : ".appbar"; const head = await page.evaluate((sel) => { const el = document.querySelector(sel); if (!el) return null; const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return { pos: cs.position, top: Math.round(r.top), h: Math.round(r.height) }; }, headerSel); ok(!!head && (head.pos === "sticky" || head.pos === "fixed"), S, `${app} 헤더 sticky/fixed`, head ? head.pos : "요소 없음"); ok(head && head.top === 0 && head.h >= 44, S, `${app} 헤더 상단 밀착·높이`, head ? head.top + "px/" + head.h + "px" : "-"); // 스크롤 내린 상태에서 탭 전환 — 새 화면은 맨 위에서 시작해야 한다 await page.evaluate(() => window.scrollTo(0, 500)); await new Promise((r) => setTimeout(r, 60)); const scrolled = await page.evaluate(() => window.scrollY); await switchView(app, cfg.views[1]); const after = await page.evaluate((sel) => ({ y: window.scrollY, top: Math.round(document.querySelector(sel).getBoundingClientRect().top), }), headerSel); ok(scrolled > 300, S, `${app} 사전 조건: 300px+ 스크롤`, scrolled + "px"); ok(after.y === 0, S, `${app} 탭 전환 후 스크롤 리셋`, after.y + "px"); ok(after.top === 0, S, `${app} 탭 전환 후 헤더 상단 밀착`, after.top + "px"); } // ════════ M6 — 왼쪽 인셋 리듬(제목·카드·헤더가 같은 축에 녹는다) ════════ // 실측 사고: 두레 제목(h2)은 16px, 앱바·카드는 24px — 8px 어긋난 큰 제목이 // '가장자리에 붙어 깨졌다'는 보고로 나왔다. 오버플로 검사는 오른쪽만 보므로 // 왼쪽 인셋의 최소값과 정렬 정합은 별도 검사여야 한다 for (const [app, cfg] of Object.entries(APPS)) { const S = "M6 왼쪽 인셋"; const headerSel = app === "gaon-lms" ? ".topbar" : ".appbar"; for (const w of [320, 390]) { await page.setViewport({ width: w, height: 844, isMobile: true, hasTouch: true }); await openApp(app); for (const v of cfg.views) { await switchView(app, v); const inset = await page.evaluate((hs) => { const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return cs.display !== "none" && cs.visibility !== "hidden" && r.width > 40 && r.height > 8; }; const textLeft = (els) => { // display:none 요소의 rect 는 0 — 보임 검사를 먼저 통과시킨다 const ls = els.filter(vis).filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())).map((el) => el.getBoundingClientRect().left); return ls.length ? Math.round(Math.min(...ls)) : null; }; const barLeft = textLeft([...document.querySelectorAll(hs + " *")]); const head = document.querySelector(".view.is-on h1, .view.is-on h2"); const headLeft = head && vis(head) ? Math.round(head.getBoundingClientRect().left) : null; // 전폭 컨테이너(보더박스 0)가 아니라 '텍스트가 실제로 시작하는 곳'을 잰다 const contentLeft = textLeft([...document.querySelectorAll(".view.is-on *")]); return { barLeft, headLeft, contentLeft }; }, headerSel); const min = Math.min(...[inset.headLeft, inset.contentLeft].filter((v2) => v2 !== null)); ok(min >= 12, S, `${app}/${v}@${w} 본문 인셋 ≥12px`, `제목 ${inset.headLeft}·카드 ${inset.contentLeft}`); if (inset.barLeft !== null && inset.headLeft !== null) ok(Math.abs(inset.barLeft - inset.headLeft) <= 10, S, `${app}/${v}@${w} 헤더-제목 같은 축(±10px)`, `바 ${inset.barLeft} vs 제목 ${inset.headLeft}`); } } } // ════════ M3+M1+M2 — 구조·터치타깃·대비 매트릭스(전 뷰 × 320/390/768) ════════ for (const [app, cfg] of Object.entries(APPS)) { for (const w of [320, 390, 768]) { await page.setViewport({ width: w, height: 844, isMobile: true, hasTouch: true }); await openApp(app); const S = `M${w} 매트릭스`; for (const v of cfg.views) { await switchView(app, v); const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); ok(over <= 0, S, `${app}/${v}@${w} 오버플로 0`, `+${over}px`); const h1 = await page.evaluate(() => window.__lines(document.querySelector(".view.is-on h2.v-h, h1"))); ok(h1 <= 1, S, `${app}/${v}@${w} 제목 1줄`, h1 + "줄"); if (w === 390) { const brand = await page.evaluate(() => window.__textLines(document.querySelector(".brand, .appbar .brand"))); ok(brand <= 1, S, `${app}/${v}@390 브랜드 1줄`, brand + "줄"); } const nav = await page.evaluate(() => { const n = document.querySelector(".bottom-nav"); if (!n) return null; const cs = getComputedStyle(n); // fixed 요소는 offsetParent 가 null — 보임 판정은 display·높이로 if (cs.display === "none" || n.getBoundingClientRect().height < 10) return null; const r = n.getBoundingClientRect(); return { h: r.height, pb: parseFloat(cs.paddingBottom), fixed: cs.position === "fixed", bottom: r.bottom, vh: innerHeight }; }); ok(!!nav, S, `${app}/${v}@${w} 바텀 내비 노출`); if (nav) { ok(nav.fixed && nav.h >= 56, S, `${app}/${v}@${w} 내비 고정·높이`, `${nav.h.toFixed(0)}px`); ok(Math.abs(nav.bottom - nav.vh) <= 1, S, `${app}/${v}@${w} 내비 화면 하단 밀착`, nav.bottom.toFixed(0) + "/" + nav.vh); } const padBottom = await page.evaluate(() => { const el = document.querySelector(".main") || document.querySelector(".layout"); return parseFloat(getComputedStyle(el).paddingBottom); }); ok(padBottom >= 64, S, `${app}/${v}@${w} 본문 하단 여백(내비 확보)`, padBottom + "px"); } if (w === 390) { for (const v of cfg.views) { await switchView(app, v); const taps = await page.evaluate(() => window.__tapSweep()); okN(taps.filter((t) => !t.bad).length, taps.length, "M1 터치타깃", `${app}/${v} ${taps.length}개`, taps.filter((t) => t.bad).slice(0, 3).map((t) => `${t.key} ${t.w}x${t.h}<${t.min}`).join(", ")); const contrast = await page.evaluate(() => window.__contrastSweep()); okN(contrast.checked - contrast.bad.length, contrast.checked, "M2 대비", `${app}/${v}`, contrast.bad.slice(0, 3).map((b) => `${b.txt} ${b.ratio}:1`).join(", ")); } } } } // ════════ S7 — 뒤로 가기(히스토리 = 앱 내 내비게이션) ════════ for (const [app, cfg] of Object.entries(APPS)) { const S = "S7 뒤로 가기"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp(app); await switchView(app, cfg.views[1]); await switchView(app, cfg.views[2]); await switchView(app, cfg.views[3]); ok(await isOn(cfg.views[3]), S, `${app} 3번째 뷰 진입`); await page.goBack(); await sleep(160); if (!(await stillHere(app))) await openApp(app); ok(await isOn(cfg.views[2]), S, `${app} back→직전 뷰(${cfg.views[2]})`, "히스토리에 뷰 기록 없음(RED) 또는 복원 실패"); await page.goBack(); await sleep(160); if (!(await stillHere(app))) await openApp(app); ok(await isOn(cfg.views[1]), S, `${app} back→2번째 뷰(${cfg.views[1]})`); } // 모달 + back { const S = "S7 뒤로 가기·모달"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp("gaon-lms"); await switchView("gaon-lms", "students"); await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click()); await page.waitForSelector("#stu-dialog[open]"); ok(true, S, "가온 학생 시트 열림"); await page.goBack(); await sleep(260); if (!(await stillHere("gaon-lms"))) await openApp("gaon-lms"); ok(await page.evaluate(() => !document.getElementById("stu-dialog").open), S, "가온 back→시트 닫힘"); ok(await page.evaluate(() => { const v = document.querySelector("#view-students"); return !!v && v.classList.contains("is-on"); }), S, "가온 시트 닫힘 후 뷰 유지"); await openApp("dure-enrollment"); await switchView("dure-enrollment", "catalog"); await page.evaluate(() => document.querySelector('[data-course="hum201"]').click()); await page.waitForSelector("#course-dialog[open]"); await page.goBack(); await sleep(260); if (!(await stillHere("dure-enrollment"))) await openApp("dure-enrollment"); ok(await page.evaluate(() => !document.getElementById("course-dialog").open), S, "두레 back→강의계획 닫힘"); ok(await page.evaluate(() => { const v = document.querySelector("#view-catalog"); return !!v && v.classList.contains("is-on"); }), S, "두레 닫힘 후 뷰 유지"); } // ════════ S8 — 바텀 시트(모바일 모달 문법) ════════ const SHEETS = [ ["gaon-lms", "#stu-dialog", 'document.querySelector(\'[data-view="students"]\')', "document.querySelector('#stu-table [data-stu]')"], ["gaon-lms", "#as-dialog", 'document.querySelector(\'[data-view="grades"]\')', 'document.querySelector(\'[data-assign="a2"]\')'], ["dure-enrollment", "#course-dialog", null, 'document.querySelector(\'[data-course="hum201"]\')'], ]; for (const [app, dlgSel, prepExpr, openExpr] of SHEETS) { const S = "S8 바텀 시트"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp(app); if (prepExpr && (await jsclick(prepExpr, S, `${app}${dlgSel} 진입 준비`))) await sleep(120); const opened = await jsclick(openExpr, S, `${app}${dlgSel} 열기 트리거`); if (!opened) continue; if (!(await jswait(`${dlgSel}[open]`, S, `${app}${dlgSel} 열림`))) continue; await sleep(380); const rect = await page.evaluate((sel) => { const d = document.querySelector(sel); const r = d.getBoundingClientRect(); const cs = getComputedStyle(d); const grip = getComputedStyle(d, "::before"); return { top: r.top, bottom: r.bottom, w: r.width, vw: innerWidth, vh: innerHeight, radius: cs.borderTopLeftRadius, gripW: grip.width, gripOn: grip.content !== "none" && grip.content !== undefined, }; }, dlgSel); ok(Math.abs(rect.bottom - rect.vh) <= 1, S, `${app}${dlgSel} 하단 밀착`, `${rect.bottom}/${rect.vh}`); ok(Math.abs(rect.w - rect.vw) <= 1, S, `${app}${dlgSel} 전폭`, `${rect.w}/${rect.vw}`); ok(parseFloat(rect.radius) > 0, S, `${app}${dlgSel} 상단 라운드`, rect.radius); ok(rect.gripOn && parseFloat(rect.gripW) > 0, S, `${app}${dlgSel} 그래버 표시`, rect.gripW); ok(rect.top >= rect.vh * 0.35, S, `${app}${dlgSel} 하단 65%(썸 존)`, `top ${Math.round((rect.top / rect.vh) * 100)}%`); await page.keyboard.press("Escape"); } // ════════ S9 — 더보기 시트 ════════ for (const [app, cfg] of Object.entries(APPS)) { const S = "S9 더보기"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp(app); ok(await page.evaluate(() => { const b = document.querySelector("[data-more]"); return !!b && !!b.offsetParent; }), S, `${app} 더보기 버튼 존재`); ok(await page.evaluate((n) => document.querySelectorAll(".bnav-item").length === n, cfg.tabs.length + 1), S, `${app} 바텀 내비 구성`); if (!(await jsclick('document.querySelector("[data-more]")', S, `${app} 더보기 열기`))) continue; if (!(await jswait("#more-dialog[open]", S, `${app} 더보기 시트 열림`))) continue; await sleep(220); const moreItems = await page.evaluate(() => document.querySelectorAll(".more-list [data-view]").length); ok(moreItems === cfg.more.length + cfg.tabs.length, S, `${app} 더보기 전체 화면 ${cfg.more.length + cfg.tabs.length}개`, String(moreItems)); const target = cfg.more[cfg.more.length - 1]; const expr = `[...document.querySelectorAll(".more-list [data-view]")].find((b) => b.dataset.view === "${target}")`; if (await jsclick(expr, S, `${app} 더보기→${target} 클릭`)) { await sleep(160); ok(await isOn(target), S, `${app} 더보기→${target} 전환`); ok(await page.evaluate(() => !document.getElementById("more-dialog").open), S, `${app} 선택 후 시트 닫힘`); ok(await page.evaluate(() => { const b = document.querySelector("[data-more]"); return !!b && b.classList.contains("is-current"); }), S, `${app} 더보기 버튼 활성 표시`); } } // ════════ S1 — 강사의 하루(장기 여정 · 실사용 10분+ 상당, 전 9화면 실조작) ════════ { const S = "S1 강사 하루"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp("gaon-lms"); ok((await page.$$("#view-dashboard .headline > div")).length === 3, S, "아침·명단 3카드"); ok(await page.$eval("#view-dashboard", (v) => v.textContent.includes("92%")).catch(() => false), S, "아침·주간 출석률 92%"); await switchView("gaon-lms", "attendance"); for (const [i, expect] of [[1, "△"], [2, "✕"], [3, "○"]]) { if (!(await jsclick(`document.querySelector("#att-table .att-btn[data-day='0']")`, S, `출석 칸 조작 ${i}`))) continue; await sleep(90); const mark = await page.$eval("#att-table .att-btn[data-day='0']", (b) => b.textContent).catch(() => null); ok(mark === expect, S, `출석 순환 ${i}→${expect}`, String(mark)); } await switchView("gaon-lms", "students"); await page.type("#q", "한지우"); await sleep(160); ok((await page.$$("#view-students [data-stu]")).length === 1, S, "검색 축소 1명"); if (await jsclick(`document.querySelector("#view-students [data-stu]")`, S, "학생 카드 열기")) { if (await jswait("#stu-dialog[open]", S, "학생 시트 열림")) { const memoBefore = await page.$eval("#stu-note-list", (l) => l.children.length).catch(() => -1); await page.type("#note-input", "3/10 등교 시간 재조정 2차"); await jsclick(`document.querySelector('#note-form button[type=submit]')`, S, "메모 제출"); await sleep(160); ok(await page.$eval("#stu-note-list", (l) => l.children.length).catch(() => -2) === memoBefore + 1, S, "상담 메모 추가"); await page.keyboard.press("Escape"); await sleep(260); } } await switchView("gaon-lms", "grades"); if (await jsclick(`document.querySelector('[data-assign="a2"]')`, S, "채점 모달 열기")) { if (await jswait("#as-dialog[open]", S, "채점 시트 열림")) { await page.$eval("#g1", (el) => { el.value = "150"; el.dispatchEvent(new Event("input", { bubbles: true })); }).catch(() => {}); ok(await page.$eval("#as-save", (b) => b.disabled).catch(() => false), S, "채점 150 차단"); await page.$eval("#g1", (el) => { el.value = "90"; el.dispatchEvent(new Event("input", { bubbles: true })); }).catch(() => {}); await jsclick(`document.getElementById("as-save")`, S, "채점 저장"); await sleep(160); ok(await page.$eval('#as-table [data-assign="a2"]', (b) => b.closest("tr").querySelector(".state").textContent === "채점 완료").catch(() => false), S, "채점 저장→완료"); await page.keyboard.press("Escape"); await sleep(260); } } await switchView("gaon-lms", "files"); if (await jsclick(`document.querySelector("[data-file]")`, S, "자료 배부 토글")) { await sleep(120); ok(await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed") === "false").catch(() => false), S, "자료 배부 상태 반전"); } await switchView("gaon-lms", "calendar"); ok((await page.$$("#up-list li")).length === 7, S, "다가오는 일정 7건"); await switchView("gaon-lms", "counsel"); ok((await page.$$(".counsel-card")).length >= 3, S, "상담 카드 확인"); await switchView("gaon-lms", "report"); ok(await page.$eval("#report-table", (t) => t.textContent.includes("84.4점")).catch(() => false), S, "리포트 실계산"); await switchView("gaon-lms", "notice"); if (await jsclick(`document.querySelector(".notice-row")`, S, "공지 열기")) { ok(await page.$eval(".notice-row", (r) => r.classList.contains("is-open")).catch(() => false), S, "공지 아코디언"); } } // ════════ S2 — 학생 정정기간 여정 ════════ { const S = "S2 정정기간"; await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await openApp("dure-enrollment"); await page.type("#q", "게임"); await sleep(160); ok((await page.$$("#course-list .course-row")).length === 1, S, "검색 게임이론 1건"); await page.$eval("#q", (el) => { el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); }).catch(() => {}); for (const id of ["econ315", "phil115"]) { if (!(await jsclick(`document.querySelector('[data-course="${id}"]')`, S, `강의계획 ${id} 열기`))) continue; if (!(await jswait("#course-dialog[open]", S, `${id} 시트 열림`))) continue; await jsclick(`document.getElementById("cd-star")`, S, `${id} 관심 남기기`); await page.keyboard.press("Escape"); await sleep(280); } await switchView("dure-enrollment", "starred"); ok((await page.$$("#starred-list .course-row")).length === 2, S, "관심 2과목"); ok(await page.$eval("#st-warn", (w) => !w.hidden && w.textContent.includes("학점")).catch(() => false), S, "관심 학점 사전 경고"); await switchView("dure-enrollment", "catalog"); // econ210(월·수 13-14:30) + econ315(화·목) 신청 → bio105(월 14-16)는 econ210 과 겹쳐 차단 for (const [id, blocked] of [["econ210", false], ["econ315", false], ["bio105", true]]) { if (!(await jsclick(`document.querySelector('[data-course="${id}"]')`, S, `${id} 열기`))) continue; if (!(await jswait("#course-dialog[open]", S, `${id} 시트 열림`))) continue; await sleep(160); if (blocked) { ok(await page.$eval("#cd-apply", (b) => b.disabled).catch(() => false), S, "생명과학 겹침 차단(econ210 월 13-14:30)"); ok(await page.$eval("#cd-warn", (w) => !w.hidden && w.textContent.includes("겹칩니다")).catch(() => false), S, "겹침 사유 안내"); await page.keyboard.press("Escape"); await sleep(280); } else { await jsclick(`document.getElementById("cd-apply")`, S, `${id} 신청`); await sleep(160); } } ok(await page.$eval("#credit-now", (c) => c.textContent === "6").catch(() => false), S, "신청 6학점"); await switchView("dure-enrollment", "enrollments"); if (await jsclick(`document.querySelector("[data-cancel]")`, S, "신청 취소")) { await sleep(160); ok(await page.$eval("#en-credits", (c) => c.textContent === "3").catch(() => false), S, "취소 후 3학점"); if (await jsclick(`document.querySelector("#en-status .undo-btn")`, S, "실행취소 클릭")) { await sleep(160); ok(await page.$eval("#en-credits", (c) => c.textContent === "6").catch(() => false), S, "실행취소 복원 6학점"); } } await switchView("dure-enrollment", "credits"); ok(await page.$eval("#cr-stats", (s) => s.textContent.includes("6학점")).catch(() => false), S, "이수 요약 6학점 반영"); ok(await page.$eval("#cr-table", (t) => t.textContent.includes("+ 6")).catch(() => false), S, "이수 표 전공 +6 반영"); await switchView("dure-enrollment", "catalog"); ok((await page.$$(".tt-block")).length === 4, S, "시간표 블록 4(2과목×2일)"); } // ════════ 리포트 ════════ await browser.close(); const total = passCount + failCount; const report = [ `# E2E 시나리오 게이트 — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`, ``, `단언 ${total} · 통과 ${passCount} · 실패 ${failCount} — ${failCount ? "**RED/미통과**" : "**GREEN — 전 단언 통과**"}`, ``, `| 스위트 | 통과 | 실패 |`, `|---|---|---|`, ...[...suites.entries()].map(([k, v]) => `| ${k} | ${v.pass} | ${v.fail} |`), ...(failCount ? ["", "## 실패 목록(최대 60)", ...failures.slice(0, 60).map((f) => `- ${f}`)] : []), ].join("\n"); fs.writeFileSync(path.join(here, "e2e-report.md"), report); console.log(`단언 ${total} · 통과 ${passCount} · 실패 ${failCount}`); console.log([...suites.entries()].map(([k, v]) => `${k}: ${v.pass}/${v.pass + v.fail}`).join(" · ")); console.log(failures.slice(0, 12).map((f) => "FAIL " + f).join("\n")); if (failures.length > 12) console.log(`… 외 ${failures.length - 12}건`); console.log(`\nE2E: ${failCount ? `${failCount} FAIL (RED/미통과)` : "ALL GREEN"} — 리포트 tools/e2e-report.md`); process.exit(failCount ? 1 : 0);