designpaca/apps/site/tools/e2e.mjs
Yun Chan 2b7fa32b5b
All checks were successful
ci / build (push) Successful in 51s
release / release (push) Successful in 2m34s
feat: 한국어 우선 쇼케이스 정리
2026-08-26 18:14:41 +09:00

538 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);