designpaca/apps/site/tools/exploratory.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

166 lines
7.7 KiB
JavaScript

// 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].evaluate((el) => { el.value = ""; }); // number 입력은 선택-덮어쓰기가 안 된다 — 비우고 타이핑
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);
if (st === "채점 완료") pass("차터: 키보드 채점 완주", st);
else fail("차터: 키보드 채점 완주", 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);