vignette/apps/web/e2e/breakpoint-sweep.spec.ts
Yun Chan cf899b7f15 유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정
- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나
  웹 진입점이 없던 2결함
- a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩
  그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정
- uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정
- breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정
  — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로
- 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN),
  직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소,
  교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기
- 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json,
  SSOT 체커 PASS(59)
2026-08-18 11:47:58 +09:00

898 lines
42 KiB
TypeScript

/* =====================================================================
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<string[]> {
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<number>();
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<string, string[]> = {
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<string, number[]>;
/** 어떤 페이지에도 매핑되지 않아 공통으로 승격된 CSS(디버깅용). */
unmapped: string[];
}
async function buildBreakpointIndex(): Promise<BreakpointIndex> {
const files = await collectCssFiles(SRC_DIR);
const perFile = new Map<string, number[]>();
for (const file of files) {
perFile.set(toKey(file), extractBreakpoints(await fs.readFile(file, "utf8")));
}
const owned = new Set<string>();
for (const list of Object.values(PAGE_CSS)) for (const key of list) owned.add(key);
const common = new Set<number>();
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<string, number[]> = {};
for (const [page, list] of Object.entries(PAGE_CSS)) {
const set = new Set<number>();
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<number>();
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연폭 세트는 최대한 함께 남긴다.
// keep 은 "경계에 이미 있다고 제외된 devices" 가 아니라 전체 DEVICE_WIDTHS 로
// 시드한다. 아니면 브레이크포인트와 겹치는 실기기 폭(예: 360)이 솎아내기에서
// 빠져 arrayContaining(DEVICE_WIDTHS) 계약이 깨진다.
const keep = new Set<number>(DEVICE_WIDTHS.map(clampWidth));
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<HTMLElement>("body *"));
const styles = new Map<Element, CSSStyleDeclaration>();
const rects = new Map<Element, DOMRect>();
// 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<Element, boolean>();
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<Element, Element | null>();
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<Element, { overshoot: number; node: Element }>();
const worstClipY = new Map<Element, { overshoot: number; node: Element }>();
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<HTMLElement>(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<Element>();
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<void>((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<KnownAppDefect>();
/** 한 라우트를 모든 폭에서 스윕하고 결함을 모아 반환한다. */
async function sweepRoute(page: Page, route: RouteCase, widths: number[]): Promise<WidthFailure[]> {
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<HTMLElement>(".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] ?? [])])]);
}
/** 해당 페이지가 폭 상한을 넘어 솎아내기가 일어났는지 — 3연폭 계약의 예외 조건. */
function isPrunedPage(page: keyof typeof PAGE_CSS) {
const candidate = new Set<number>(DEVICE_WIDTHS.map(clampWidth));
for (const bp of [...new Set([...index.common, ...(index.byPage[page] ?? [])])]) {
for (const delta of [-1, 0, 1]) candidate.add(clampWidth(bp + delta));
}
return candidate.size > MAX_WIDTHS_PER_ROUTE;
}
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<keyof typeof PAGE_CSS>) {
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 (isPrunedPage(pageKey)) 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<keyof typeof PAGE_CSS>) {
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<string, unknown>;
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 에서 삭제하라`,
);
}
}
});
});