/* ===================================================================== breakpoint-sweep.spec.ts — 브레이크포인트 경계 스윕 게이트. 왜 필요한가: 기존 layout-visual-gate.spec.ts 는 고정 7폭(390/720/861/900/1024/1280/1440)에서 "문서 가로 오버플로 0" 만 검사했다. 그래서 아래 3건은 구조적으로 잡히지 않았다. (1) 설정 서브네비 라벨 7개가 721~1080px 구간에서 폭 0 으로 붕괴 (2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 195px 잘림 (3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖 이탈 셋 다 문서 오버플로는 0 이고, 고정 7폭 사이의 "경계 안쪽" 에서만 나타난다. 이 스펙이 하는 일: 1. src 아래 모든 .css 의 @media min-width/max-width px 값을 실행 시점에 파싱한다. (하드코딩 없음 — CSS 에 브레이크포인트가 추가되면 자동으로 커버된다) 2. 각 브레이크포인트 B 마다 B-1 / B / B+1 을 만들고 실기기 대표 폭을 합쳐 페이지별 테스트 폭 목록을 만든다. 3. 각 폭에서 겹침 / 잘림 / 폭·높이 붕괴 / 화면 밖 / 문서 가로 오버플로 5종을 DOM 실측으로 검출한다. 4. 실패 메시지에 페이지 · 폭 · 결함 종류 · 요소 · 실측 수치를 모두 담는다. 실행: npx playwright test e2e/breakpoint-sweep.spec.ts --reporter=list (또는 npm run e2e:single-run — @single-run 태그로 별도 프로젝트에서 돈다) ===================================================================== */ import { promises as fs } from "node:fs"; import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { fetchAvailablePersona, signInAsAdmin, signInAsLearner, signInAsTeacher, } from "./support"; /* ───────────────────────────────────────────────────────────────────── 1) CSS 브레이크포인트 추출 ───────────────────────────────────────────────────────────────────── */ const SRC_DIR = path.join(process.cwd(), "src"); /** 뷰포트 클램프 범위 — 320px 미만/1600px 초과 폭은 지원 대상이 아니다. */ const MIN_WIDTH = 320; const MAX_WIDTH = 1600; /** 실기기 대표 폭. 브레이크포인트 경계와 무관하게 항상 확인한다. */ const DEVICE_WIDTHS = [320, 360, 375, 390, 414, 768, 820, 1024, 1280, 1366, 1440, 1536]; /** 뷰포트 높이 — 폭 스윕이 목적이라 높이는 고정해 결과를 결정적으로 만든다. */ const SWEEP_HEIGHT = 900; /** 한 라우트에서 확인할 최대 폭 수 — 런타임 상한. */ const MAX_WIDTHS_PER_ROUTE = 44; /** src 아래 모든 .css 파일을 posix 상대경로로 수집. */ async function collectCssFiles(dir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); const out: string[] = []; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...(await collectCssFiles(full))); else if (entry.name.endsWith(".css")) out.push(full); } return out; } function toKey(fullPath: string) { return path.relative(SRC_DIR, fullPath).split(path.sep).join("/"); } /** * @media 프렐류드에서 min-width/max-width 의 px 값을 뽑는다. * CSS 주석 안의 예시 표기(settings.css 의 설명 주석 등)가 잡히지 않도록 * 블록 주석을 먼저 제거한다. */ function extractBreakpoints(css: string): number[] { const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ""); const found = new Set(); const mediaRe = /@media([^{]+)\{/g; let media: RegExpExecArray | null; while ((media = mediaRe.exec(stripped)) !== null) { const featureRe = /\((?:min|max)-width:\s*(\d+(?:\.\d+)?)px\)/g; let feature: RegExpExecArray | null; while ((feature = featureRe.exec(media[1])) !== null) { found.add(Math.round(Number(feature[1]))); } } return [...found].sort((a, b) => a - b); } /* ───────────────────────────────────────────────────────────────────── 2) CSS 파일 → 페이지 매핑 ───────────────────────────────────────────────────────────────────── */ /** 전 페이지 공통으로 취급하는 CSS. 여기 브레이크포인트는 모든 라우트에 적용된다. */ const COMMON_CSS = new Set([ "styles/global.css", "styles/tokens.css", "components/shell/shell.css", "components/auth/auth-shell.css", "components/ui/ui.css", ]); /** 페이지 키 → 그 페이지가 소유한 CSS 파일. 여기 없는 CSS 는 공통으로 fallback 한다. */ const PAGE_CSS: Record = { login: ["pages/login/login.css"], onboarding: ["pages/onboarding.css"], pending: ["pages/pending-approval.css"], "avatar-preview": ["pages/avatar-preview.css", "components/avatar/client-avatar.css"], "learner-home": ["pages/learner-home.css"], "avatar-lab": ["pages/avatar-expression-lab.css", "components/avatar/client-avatar.css"], session: ["pages/session/session.css", "components/avatar/client-avatar.css"], "session-review": ["pages/session-review/session-review.css"], professor: ["pages/professor.css"], "persona-studio": ["pages/persona-studio.css"], "admin-console": ["pages/admin/admin-console.css"], "admin-ai": ["pages/admin/admin-ai.css"], settings: ["pages/settings/settings.css"], }; interface BreakpointIndex { /** 공통 CSS + 매핑되지 않은 CSS 에서 나온 브레이크포인트. */ common: number[]; /** 페이지 키별 고유 브레이크포인트. */ byPage: Record; /** 어떤 페이지에도 매핑되지 않아 공통으로 승격된 CSS(디버깅용). */ unmapped: string[]; } async function buildBreakpointIndex(): Promise { const files = await collectCssFiles(SRC_DIR); const perFile = new Map(); for (const file of files) { perFile.set(toKey(file), extractBreakpoints(await fs.readFile(file, "utf8"))); } const owned = new Set(); for (const list of Object.values(PAGE_CSS)) for (const key of list) owned.add(key); const common = new Set(); const unmapped: string[] = []; for (const [key, values] of perFile) { if (owned.has(key)) continue; // 공통 CSS 이거나, 아직 어떤 페이지에도 매핑되지 않은 새 CSS → 전 페이지 공통 취급. // (매핑 누락 때문에 커버리지가 조용히 사라지는 것보다 과잉 커버가 안전하다) if (!COMMON_CSS.has(key)) unmapped.push(key); for (const value of values) common.add(value); } const byPage: Record = {}; for (const [page, list] of Object.entries(PAGE_CSS)) { const set = new Set(); for (const key of list) { const values = perFile.get(key); expect(values, `PAGE_CSS 매핑이 가리키는 ${key} 가 src 에 없다`).toBeDefined(); for (const value of values ?? []) set.add(value); } byPage[page] = [...set].sort((a, b) => a - b); } return { common: [...common].sort((a, b) => a - b), byPage, unmapped }; } const clampWidth = (value: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value)); /** 브레이크포인트 목록 → B-1 / B / B+1 + 실기기 폭 (중복 제거, 클램프, 상한 적용). */ function widthsFor(breakpoints: number[]): number[] { const boundary = new Set(); for (const bp of breakpoints) { for (const delta of [-1, 0, 1]) boundary.add(clampWidth(bp + delta)); } const devices = DEVICE_WIDTHS.map(clampWidth).filter((w) => !boundary.has(w)); const ordered = [...boundary].sort((a, b) => a - b); let merged = [...ordered, ...devices]; if (merged.length > MAX_WIDTHS_PER_ROUTE) { // 상한을 넘으면 실기기 폭을 먼저 유지하고(사용자가 실제로 보는 폭), // 경계 폭은 균등 간격으로 솎아 낸다 — 경계 3연폭 세트는 최대한 함께 남긴다. const keep = new Set(devices); const budget = MAX_WIDTHS_PER_ROUTE - keep.size; const step = Math.max(1, Math.ceil(ordered.length / Math.max(1, budget))); for (let i = 0; i < ordered.length; i += step) keep.add(ordered[i]); merged = [...keep]; } return [...new Set(merged)].sort((a, b) => a - b); } /* ───────────────────────────────────────────────────────────────────── 3) 브라우저에서 도는 결함 검출기 ───────────────────────────────────────────────────────────────────── */ interface Finding { kind: "overlap" | "clip-x" | "clip-y" | "collapse" | "offscreen" | "doc-overflow"; element: string; detail: string; } /** * 브라우저 컨텍스트에서 실행되는 레이아웃 결함 스캐너. * 5종을 검출하되, 아래 4종 오탐은 명시적으로 제외한다. * - -webkit-line-clamp (의도된 줄 자름) * - visually-hidden 패턴 (width/height 1px, clip: rect(...)) * - border-radius >= 40px 원형 마스크 * - line-height 가 폰트 content-area 보다 작아 생기는 1~2px 세로 오버슛 */ function scanLayoutDefects(): Finding[] { const doc = document.documentElement; const viewportWidth = doc.clientWidth; const findings: Finding[] = []; const all = Array.from(document.querySelectorAll("body *")); const styles = new Map(); const rects = new Map(); // body 도 overflow 경계가 될 수 있으므로 캐시에 포함한다(순회 대상은 아니다). for (const el of [document.body, ...all]) { styles.set(el, window.getComputedStyle(el)); rects.set(el, el.getBoundingClientRect()); } const cs = (el: Element) => styles.get(el) ?? window.getComputedStyle(el); const rc = (el: Element) => rects.get(el) ?? el.getBoundingClientRect(); function classOf(el: Element) { const raw = (el as HTMLElement).className as unknown; const source = typeof raw === "string" ? raw : raw && typeof raw === "object" && "baseVal" in raw ? String((raw as SVGAnimatedString).baseVal) : ""; return source.trim().split(/\s+/).filter(Boolean).slice(0, 3).join("."); } /** 요소 식별 문자열. 클래스가 없으면 부모 클래스를 붙여 디버깅 가능한 좌표를 만든다. */ function describe(el: Element) { const cls = classOf(el); const tag = el.tagName.toLowerCase(); const anchor = cls ? `${tag}.${cls}` : `${tag}${el.parentElement && classOf(el.parentElement) ? `@${classOf(el.parentElement)}` : ""}`; const text = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40); return `${anchor}${text ? ` "${text}"` : ""}`; } const visibleCache = new Map(); function isVisible(el: Element): boolean { const cached = visibleCache.get(el); if (cached !== undefined) return cached; const style = cs(el); const rect = rc(el); let result = true; if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) result = false; else if (rect.width <= 0 && rect.height <= 0) result = false; else if (el.getAttribute("aria-hidden") === "true") result = false; else if (el.hasAttribute("hidden") || el.hasAttribute("inert")) result = false; else if (el.parentElement && el.parentElement !== document.body && !isVisible(el.parentElement)) result = false; visibleCache.set(el, result); return result; } /** 장식 레이어: 히트테스트 대상이 아니거나 접근성 트리에서 숨겨진 요소. */ function isDecorative(el: Element) { return cs(el).pointerEvents === "none" || el.getAttribute("aria-hidden") === "true"; } /** 오탐 제외 (2): visually-hidden 패턴 (1px 박스 / clip / clip-path inset(50%)). */ function isVisuallyHidden(el: Element) { const style = cs(el); const rect = rc(el); if (rect.width <= 2 && rect.height <= 2) return true; if (style.clip && style.clip !== "auto") return true; if (style.clipPath && style.clipPath.includes("inset(50%")) return true; return false; } /** 오탐 제외 (1): -webkit-line-clamp 가 걸린 요소는 세로 자름이 의도다. */ function hasLineClamp(el: Element) { const style = cs(el); const value = style.getPropertyValue("-webkit-line-clamp") || (style as unknown as { webkitLineClamp?: string }).webkitLineClamp || "none"; return value !== "none" && value !== "" && value !== "0"; } /** 오탐 제외 (3): border-radius 40px 이상 원형 마스크(아바타 스테이지 등). */ function hasCircularMask(el: Element) { const style = cs(el); return ( ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"] as const ).some((key) => Number.parseFloat(style[key]) >= 40); } /** 오탐 제외 (4): line-height < fontSize * 1.15 → 1~2px 세로 오버슛은 글리프가 안 잘린다. */ function hasTightLineHeight(el: Element) { const style = cs(el); const fontSize = Number.parseFloat(style.fontSize) || 16; const lineHeight = style.lineHeight === "normal" ? fontSize * 1.2 : Number.parseFloat(style.lineHeight) || fontSize * 1.2; return lineHeight < fontSize * 1.15; } function hasDirectText(el: Element) { for (const node of Array.from(el.childNodes)) { if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true; } return false; } const clipsX = (s: CSSStyleDeclaration) => s.overflowX === "hidden" || s.overflowX === "clip"; const clipsY = (s: CSSStyleDeclaration) => s.overflowY === "hidden" || s.overflowY === "clip"; const scrollsX = (s: CSSStyleDeclaration) => s.overflowX === "auto" || s.overflowX === "scroll"; const scrollsY = (s: CSSStyleDeclaration) => s.overflowY === "auto" || s.overflowY === "scroll"; const isOverflowBoundary = (s: CSSStyleDeclaration) => clipsX(s) || clipsY(s) || scrollsX(s) || scrollsY(s); /* ── (A) 잘림 ──────────────────────────────────────────────────────── overflow hidden/clip 컨테이너가 자기 콘텐츠를 잘라내는 경우. scrollWidth/scrollHeight 만 보면 ::after 장식(음수 offset 으로 깔아 둔 배경 아트, 예: .lh-session-focus::after)까지 "잘림" 으로 잡혀 노이즈가 된다. 그래서 자식 요소가 있는 컨테이너는 "in-flow 자손 rect vs client box" 로 판정한다. absolute/fixed 자손과 pointer-events:none 장식은 제외한다. 성능: 컨테이너마다 자손을 훑으면 O(n^2) 이라 느리다. 대신 요소마다 "최근접 overflow 경계 조상" 을 한 번만 계산(O(n))하고, 각 요소를 자기 경계와만 비교한다. 결과는 동일하고 중복 보고도 자동으로 없어진다. */ const clipCandidate = (el: Element) => { const style = cs(el); if (!clipsX(style) && !clipsY(style)) return false; if (!isVisible(el) || isVisuallyHidden(el) || hasCircularMask(el)) return false; const tag = el.tagName.toLowerCase(); // 폼 컨트롤은 설계상 자기 값을 스크롤한다(키보드로 전부 도달 가능). if (tag === "input" || tag === "textarea" || tag === "select") return false; return el.clientWidth > 0 || el.clientHeight > 0; }; // 최근접 overflow 경계(또는 containing-block 을 바꾸는 absolute/fixed 조상). const nearestBoundary = new Map(); for (const el of all) { const parent = el.parentElement; if (!parent || !styles.has(parent)) { nearestBoundary.set(el, null); continue; } const parentStyle = cs(parent); if (isOverflowBoundary(parentStyle) || parentStyle.position === "absolute" || parentStyle.position === "fixed") { nearestBoundary.set(el, parent); } else { nearestBoundary.set(el, nearestBoundary.get(parent) ?? null); } } // 컨테이너별 최악 오버슛만 남긴다(요소 하나당 한 줄 보고). const worstClipX = new Map(); const worstClipY = new Map(); const clientBox = (el: Element) => { const style = cs(el); const rect = rc(el); const left = rect.left + (Number.parseFloat(style.borderLeftWidth) || 0); const top = rect.top + (Number.parseFloat(style.borderTopWidth) || 0); return { left, top, right: left + el.clientWidth, bottom: top + el.clientHeight }; }; for (const el of all) { if (!isVisible(el) || isDecorative(el) || isVisuallyHidden(el)) continue; const style = cs(el); if (style.position === "absolute" || style.position === "fixed") continue; const boundary = nearestBoundary.get(el); if (!boundary || !clipCandidate(boundary)) continue; const rect = rc(el); if (rect.width <= 0 || rect.height <= 0) continue; const boundaryStyle = cs(boundary); const box = clientBox(boundary); const overshootX = Math.max(rect.right - box.right, box.left - rect.left); const overshootY = Math.max(rect.bottom - box.bottom, box.top - rect.top); if (clipsX(boundaryStyle) && boundaryStyle.textOverflow !== "ellipsis" && overshootX > 1) { const prev = worstClipX.get(boundary); if (!prev || overshootX > prev.overshoot) worstClipX.set(boundary, { overshoot: overshootX, node: el }); } if ( clipsY(boundaryStyle) && !hasLineClamp(boundary) && overshootY > 1 && // 오탐 제외 (4): line-height 가 content-area 보다 작아 생기는 1~2px 오버슛. !(overshootY <= 2 && hasTightLineHeight(el)) ) { const prev = worstClipY.get(boundary); if (!prev || overshootY > prev.overshoot) worstClipY.set(boundary, { overshoot: overshootY, node: el }); } } for (const [boundary, worst] of worstClipX) { findings.push({ kind: "clip-x", element: describe(boundary), detail: `자식 ${describe(worst.node)} 이(가) 가로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientWidth ${boundary.clientWidth}px)`, }); } for (const [boundary, worst] of worstClipY) { findings.push({ kind: "clip-y", element: describe(boundary), detail: `자식 ${describe(worst.node)} 이(가) 세로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientHeight ${boundary.clientHeight}px)`, }); } // 자식 요소 없이 텍스트만 담은 잎 노드는 rect 비교가 불가능하므로 // scrollWidth/scrollHeight 로 자기 콘텐츠가 잘렸는지 본다. for (const el of all) { if (el.children.length > 0 || !clipCandidate(el)) continue; const style = cs(el); const dx = Math.ceil(el.scrollWidth - el.clientWidth); const dy = Math.ceil(el.scrollHeight - el.clientHeight); if (clipsX(style) && style.textOverflow !== "ellipsis" && dx > 1) { findings.push({ kind: "clip-x", element: describe(el), detail: `자기 텍스트가 가로로 ${dx}px 잘림 (scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth})`, }); } if (clipsY(style) && !hasLineClamp(el) && dy > 1 && !(dy <= 2 && hasTightLineHeight(el))) { findings.push({ kind: "clip-y", element: describe(el), detail: `자기 텍스트가 세로로 ${dy}px 잘림 (scrollHeight ${el.scrollHeight} > clientHeight ${el.clientHeight})`, }); } } /* ── (B) 폭/높이 붕괴 ──────────────────────────────────────────────── 직접 텍스트 자식이 있는데 rect 폭 또는 높이가 1px 미만 → 글자가 사라진다. (설정 서브네비 라벨 폭 0 결함이 여기에 잡힌다) */ for (const el of all) { if (!isVisible(el) || !hasDirectText(el) || isVisuallyHidden(el)) continue; const rect = rc(el); if (rect.width < 1 || rect.height < 1) { findings.push({ kind: "collapse", element: describe(el), detail: `텍스트가 있는데 박스가 붕괴 (width ${rect.width.toFixed(2)}px, height ${rect.height.toFixed(2)}px)`, }); } } /* ── (C) 화면 밖 조작 요소 ─────────────────────────────────────────── 조작 요소가 뷰포트 좌우 밖으로 나감. 단 조상에 실제 가로 스크롤이 있으면 사용자가 스크롤로 도달할 수 있으므로 정상으로 본다. */ const interactiveSelector = "button,a[href],input,select,textarea,[role='button'],[role='tab']"; function hasHorizontalScrollAncestor(el: Element) { let node: Element | null = el.parentElement; while (node && node !== document.body) { const style = cs(node); if (scrollsX(style) && node.scrollWidth - node.clientWidth > 1) return true; node = node.parentElement; } return false; } for (const el of Array.from(document.querySelectorAll(interactiveSelector))) { if (!styles.has(el) || !isVisible(el) || isVisuallyHidden(el)) continue; const rect = rc(el); if (rect.width <= 0 || rect.height <= 0) continue; if (rect.left >= -1 && rect.right <= viewportWidth + 1) continue; if (hasHorizontalScrollAncestor(el)) continue; findings.push({ kind: "offscreen", element: describe(el), detail: `조작 요소가 뷰포트 밖 (left ${Math.round(rect.left)}px, right ${Math.round(rect.right)}px, viewport ${viewportWidth}px)`, }); } /* ── (D) 문서 가로 오버플로 ──────────────────────────────────────── */ const documentOverflow = Math.ceil(doc.scrollWidth - viewportWidth); if (documentOverflow > 1) { findings.push({ kind: "doc-overflow", element: "html", detail: `문서가 가로로 ${documentOverflow}px 넘침 (scrollWidth ${doc.scrollWidth} > clientWidth ${viewportWidth})`, }); } /* ── (E) 겹침 ──────────────────────────────────────────────────────── 같은 부모의 in-flow 형제끼리 rect 가 교차. absolute/fixed/sticky, pointer-events:none, aria-hidden, float/transform, grid-template-areas 를 쓰는 부모(의도적 겹침 레이아웃)는 제외. */ const parents = new Set(); for (const el of all) if (el.parentElement) parents.add(el.parentElement); for (const parent of parents) { const parentStyle = parent === document.body ? window.getComputedStyle(parent) : cs(parent); if (parentStyle.gridTemplateAreas && parentStyle.gridTemplateAreas !== "none") continue; const siblings = Array.from(parent.children).filter((el) => { if (!styles.has(el) || !isVisible(el)) return false; const style = cs(el); if (style.position !== "static" && style.position !== "relative") return false; if (style.pointerEvents === "none" || style.float !== "none" || style.transform !== "none") return false; if (isDecorative(el) || isVisuallyHidden(el)) return false; const rect = rc(el); return rect.width > 2 && rect.height > 2; }); for (let i = 0; i < siblings.length; i += 1) { for (let j = i + 1; j < siblings.length; j += 1) { const a = rc(siblings[i]); const b = rc(siblings[j]); const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left); const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); if (overlapX > 2 && overlapY > 2) { findings.push({ kind: "overlap", element: `${describe(siblings[i])} ∩ ${describe(siblings[j])}`, detail: `형제 요소가 ${overlapX.toFixed(1)}x${overlapY.toFixed(1)}px 겹침 (부모 ${describe(parent)})`, }); } } } } return findings; } /* ───────────────────────────────────────────────────────────────────── 4) 라우트 정의 & 스윕 실행 ───────────────────────────────────────────────────────────────────── */ interface RouteCase { /** 리포트에 찍히는 이름. */ label: string; /** PAGE_CSS 키 — 이 라우트가 상속할 브레이크포인트 집합. */ page: keyof typeof PAGE_CSS; url: string; /** 렌더 완료 판정 셀렉터. */ ready: string; } /** 폭 변경 후 레이아웃이 안정될 때까지 대기 (rAF 2회 + 폰트/차트 여유). */ async function settleLayout(page: Page) { await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); await page.waitForTimeout(90); } interface WidthFailure { width: number; finding: Finding; } /* ───────────────────────────────────────────────────────────────────── 4-1) 격리(quarantine) — 검출기 오탐이 아니라 "이미 알려진 앱 CSS 결함" ───────────────────────────────────────────────────────────────────── 이 게이트가 처음 돌면서 실제로 찾아낸 결함들이다. CSS 파일은 이 스펙의 담당 범위가 아니라 여기서 고칠 수 없어, 게이트를 초록으로 유지하되 "무엇을 눈감아 주고 있는지" 를 코드에 남기고 실행 로그로 계속 노출한다. - 이 목록은 검출기를 무력화하지 않는다. 라우트·결함종류·요소·폭 구간이 모두 일치할 때만 통과시킨다. 다른 폭이나 다른 요소에서 같은 결함이 생기면 그대로 실패한다. - CSS 가 고쳐지면 해당 항목은 "재현되지 않음" 으로 로그에 찍히므로 그때 이 배열에서 지우면 된다. - 검출기의 검출력 자체는 아래 "검출기 자기검증" 테스트가 매번 보증한다. */ interface KnownAppDefect { route: string; kind: Finding["kind"]; element: RegExp; minWidth: number; maxWidth: number; note: string; } /* 2026-07-27: 최초 3건을 전부 CSS 에서 실제로 고쳐 목록을 비웠다. (전체 스펙 7테스트 실행에서 3건 모두 "격리 항목 미재현" 으로 찍혔다) - learner-home/dashboard · clip-x · .lh-recap__avatar (320~380px) → learner-home.css: 그리드 트랙 명시 + .vg-avatar min-width:0/max-width:100% - learner-home/practice · collapse · b@lh-session-card__title (1181~1200px) → learner-home.css: .lh-preview__hero 를 flex-wrap 으로 바꿔 제목 열 폭 확보 - persona-studio · clip-x · .ps-active-table (761~900px) → persona-studio.css: 트랙 최소치 축소 + .ps-usage-table 영역 가로 스크롤 목록이 비었으므로 이제 어떤 결함도 곧바로 실패한다. 다시 채우지 말고 CSS 를 고쳐라. */ const KNOWN_APP_DEFECTS: KnownAppDefect[] = []; function matchKnownDefect(routeLabel: string, width: number, finding: Finding) { return KNOWN_APP_DEFECTS.find( (known) => known.route === routeLabel && known.kind === finding.kind && known.element.test(finding.element) && width >= known.minWidth && width <= known.maxWidth, ); } /** 격리 항목이 실제로 재현됐는지 추적 — 재현되지 않으면 목록에서 지우라고 알린다. */ const quarantineHits = new Set(); /** 한 라우트를 모든 폭에서 스윕하고 결함을 모아 반환한다. */ async function sweepRoute(page: Page, route: RouteCase, widths: number[]): Promise { await page.goto(route.url, { waitUntil: "domcontentloaded" }); await expect(page.locator(route.ready).first(), `[${route.label}] 렌더 대기 실패: ${route.ready}`).toBeVisible({ timeout: 20_000, }); await settleLayout(page); const failures: WidthFailure[] = []; for (const width of widths) { await page.setViewportSize({ width, height: SWEEP_HEIGHT }); await settleLayout(page); await page.evaluate(() => { window.scrollTo(0, 0); const main = document.querySelector(".vg-main"); if (main) main.scrollTop = 0; }); const findings = await page.evaluate(scanLayoutDefects); for (const finding of findings) { const known = matchKnownDefect(route.label, width, finding); if (known) { quarantineHits.add(known); console.log( `[breakpoint-sweep][격리된 앱 결함] ${route.label} @ ${width}px [${finding.kind}] ${finding.element} → ${finding.detail}`, ); continue; } failures.push({ width, finding }); } } return failures; } function formatFailures(route: RouteCase, widths: number[], failures: WidthFailure[]) { const lines = failures .slice(0, 40) .map((f) => ` · ${route.label} @ ${f.width}px [${f.finding.kind}] ${f.finding.element} → ${f.finding.detail}`); const more = failures.length > 40 ? `\n · … 외 ${failures.length - 40}건` : ""; return `[${route.label}] ${widths.length}개 폭(${widths[0]}~${widths[widths.length - 1]}px) 스윕에서 레이아웃 결함 ${failures.length}건\n${lines.join("\n")}${more}`; } let index: BreakpointIndex; test.beforeAll(async () => { index = await buildBreakpointIndex(); }); /** 페이지 키에 해당하는 최종 폭 목록(공통 + 페이지 고유). */ function widthsForPage(page: keyof typeof PAGE_CSS) { return widthsFor([...new Set([...index.common, ...(index.byPage[page] ?? [])])]); } async function runSweep(page: Page, routes: RouteCase[]) { const report: string[] = []; let total = 0; for (const route of routes) { const widths = widthsForPage(route.page); const failures = await sweepRoute(page, route, widths); if (failures.length) { total += failures.length; report.push(formatFailures(route, widths, failures)); } } expect(total, `브레이크포인트 스윕 결함\n\n${report.join("\n\n")}`).toBe(0); } test.describe("브레이크포인트 경계 스윕 @single-run", () => { test("추출한 브레이크포인트가 CSS 를 실제로 반영한다", async () => { // 검출기가 아니라 "무엇을 볼지" 를 정하는 파서/매핑의 회귀 방지. // 값 자체는 하드코딩하지 않고 "구조가 살아 있는가" 만 본다. expect(index.common.length, "공통 CSS(shell/global/auth-shell 등)에서 브레이크포인트를 하나도 못 찾았다").toBeGreaterThan(0); const distinct = new Set(index.common); for (const values of Object.values(index.byPage)) for (const value of values) distinct.add(value); expect(distinct.size, "CSS 전체에서 추출한 브레이크포인트가 비정상적으로 적다 — 파서가 깨졌을 수 있다").toBeGreaterThan(10); for (const pageKey of Object.keys(PAGE_CSS) as Array) { const widths = widthsForPage(pageKey); expect(Math.min(...widths), `${pageKey}: 클램프 하한 위반`).toBeGreaterThanOrEqual(MIN_WIDTH); expect(Math.max(...widths), `${pageKey}: 클램프 상한 위반`).toBeLessThanOrEqual(MAX_WIDTH); expect(widths.length, `${pageKey}: 라우트당 폭 상한 초과`).toBeLessThanOrEqual(MAX_WIDTHS_PER_ROUTE); // 실기기 대표 폭은 어떤 페이지에서도 빠지면 안 된다. expect(widths, `${pageKey}: 실기기 대표 폭 누락`).toEqual(expect.arrayContaining(DEVICE_WIDTHS)); // 브레이크포인트 B 마다 B-1/B/B+1 이 살아 있는지(클램프 경계 제외). for (const bp of index.byPage[pageKey] ?? []) { if (bp <= MIN_WIDTH || bp >= MAX_WIDTH) continue; if (widths.length >= MAX_WIDTHS_PER_ROUTE) continue; // 상한으로 솎아 낸 경우는 예외 expect(widths, `${pageKey}: 브레이크포인트 ${bp}px 경계 3연폭 누락`).toEqual( expect.arrayContaining([bp - 1, bp, bp + 1]), ); } } if (index.unmapped.length) { // 실패시키지 않고 로그만 — 새 CSS 는 공통으로 승격돼 이미 전 라우트에서 커버된다. console.log(`[breakpoint-sweep] PAGE_CSS 에 없는 CSS(공통 승격): ${index.unmapped.join(", ")}`); } for (const pageKey of Object.keys(PAGE_CSS) as Array) { console.log(`[breakpoint-sweep] ${pageKey}: ${widthsForPage(pageKey).length}폭`); } }); /** * 검출기 자기검증. * * 게이트가 초록이라는 사실만으로는 "검출기가 살아 있다" 를 증명하지 못한다. * (오탐 제외를 과하게 넣어 전부 무시해도 초록이 된다.) * 그래서 이 스펙이 잡아야 했던 실제 결함 3건을 런타임 CSS 주입으로 되살리고, * 검출기가 그 결함을 실제로 보고하는지 매 실행마다 확인한다. * (1) 설정 서브네비 라벨 폭 0 붕괴 → collapse * (2) 회기 프리스타트 패널 잘림 → clip-x * (3) 티켓 "초기화" 버튼 화면 밖 이탈 → offscreen */ test("검출기가 되살린 실제 결함 3건을 실제로 잡는다", async ({ page }) => { test.setTimeout(240_000); async function findingsWith(url: string, ready: string, width: number, css: string) { await page.goto(url, { waitUntil: "domcontentloaded" }); await expect(page.locator(ready).first()).toBeVisible({ timeout: 20_000 }); await page.setViewportSize({ width, height: SWEEP_HEIGHT }); await settleLayout(page); const clean = await page.evaluate(scanLayoutDefects); const handle = await page.addStyleTag({ content: css }); await settleLayout(page); const dirty = await page.evaluate(scanLayoutDefects); await handle.evaluate((node) => { (node as HTMLStyleElement).remove(); }); return { clean, dirty }; } await signInAsLearner(page); const persona = await fetchAvailablePersona(page); // (1) 설정 서브네비 라벨이 721~1080px 구간에서 폭 0 이 되던 결함. const railLabels = await findingsWith( "/settings", ".vg-set", 900, ".vg-set__rail button span{width:0;overflow:hidden;display:inline-block;}", ); expect( railLabels.dirty.filter((f) => f.kind === "collapse").length, `설정 서브네비 라벨 붕괴를 못 잡았다: ${JSON.stringify(railLabels.dirty)}`, ).toBeGreaterThanOrEqual(3); // (2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 잘리던 결함. const prestart = await findingsWith( `/learn/session/${persona.code}`, ".sx-head", 1100, ".vg-main__inner{overflow:hidden;} .sx-prestart{width:1400px;}", ); expect( prestart.dirty.filter((f) => f.kind === "clip-x").length, `프리스타트 패널 잘림을 못 잡았다: ${JSON.stringify(prestart.dirty)}`, ).toBeGreaterThanOrEqual(1); // (3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖으로 나가던 결함. await signInAsAdmin(page); const ticketButton = await findingsWith( "/admin/tickets", ".vgops-root", 1220, ".vgops-ticket-filter .vg-btn:last-child{position:relative;left:400px;}", ); expect( ticketButton.dirty.filter((f) => f.kind === "offscreen").length, `티켓 버튼 화면 밖 이탈을 못 잡았다: ${JSON.stringify(ticketButton.dirty)}`, ).toBeGreaterThanOrEqual(1); // 주입 전에는 같은 결함이 없어야 한다 — 오탐 노이즈로 통과하는 것을 막는다. expect(railLabels.clean.filter((f) => f.kind === "collapse"), "주입 전 설정 화면에 붕괴 오탐").toEqual([]); expect(prestart.clean.filter((f) => f.kind === "clip-x"), "주입 전 프리스타트에 잘림 오탐").toEqual([]); expect(ticketButton.clean.filter((f) => f.kind === "offscreen"), "주입 전 티켓 화면에 이탈 오탐").toEqual([]); }); test("비인증 화면(로그인·아바타 프리뷰)이 모든 경계 폭에서 온전하다", async ({ page }) => { test.setTimeout(240_000); await runSweep(page, [ { label: "login", page: "login", url: "/login", ready: ".lg-root" }, { label: "avatar-preview", page: "avatar-preview", url: "/dev/avatar-preview", ready: ".ap" }, ]); }); test("가입 게이트 화면(온보딩·승인대기)이 모든 경계 폭에서 온전하다", async ({ page }) => { test.setTimeout(240_000); // 온보딩 미완료 사용자를 만들면 OnboardingGate 가 /onboarding 으로 보낸다. const res = await page.request.post("/api/auth/dev-login", { data: { email: `sweep.onboarding.${Date.now()}@hs.ac.kr`, role: "learner", display_name: "Sweep Onboarding", }, }); expect(res.ok(), await res.text()).toBeTruthy(); await runSweep(page, [ { label: "onboarding", page: "onboarding", url: "/onboarding", ready: ".ob-page" }, ]); // 승인 대기 화면은 계정 상태에 의존하므로 /auth/me 응답만 최소로 덮어쓴다. // email 도 함께 짧게 바꾼다 — 이 화면은 이메일을 그대로 노출하는데, // dev-login 용 타임스탬프 이메일은 실제 사용자보다 훨씬 길어서 // "브레이크포인트" 가 아니라 "테스트 데이터 길이" 때문에 패널이 넘친다. await page.route("**/api/auth/me", async (route) => { const response = await route.fetch(); const body = (await response.json()) as Record; await route.fulfill({ response, json: { ...body, email: "pending@hs.ac.kr", account_status: "pending", approval_required: true, }, }); }); await runSweep(page, [ { label: "pending-approval", page: "pending", url: "/pending", ready: ".pa-page" }, ]); await page.unroute("**/api/auth/me"); }); test("learner 화면이 모든 경계 폭에서 온전하다", async ({ page }) => { test.setTimeout(900_000); await signInAsLearner(page); const persona = await fetchAvailablePersona(page); // 리뷰 화면을 실제로 열려면 종료된 회기가 하나 필요하다(AI 턴 생성은 하지 않는다). const created = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic" }, }); expect(created.ok(), await created.text()).toBeTruthy(); const { session_id: endedSessionId } = (await created.json()) as { session_id: string }; await page.request.post(`/api/sessions/${endedSessionId}/end`); await runSweep(page, [ { label: "learner-home/dashboard", page: "learner-home", url: "/learn", ready: ".lh-root" }, { label: "learner-home/practice", page: "learner-home", url: "/learn/practice", ready: ".lh-root" }, { label: "learner-home/history", page: "learner-home", url: "/learn/history", ready: ".lh-root" }, { label: "session/prestart", page: "session", url: `/learn/session/${persona.code}`, ready: ".sx-head", }, { label: "session-review/learner", page: "session-review", url: `/learn/session/${endedSessionId}/review`, ready: ".sr-root", }, { label: "settings/learner", page: "settings", url: "/settings", ready: ".vg-set" }, { label: "avatar-lab", page: "avatar-lab", url: "/learn/avatar-expressions", ready: ".axl", }, ]); }); test("teacher 화면이 모든 경계 폭에서 온전하다", async ({ page }) => { test.setTimeout(420_000); await signInAsTeacher(page); await runSweep(page, [ { label: "professor/dashboard", page: "professor", url: "/teach", ready: ".pf-root" }, { label: "professor/analysis", page: "professor", url: "/teach/analysis", ready: ".pf-root" }, { label: "persona-studio", page: "persona-studio", url: "/teach/personas", ready: ".ps-root" }, { label: "settings/teacher", page: "settings", url: "/settings", ready: ".vg-set" }, ]); }); test("admin 화면이 모든 경계 폭에서 온전하다", async ({ page }) => { test.setTimeout(600_000); await signInAsAdmin(page); await runSweep(page, [ { label: "admin/overview", page: "admin-console", url: "/admin", ready: ".vgops-root" }, { label: "admin/users", page: "admin-console", url: "/admin/users", ready: ".vgops-root" }, { label: "admin/access", page: "admin-console", url: "/admin/access", ready: ".vgops-root" }, { label: "admin/tickets", page: "admin-console", url: "/admin/tickets", ready: ".vgops-root" }, { label: "admin/ai", page: "admin-ai", url: "/admin/ai", ready: ".aic" }, ]); }); test.afterAll(() => { // 격리 목록 위생 관리: 재현되지 않은 항목은 CSS 가 고쳐졌다는 뜻이니 지우면 된다. // (일부 테스트만 -g 로 돌리면 당연히 미재현으로 찍히므로 실패시키지 않는다) for (const known of KNOWN_APP_DEFECTS) { if (!quarantineHits.has(known)) { console.log( `[breakpoint-sweep][격리 항목 미재현] ${known.route} / ${known.kind} / ${known.element} — 고쳐졌다면 KNOWN_APP_DEFECTS 에서 삭제하라`, ); } } }); });