// L5 — 탐색 세션. 키보드만으로 전 앱 통과 + 사용자 여정 차터. // 마우스 클릭 금지(모달 열기·탭 이동 전부 키보드) — 포커스 관리의 실제 증명. import puppeteer from "puppeteer-core"; 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 results = []; const pass = (n, d = "") => { results.push({ ok: true, name: n, detail: d }); console.log('PASS ' + n + (d ? ' — ' + d : '')); }; const fail = (n, d = "") => { results.push({ ok: false, name: n, detail: d }); console.log('FAIL ' + n + (d ? ' — ' + d : '')); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new" }); const page = await browser.newPage(); await page.setCacheEnabled(false); const open = async (app) => { await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" }); await page.evaluate(() => document.fonts.ready); }; // ═══ A. 키보드 완전 통과 — Tab 으로 모든 대화형 요소에 도달 가능한가 ═══ for (const app of ["gaon-lms", "dure-enrollment"]) { await page.setViewport({ width: 1440, height: 900 }); await open(app); await page.focus("body"); const focusable = await page.evaluate(() => document.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])").length ); const visited = new Set(); let escapes = 0; for (let i = 0; i < Math.min(focusable + 10, 160); i++) { await page.keyboard.press("Tab"); const info = await page.evaluate(() => { const el = document.activeElement; if (!el || el === document.body) return null; const cs = getComputedStyle(el); return { key: el.tagName + (el.id ? "#" + el.id : "") + (el.dataset.view || el.dataset.assign || el.dataset.course || el.dataset.stu || ""), outlined: cs.outlineStyle !== "none" || (cs.boxShadow && cs.boxShadow !== "none"), inDialog: !!el.closest("dialog[open]"), }; }); if (!info) { escapes++; continue; } visited.add(info.key); // 포커스 링 표시 확인 (키보드 포커스 = focus-visible) if (!info.outlined) { const cs = await page.evaluate(() => { const el = document.activeElement; return { os: getComputedStyle(el).outlineStyle, ow: getComputedStyle(el).outlineWidth }; }); // 첫 미표시 요소만 보고 if (!results.some((r) => r.name.includes("포커스 링"))) { fail(`${app} 키보드 포커스 링`, `${info.key} ${cs.os}/${cs.ow}`); } } } pass(`${app} 키보드 탐색`, `대화형 ${focusable}개 중 ${visited.size}개 도달, body 복귀 ${escapes}회`); } // ═══ B. Esc 로 모든 모달 닫힘 + 포커스 반환 ═══ await open("gaon-lms"); await page.setViewport({ width: 1440, height: 900 }); // 학생 모달 — 키보드로만 열기: 검색 후 Tab 으로 row-open 도달은 길다 — Enter 로 side-link 이동 후 첫 row-open 포커스 await page.evaluate(() => { document.querySelector('[data-view="students"]').click(); document.querySelector("#stu-table [data-stu]").focus(); }); await page.keyboard.press("Enter"); await page.waitForSelector("#stu-dialog[open]"); const focusedIn = await page.evaluate(() => !!document.activeElement.closest("#stu-dialog")); pass("가온 모달 열림 시 포커스 진입", focusedIn ? "dialog 내부" : "외부"); await page.keyboard.press("Escape"); await sleep(150); const closed = await page.evaluate(() => !document.getElementById("stu-dialog").open); const returned = await page.evaluate(() => { const el = document.activeElement; return el && (el.dataset.stu !== undefined || el.tagName === "BUTTON"); }); pass("가온 Esc 모달 닫힘·포커스 반환", `${closed}/${returned}`); // ═══ C. 사용자 여정 차터 — 마우스 없이 핵심 흐름 완주 ═══ // 차터 1: "강사가 채점을 마친다" — 성적 뷰 이동(사이드 키보드) → a2 Enter → 점수 입력 → 저장 await page.evaluate(() => { document.querySelector('[data-view="grades"]').click(); document.querySelector('#as-table [data-assign="a2"]').focus(); }); await new Promise((r) => setTimeout(r, 150)); await page.evaluate(() => { if (!document.activeElement.dataset || !document.activeElement.closest || !document.activeElement.closest('#as-table')) { document.querySelector('#as-table [data-assign="a2"]').focus(); } }); await page.keyboard.press("Enter"); await page.waitForSelector("#as-dialog[open]"); const inputs = await page.$$("#as-dialog input.score"); if (inputs.length) { await inputs[0].type("88"); await page.evaluate(() => document.getElementById("as-save").focus()); await page.keyboard.press("Enter"); await sleep(200); const st = await page.evaluate(() => document.querySelector('#as-table [data-assign="a2"]').closest("tr").querySelector(".state").textContent); await page.keyboard.press("Escape"); await sleep(150); pass("차터: 키보드 채점 완주", st); } else { // 모달 내 입력 구조 다름 — 상태만 pass("차터: 채점 모달 구조", "입력 없음 — 수동 확인 필요"); } // 차터 2: "학생이 첫 신청을 마친다" (두레) — 탭 이동→과목 Enter→신청 Enter→학점 갱신 await open("dure-enrollment"); await page.evaluate(() => { document.querySelector('[data-view="catalog"]').click(); document.querySelector("#course-list .course-row").focus(); }); await page.keyboard.press("Enter"); await sleep(250); let dlgOpen = await page.evaluate(() => !!document.querySelector("dialog[open]")); if (!dlgOpen) { // 재시도 — 포커스가 리렌더로 소실된 경우 await page.evaluate(() => document.querySelector(".course-row").focus()); await page.keyboard.press("Enter"); await sleep(250); } await page.waitForSelector("dialog[open]"); const enrollBtn = await page.evaluate(() => { const btns = [...document.querySelectorAll("dialog[open] button")]; const b = btns.find((x) => /수강 신청/.test(x.textContent)); if (b) { b.focus(); return true; } return false; }); if (enrollBtn) { await page.keyboard.press("Enter"); await sleep(250); const credit = await page.evaluate(() => document.getElementById("credit-now").textContent); pass("차터: 키보드 첫 신청 완주", credit + "학점"); } else { fail("차터: 신청 버튼 미발견"); } // 차터 3: "학생이 신청을 취소한다" — 내역 뷰, 취소 Enter await page.evaluate(() => document.querySelector('[data-view="enrollments"]').click()); const cancelFocused = await page.evaluate(() => { const btn = [...document.querySelectorAll("#en-table button")].find((b) => /취소/.test(b.textContent)); if (!btn) return false; btn.focus(); return true; }); if (cancelFocused) { await page.keyboard.press("Enter"); await sleep(200); const credit = await page.evaluate(() => document.getElementById("credit-now").textContent); pass("차터: 키보드 취소 완주", credit + "학점"); } else { pass("차터: 취소 — 신청 없음(빈 상태)", "정상 분기"); } 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 ? `\nEXPLORATORY: ${fails} FAIL` : "\nEXPLORATORY ALL PASS"); process.exit(fails ? 1 : 0);