Files
company-haness/.claude/hooks/preview_ui.py
T

556 lines
26 KiB
Python

#!/usr/bin/env python3
"""preview_ui.py — 코드 UI의 **render-health 게이트**를 강제한다.
디자인 시스템 파이프라인의 미리보기 훅(render_consult.py의 형제). Figma 불필요·rate-limit 없음.
핵심 원칙 (이 훅의 존재 이유):
**스크린샷이 존재한다 ≠ 품질이다.**
build rc==0 + PNG 파일 존재만으로 "성공" 처리하면, 앱이 런타임에 크래시해서
#root가 비어 있는 **빈 화면**을 렌더해도, 또는 vite가 빈 번들을 뱉어도 통과한다.
그래서 이 훅은 "build 성공 + PNG 존재"를 통과로 취급하지 **않고** 아래 게이트를
강제한다. 게이트가 하나라도(strict에서) 실패하면 "OK"를 출력하지 않고 비영점 종료한다:
1. build degraded 검출 — rc!=0은 물론, rc==0이어도 JS 번들이 없으면 위장 실패로 간주.
2. 렌더 비어있음 검출 — chrome --dump-dom으로 실제 DOM을 받아 mount 노드(#root/#app)가
비어 있으면(=React가 안 붙음) degraded. PNG는 멀쩡히 저장돼도 이건 실패다.
3. 정적 CSS health 체크 — WCAG AA 대비(contrast) + 키보드 포커스 가시성.
4. 반응형 다중 viewport 캡처(--viewports) — 모바일/태블릿/데스크톱 폭.
5. 상태(state) 라우트 캡처(--states) — loading/empty/error/overflow, 구동 가능할 때만.
이 도구는 시각적 차별성·타이포그래피·비례·spacing·imagery의 전문성을 판정하지 않는다.
해당 판단은 comparative-divergence-audit와 visual-craft review가 담당한다.
브라우저(chrome/npm/vite)가 없으면 **가짜 성공을 만들지 않는다**: 비영점 종료하거나,
검증 불가한 항목은 'unverified'로 정직하게 표시한다(통과로 위장 금지).
동작: (필요시) <pm> install → <pm> run build(또는 manifest/--build-cmd) → out-dir(자동 감지)를
임시 포트로 http.server → headless chrome로 dump-dom(렌더 검증) + screenshot(들) → 서버 종료.
패키지 매니저는 lockfile 로 감지(pnpm/yarn/bun/npm — npm 오염 방지). --pm/--build-cmd/--out-dir 로 오버라이드.
sleep 금지 제약: 서버 준비는 urllib 폴링으로 대기한다.
Usage:
# 기본(빌드→렌더검증→스크린샷)
python3 preview_ui.py <project_dir> [--out PNG] [--width 800] [--height 1400] [--path /]
[--no-build] [--budget 8000]
# 반응형 + 정적 CSS 품질 게이트 + 상태 라우트
python3 preview_ui.py <project_dir> --viewports 360,768,1280 --check-css \
--states "loading=/#/loading,empty=/#/empty,error=/#/error"
# degraded 허용(게이트를 경고로만) — 기본은 strict(실패=비영점)
python3 preview_ui.py <project_dir> --allow-degraded
# 브라우저 없이 정적 CSS 대비/포커스만 검사(CI·테스트용)
python3 preview_ui.py --contrast-only <css_file_or_dir>
"""
import argparse
import glob
import os
import re
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
CHROME_CANDIDATES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]
# WCAG 대비 임계 — 일반 텍스트 AA=4.5. 3.0 미만은 사실상 접근 불가 → critical(strict에서 실패).
CONTRAST_FAIL = 3.0 # 이 아래는 critical
CONTRAST_WARN = 4.5 # 이 아래는 warning(AA 미달)
# 포그라운드/백그라운드 토큰 네이밍 힌트 — CSS 변수명에서 대비 쌍을 유추한다.
_FG_HINTS = ("ink", "text", "fg", "muted", "body", "heading", "title", "label", "content")
_BG_HINTS = ("surface", "canvas", "bg", "background", "paper", "base", "panel")
# ────────────────────────────────────────────────────────────── 인프라 헬퍼
def die(msg, code=1):
"""실패는 항상 stderr + 비영점. 절대 'OK'를 남기지 않는다(성공 위장 금지)."""
print(f"[preview_ui] FAIL: {msg}", file=sys.stderr)
sys.exit(code)
def find_chrome():
for c in CHROME_CANDIDATES:
p = shutil.which(c)
if p:
return p
if os.path.exists("/usr/bin/google-chrome"):
return "/usr/bin/google-chrome"
return None
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def wait_http(url, tries=100, interval=0.15):
"""서버 준비 대기 — Python time.sleep로 폴 간격을 준다(Bash 툴의 sleep 제약과 무관)."""
for _ in range(tries):
try:
with urllib.request.urlopen(url, timeout=1) as r:
if r.status == 200:
return True
except Exception:
time.sleep(interval)
return False
def run(cmd, cwd, timeout):
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
# ────────────────────────────────────────────────────────────── 정적 CSS 품질 체크
def _to_rgb(val):
"""CSS 색 리터럴(#hex / rgb()/rgba())을 (r,g,b)로. alpha<1이면 흰 배경 위 합성. 실패시 None."""
val = val.strip()
m = re.match(r"#([0-9a-fA-F]{3,8})$", val)
if m:
h = m.group(1)
if len(h) in (3, 4):
h = "".join(c * 2 for c in h)
if len(h) >= 6:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
return None
m = re.match(r"rgba?\(([^)]+)\)", val, re.I)
if m:
parts = [p.strip() for p in re.split(r"[,\s/]+", m.group(1)) if p.strip()]
try:
def chan(p):
if p.endswith("%"):
return round(float(p[:-1]) * 2.55)
return round(float(p))
r, g, b = chan(parts[0]), chan(parts[1]), chan(parts[2])
a = 1.0
if len(parts) >= 4:
ap = parts[3]
a = float(ap[:-1]) / 100 if ap.endswith("%") else float(ap)
if a < 1.0: # 반투명은 흰 배경 위로 합성해 실효 색을 본다
r = round(r * a + 255 * (1 - a))
g = round(g * a + 255 * (1 - a))
b = round(b * a + 255 * (1 - a))
return (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
except Exception:
return None
return None
def _parse_css_colors(css):
"""--var: <color>; 형태의 CSS 변수 중 색 리터럴만 {name: (r,g,b)}로."""
colors = {}
for m in re.finditer(r"--([\w-]+)\s*:\s*([^;{}]+);", css):
rgb = _to_rgb(m.group(2).strip())
if rgb:
colors[m.group(1).strip()] = rgb
return colors
def _lum(rgb):
def f(c):
c /= 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
def contrast_ratio(fg, bg):
"""WCAG 상대명도 대비비(1..21)."""
l1, l2 = _lum(fg), _lum(bg)
hi, lo = max(l1, l2), min(l1, l2)
return (hi + 0.05) / (lo + 0.05)
def check_contrast(css):
"""토큰 색쌍의 WCAG 대비를 검사. returns list of dict(pair, ratio, level).
level: 'critical'(<3.0) / 'warn'(<4.5) / 'ok'. 쌍 유추:
- on-X ↔ X (예: on-accent ↔ accent)
- fg힌트 토큰 ↔ bg힌트 토큰 (예: ink ↔ surface)"""
colors = _parse_css_colors(css)
findings = []
seen = set()
def add(fg, bg):
key = (fg, bg)
if key in seen or fg == bg:
return
seen.add(key)
r = contrast_ratio(colors[fg], colors[bg])
level = "critical" if r < CONTRAST_FAIL else ("warn" if r < CONTRAST_WARN else "ok")
findings.append({"fg": fg, "bg": bg, "ratio": round(r, 2), "level": level})
for name in colors:
if name.startswith("on-") and name[3:] in colors:
add(name, name[3:])
fgs = [n for n in colors if any(h in n for h in _FG_HINTS) and not n.startswith("on-")]
bgs = [n for n in colors if any(h in n for h in _BG_HINTS)]
for fg in fgs:
for bg in bgs:
add(fg, bg)
return findings
def check_focus(css):
"""키보드 포커스 가시성 정적 체크. outline을 죽였는데(:focus outline:none/0)
대체 포커스 표식(box-shadow/visible outline/border 변경)이 없으면 접근성 결함.
returns (ok: bool, detail: dict).
주의: 공백 백트래킹으로 'outline: none'이 'visible outline'으로 오탐되지 않게
lookahead 안에 \\s*를 넣는다."""
kills = len(re.findall(r"outline\s*:\s*(?:none|0)\b", css, re.I))
has_focus_style = False
for m in re.finditer(r":focus(?:-visible)?[^{}]*\{([^}]*)\}", css, re.I | re.S):
block = m.group(1)
if re.search(r"box-shadow\s*:\s*(?!\s*none\b)[^;]+", block, re.I):
has_focus_style = True
break
if re.search(r"outline\s*:\s*(?!\s*(?:none|0)\b)[^;]+", block, re.I):
has_focus_style = True
break
if re.search(r"border(?:-color)?\s*:", block, re.I):
has_focus_style = True
break
ok = has_focus_style or kills == 0
return ok, {"focus_style_present": has_focus_style, "outline_suppressions": kills}
def gather_css(path):
"""path가 파일이면 그 파일, 디렉터리면 그 아래 모든 .css(node_modules 제외)를 이어붙인다."""
if os.path.isfile(path):
return open(path, encoding="utf-8", errors="replace").read()
chunks = []
for f in sorted(glob.glob(os.path.join(path, "**", "*.css"), recursive=True)):
if "node_modules" in f or os.sep + "dist" + os.sep in f:
continue
chunks.append("/* %s */\n%s" % (f, open(f, encoding="utf-8", errors="replace").read()))
return "\n".join(chunks)
def report_css_quality(css, strict):
"""정적 CSS health 리포트. AA 미달 대비나 포커스 결함은 strict에서 실패."""
failed = False
con = check_contrast(css)
if not con:
print(" [css] contrast: 검사할 색쌍을 못 찾음(토큰 CSS 변수 없음) — unverified")
for f in con:
mark = {"critical": "✗", "warn": "!", "ok": "✓"}[f["level"]]
print(f" [css] contrast {mark} {f['fg']} on {f['bg']} = {f['ratio']}:1 ({f['level']})")
if f["level"] in ("critical", "warn"):
failed = True
fok, fdetail = check_focus(css)
print(f" [css] focus-visible: {'✓' if fok else '✗'} "
f"(focus-style={fdetail['focus_style_present']}, outline-kills={fdetail['outline_suppressions']})")
if not fok:
failed = True
return failed and strict
# ────────────────────────────────────────────────────────────── 렌더 검증(빈 화면 탐지)
def dom_is_empty(chrome, url, budget, timeout=90):
"""chrome --dump-dom으로 실제 DOM을 받아 mount 노드(#root/#app)가 비었는지 판정.
React가 런타임 에러로 안 붙으면 <div id="root"></div>만 남는다 → 이 경우 True(빈 화면).
returns (empty: bool, tag_count: int, ok: bool). ok=False면 dump-dom 자체 실패(검증 불가)."""
try:
r = subprocess.run(
[chrome, "--headless=new", "--disable-gpu", "--no-sandbox",
f"--virtual-time-budget={budget}", "--dump-dom", url],
capture_output=True, text=True, timeout=timeout)
except Exception:
return (False, 0, False)
html = r.stdout or ""
if not html.strip():
return (False, 0, False)
empty_mount = bool(re.search(r'id=["\']?(?:root|app)["\']?[^>]*>\s*</(?:div|main|section)>',
html, re.I))
body = re.search(r"<body[^>]*>(.*)</body>", html, re.I | re.S)
tag_count = len(re.findall(r"<[a-zA-Z]", body.group(1))) if body else len(re.findall(r"<[a-zA-Z]", html))
return (empty_mount, tag_count, True)
def screenshot(chrome, url, out, width, height, budget, timeout=120):
"""단일 스크린샷. PNG가 저장되고 유효(PNG 시그니처)해야 성공. returns (ok, rc, stderr)."""
cmd = [chrome, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
f"--window-size={width},{height}", f"--virtual-time-budget={budget}",
f"--screenshot={out}", url]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if not os.path.exists(out) or os.path.getsize(out) == 0:
return (False, r.returncode, r.stderr)
with open(out, "rb") as fh:
sig = fh.read(8)
if sig != b"\x89PNG\r\n\x1a\n": # PNG 시그니처 아니면 chrome이 에러 파일을 남긴 것
return (False, r.returncode, "출력이 유효한 PNG가 아님")
return (True, r.returncode, r.stderr)
# ────────────────────────────────────────────────────────────── 빌드(스택 중립, R4)
# 재리뷰 지적: 예전엔 npm+vite+dist 고정이라 pnpm/yarn·Next/Vue·다른 out-dir 에서 stack-neutral
# 이 아니었고, pnpm 프로젝트에 `npm install` 로 lockfile 을 오염시킬 수 있었다. 이제 lockfile 로
# 패키지매니저를 감지하고, company-context projects[] manifest 나 CLI 로 build/out-dir 를 구동한다.
_PM_INSTALL = {
"pnpm": ["pnpm", "install", "--silent"],
"yarn": ["yarn", "install", "--silent"],
"bun": ["bun", "install"],
"npm": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
}
_PM_BUILD = {"pnpm": ["pnpm", "run", "build"], "yarn": ["yarn", "build"],
"bun": ["bun", "run", "build"], "npm": ["npm", "run", "build"]}
def detect_pm(proj):
"""lockfile 로 패키지 매니저 감지(오염 방지). 없으면 npm."""
for lf, pm in (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun")):
if os.path.exists(os.path.join(proj, lf)):
return pm
return "npm"
def detect_out_dir(proj):
"""빌드 산출 디렉터리 추정. Next(정적 export)=out, Nuxt=.output/public, 그 외 vite=dist."""
for cfg in ("next.config.js", "next.config.mjs", "next.config.ts"):
if os.path.exists(os.path.join(proj, cfg)):
return "out"
if any(os.path.exists(os.path.join(proj, c)) for c in ("nuxt.config.ts", "nuxt.config.js")):
return ".output/public"
return "dist"
def manifest_hints(proj):
"""company-context.yaml projects[] 에서 이 프로젝트(id==basename)의 build/out-dir 힌트."""
try:
import yaml as _y
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
doc = _y.safe_load(open(os.path.join(root, "org-os", "01-company", "company-context.yaml"),
encoding="utf-8")) or {}
pid = os.path.basename(proj.rstrip("/"))
for pr in (doc.get("projects") or []):
if isinstance(pr, dict) and pr.get("id") == pid:
return {"build": (pr.get("build") or "").strip(),
"out-dir": (pr.get("preview-out-dir") or "").strip()}
except Exception:
pass
return {}
def install_deps(proj, pm):
if os.path.isdir(os.path.join(proj, "node_modules")):
return
cmd = _PM_INSTALL.get(pm, _PM_INSTALL["npm"])
if not shutil.which(cmd[0]):
die(f"패키지 매니저 '{cmd[0]}' 미가용(lockfile 로 감지) — 설치하거나 --pm 로 지정하세요")
r = run(cmd, proj, 600)
if r.returncode != 0:
die(f"{pm} install 실패:\n{r.stderr[-800:]}")
def build_project(proj, pm, build_cmd, out_dir, strict):
"""빌드. rc!=0은 실패. rc==0이어도 index.html/JS 번들이 없으면 degraded(위장 실패)."""
import shlex
cmd = shlex.split(build_cmd) if build_cmd else _PM_BUILD.get(pm, _PM_BUILD["npm"])
if not shutil.which(cmd[0]):
die(f"빌드 명령 '{cmd[0]}' 미가용 — --build-cmd 로 지정하세요")
r = run(cmd, proj, 300)
if r.returncode != 0:
die(f"빌드 실패({' '.join(cmd)}, rc={r.returncode}):\n{(r.stderr or r.stdout)[-800:]}")
od = os.path.join(proj, out_dir)
if not os.path.exists(os.path.join(od, "index.html")):
die(f"{out_dir}/index.html 없음 — 빌드 out-dir 를 확인하세요(--out-dir 로 지정 가능): {od}")
js = glob.glob(os.path.join(od, "**", "*.js"), recursive=True)
if not js:
msg = f"빌드 rc==0이지만 JS 번들이 없음 — degraded(빈 앱): {out_dir}"
if strict:
die(msg)
print(f" [build] WARN degraded: {msg}")
warns = len(re.findall(r"warning", (r.stdout + r.stderr), re.I))
if warns:
print(f" [build] {warns} warning(s) (참고, 실패 아님)")
# ────────────────────────────────────────────────────────────── 메인
def parse_states(spec):
"""'loading=/#/loading,empty=/#/empty' → [(label, path), ...]"""
out = []
for item in (spec or "").split(","):
item = item.strip()
if not item:
continue
if "=" in item:
label, path = item.split("=", 1)
else:
label, path = item, item
out.append((label.strip(), path.strip()))
return out
def main():
ap = argparse.ArgumentParser(
prog="preview_ui.py",
description="preview_ui — 코드 UI 렌더 + 최소 품질 게이트(스크린샷 존재≠품질).")
ap.add_argument("project_dir", nargs="?", default=None,
help="디자인 시스템/프론트 패키지 디렉터리(package.json 있는 곳)")
ap.add_argument("--out", default=None, help="주 스크린샷 PNG 경로(기본 <dir>/preview.png)")
ap.add_argument("--width", type=int, default=800)
ap.add_argument("--height", type=int, default=1400)
ap.add_argument("--path", default="/", help="스크린샷할 라우트(예: / 또는 /#/screen)")
ap.add_argument("--no-build", action="store_true", help="dist가 이미 있으면 빌드 생략")
ap.add_argument("--budget", type=int, default=8000, help="chrome virtual-time-budget(ms)")
# ── 신규: 실질 품질 게이트 ──────────────────────────────
ap.add_argument("--viewports", default=None,
help="반응형 폭 목록(콤마). 예: 360,768,1280 — 폭마다 스크린샷")
ap.add_argument("--states", default=None,
help="상태 라우트(콤마). 예: loading=/#/loading,empty=/#/empty,error=/#/error")
ap.add_argument("--check-css", action="store_true",
help="정적 CSS 품질 체크(WCAG 대비 + 포커스 가시성)")
ap.add_argument("--contrast-only", default=None,
help="브라우저 없이 이 CSS 파일/디렉터리의 대비·포커스만 검사하고 종료(CI/테스트용)")
ap.add_argument("--allow-degraded", action="store_true",
help="게이트 실패를 경고로만(기본은 strict: 실패=비영점 종료)")
# ── R4: 스택 중립 구동(패키지매니저/빌드/out-dir) ──────────
ap.add_argument("--pm", default=None, choices=["npm", "pnpm", "yarn", "bun"],
help="패키지 매니저(기본: lockfile 로 자동 감지)")
ap.add_argument("--build-cmd", default=None,
help="빌드 명령(기본: <pm> run build 또는 manifest.build). 예: 'pnpm run build'")
ap.add_argument("--out-dir", default=None,
help="빌드 산출 디렉터리(기본: 자동 감지 — vite=dist, next=out)")
args = ap.parse_args()
strict = not args.allow_degraded
# ── 모드 A: 브라우저 없는 정적 검사(대비/포커스) — fail-loud, 빌드/크롬 불필요 ──
if args.contrast_only:
if not os.path.exists(args.contrast_only):
die(f"--contrast-only 경로 없음: {args.contrast_only}")
css = gather_css(args.contrast_only)
if not css.strip():
die(f"--contrast-only: CSS를 못 찾음: {args.contrast_only}")
print(f"== 정적 CSS 품질(브라우저 없음): {args.contrast_only} ==")
failed = report_css_quality(css, strict=True)
if failed:
die("정적 CSS render-health 실패(WCAG AA 미달 대비 또는 포커스 결함)")
print("OK preview_ui: 정적 CSS render-health 통과")
return
if not args.project_dir:
die("project_dir가 필요합니다(또는 --contrast-only <css>). --help 참고", code=2)
proj = os.path.abspath(args.project_dir)
if not os.path.exists(proj):
die(f"디렉터리 없음: {proj}", code=1)
if not os.path.exists(os.path.join(proj, "package.json")):
die(f"package.json 없음: {proj}")
out = os.path.abspath(args.out or os.path.join(proj, "preview.png"))
gate_failed = False # strict에서 하나라도 True면 최종 비영점
# 0) (선택) 정적 CSS 품질 — 브라우저 전에 값싸게 먼저
if args.check_css:
css = gather_css(proj)
if css.strip():
print("== 정적 CSS 품질(대비·포커스) ==")
if report_css_quality(css, strict):
gate_failed = True
else:
print(" [css] CSS 없음 — unverified")
# 1) install + build — 스택 중립(lockfile 감지 + manifest/CLI 구동, R4). 실패시 die.
hints = manifest_hints(proj)
pm = args.pm or detect_pm(proj)
out_dir = args.out_dir or hints.get("out-dir") or detect_out_dir(proj)
build_cmd = args.build_cmd or hints.get("build") or None
print(f" [build] pm={pm} · out-dir={out_dir} · build={build_cmd or (pm + ' run build')}")
# ``--no-build`` is the offline/previously-built path. Installing dependencies
# here made the flag unusable in restricted CI even when a valid dist already
# existed, and turned a render-only check into an unexpected network mutation.
if not args.no_build:
install_deps(proj, pm)
build_project(proj, pm, build_cmd, out_dir, strict)
else:
print(" [build] --no-build: dependency install/build skipped; existing output will be rendered")
dist = os.path.join(proj, out_dir)
if not os.path.exists(os.path.join(dist, "index.html")):
die(f"{out_dir}/index.html 없음(빌드 out-dir 확인, --out-dir 로 지정 가능): {dist}")
# 2) chrome — 없으면 정직하게 실패(스크린샷 없이 통과 금지)
chrome = find_chrome()
if not chrome:
die(f"chrome 미가용 — 렌더/스크린샷 검증 불가. {out_dir}/index.html을 직접 확인하세요")
# 3) serve(out-dir) + 렌더검증(dump-dom) + 스크린샷(들)
port = free_port()
srv = subprocess.Popen(
[sys.executable, "-m", "http.server", str(port), "--directory", dist],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
shots = []
try:
base = f"http://127.0.0.1:{port}/"
if not wait_http(base):
die("로컬 서버 준비 실패")
path = args.path if args.path.startswith("/") else "/" + args.path
main_url = base.rstrip("/") + path
# 3a) 렌더 검증 — PNG가 나와도 mount가 비면 실패(스크린샷 존재≠품질의 핵심)
empty, tags, ok = dom_is_empty(chrome, main_url, args.budget)
if not ok:
# R4: strict 에서 dump-dom 실패는 '통과'가 아니다 — 렌더 검증 불가는 게이트 실패.
print(" [render] ✗ dump-dom 실패 — 렌더 검증 불가(strict 에서 게이트 실패)")
if strict:
gate_failed = True
elif empty:
print(f" [render] ✗ mount 노드가 비어 있음(#root/#app empty, body tags={tags}) — 빈 화면")
if strict:
gate_failed = True
else:
print(f" [render] ✓ 실제 콘텐츠 렌더됨(body tags={tags})")
# 3b) 반응형 다중 viewport (없으면 단일 --width)
widths = [int(w) for w in args.viewports.split(",") if w.strip()] if args.viewports else [args.width]
for i, w in enumerate(widths):
shot = out if (len(widths) == 1) else _suffix(out, f"w{w}")
sok, rc, err = screenshot(chrome, main_url, shot, w, args.height, args.budget)
if not sok:
print(f" [shot] ✗ width={w} 스크린샷 실패 rc={rc}: {(err or '')[-200:]}")
if strict:
gate_failed = True
else:
shots.append(shot)
print(f" [shot] ✓ width={w}{shot} ({os.path.getsize(shot)} bytes)")
# 3c) 상태 라우트(loading/empty/error/overflow) — 구동 가능할 때만
for label, spath in parse_states(args.states):
spath = spath if spath.startswith("/") else "/" + spath
surl = base.rstrip("/") + spath
sfile = _suffix(out, f"state-{label}")
sok, rc, err = screenshot(chrome, surl, sfile, args.width, args.height, args.budget)
if not sok:
# R4: --states 로 명시한 상태 라우트는 캡처돼야 한다 — strict 에서 실패는 게이트 실패
# (사용자가 그 상태를 요구했는데 미구현/구동불가면 '통과'가 아니다).
print(f" [state:{label}] ✗ 캡처 실패 rc={rc} — 라우트 미구현/구동불가(strict 게이트 실패)")
if strict:
gate_failed = True
else:
shots.append(sfile)
print(f" [state:{label}] ✓ → {sfile}")
finally:
srv.terminate()
try:
srv.wait(timeout=5)
except Exception:
srv.kill()
if not shots:
die("스크린샷을 하나도 못 만듦 — 렌더 검증 실패")
if gate_failed:
die(f"품질 게이트 실패(위 ✗ 항목). 스크린샷 {len(shots)}장은 남겼지만 '품질'이 아님. "
f"고치고 재실행하거나 --allow-degraded로 강등하세요")
print(f"OK preview_ui: {len(shots)} shot(s), primary={out} — render-health 통과(심미 품질 판정 아님)")
def _suffix(path, tag):
root, ext = os.path.splitext(path)
return f"{root}.{tag}{ext or '.png'}"
if __name__ == "__main__":
main()