#!/usr/bin/env python3 """UI 对齐与控件一致性自检(无头 Chrome + 原生 CDP,仅用标准库)。 为什么需要这个脚本 ------------------ 「控件没对齐 / 尺寸和内容不匹配 / 输入不友好」这类问题**肉眼能看出,但无法回归**: 截图不能进版本库比较,人工点页面又会漏。所以把「对齐」变成可测量的不变量, 每次改样式后跑一遍,用数字证明没有退化。 为什么自己实现 CDP ------------------ 不依赖 playwright / selenium / websocket-client(本项目零额外依赖原则)。 只用标准库 socket 做 WebSocket 握手与帧编解码,连本机 Chrome 的调试端口; 用**独占端口 + 临时 profile**,不碰用户正在用的浏览器(共享端口曾导致误开标签页)。 检查项(每条都对应一种真实可感知的缺陷) ---------------------------------------- 1. 同排等高 同一横向视觉行内的输入框/下拉/按钮高度必须一致(容差 1.5px) 2. 高度归一 单页控件高度取值 ≤ 3 种(输入 / 标准按钮 / 小按钮), 杜绝「每个组件各写一套 padding」导致的 33/34/35px 参差 3. 点击目标 ≥ 28px 高;图标按钮 ≥ 24×24 4. 标签可见 每个 input/select/textarea 都有可见标签或 aria-label (placeholder 不能当唯一标签) 5. 图标按钮命名 仅图标的按钮必须有 aria-label / title(否则读屏只会念「按钮」) 6. 字号 / 圆角一致 控件字号取值 ≤ 3 种、圆角取值 ≤ 2 种 7. 尺寸匹配内容 number/date 输入不得占满整行、文本输入不得窄于 120px 8. 无横向溢出 1500px 与 375px 两种视口下页面都不得出现横向滚动 9. 提示不裁切 字段提示/错误文本不得溢出或裁切 用法 ---- python3 scripts/verify_ui_alignment.py # 检查默认页面 python3 scripts/verify_ui_alignment.py --dump # 额外打印几何明细 python3 scripts/verify_ui_alignment.py --pages /experiments python3 scripts/verify_ui_alignment.py --width 375 # 小屏复核 """ from __future__ import annotations import argparse import base64 import contextlib import glob import json import os import shutil import socket import struct import subprocess import sys import tempfile import time import urllib.error import urllib.parse import urllib.request WEB = os.environ.get("QLIB_WEB", "http://127.0.0.1:3000") DEFAULT_PAGES = ["/", "/backtest", "/strategies", "/selection", "/experiments", "/stocks", "/factors"] CHROME_CANDIDATES = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", ] CHROME_GLOBS = [ os.path.expanduser( "~/Library/Caches/ms-playwright/chromium-*/chrome-mac*/" "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" ), os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux/chrome"), ] # ---------------------------------------------------------------- CDP(标准库) class _WS: """最小 WebSocket 客户端(RFC6455 文本帧、客户端掩码、ping 自动回 pong)。""" def __init__(self, url: str, timeout: float = 60.0) -> None: u = urllib.parse.urlparse(url) self.sock = socket.create_connection((u.hostname, u.port), timeout=timeout) self.sock.settimeout(timeout) key = base64.b64encode(os.urandom(16)).decode() path = u.path + (f"?{u.query}" if u.query else "") req = ( f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\n" "Upgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" ) self.sock.sendall(req.encode()) self._buf = b"" while b"\r\n\r\n" not in self._buf: chunk = self.sock.recv(65536) if not chunk: raise RuntimeError("CDP 握手失败:连接被关闭") self._buf += chunk head, self._buf = self._buf.split(b"\r\n\r\n", 1) if b" 101 " not in head.split(b"\r\n")[0]: raise RuntimeError(f"CDP 握手失败:{head.split(b'\r\n')[0]!r}") def _read(self, n: int) -> bytes: while len(self._buf) < n: chunk = self.sock.recv(65536) if not chunk: raise RuntimeError("CDP 连接已关闭") self._buf += chunk out, self._buf = self._buf[:n], self._buf[n:] return out def _frame(self, opcode: int, payload: bytes) -> bytes: head = bytearray([0x80 | opcode]) n = len(payload) if n < 126: head.append(0x80 | n) elif n < 65536: head.append(0x80 | 126) head += struct.pack(">H", n) else: head.append(0x80 | 127) head += struct.pack(">Q", n) mask = os.urandom(4) head += mask return bytes(head) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) def send_text(self, text: str) -> None: self.sock.sendall(self._frame(0x1, text.encode())) def recv_text(self) -> str: while True: b1, b2 = self._read(2) opcode, n, masked = b1 & 0x0F, b2 & 0x7F, bool(b2 & 0x80) if n == 126: n = struct.unpack(">H", self._read(2))[0] elif n == 127: n = struct.unpack(">Q", self._read(8))[0] mask = self._read(4) if masked else None data = self._read(n) if mask: data = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) if opcode == 0x9: # ping self.sock.sendall(self._frame(0xA, data)) continue if opcode == 0x8: raise RuntimeError("CDP 连接被对端关闭") if opcode in (0x1, 0x2): return data.decode("utf-8", "ignore") def close(self) -> None: with contextlib.suppress(OSError): self.sock.close() class CDP: def __init__(self, ws_url: str) -> None: self.ws = _WS(ws_url) self._id = 0 def call(self, method: str, params: dict | None = None, timeout: float = 90.0): self._id += 1 mid = self._id self.ws.send_text(json.dumps({"id": mid, "method": method, "params": params or {}})) deadline = time.time() + timeout while time.time() < deadline: msg = json.loads(self.ws.recv_text()) if msg.get("id") == mid: if "error" in msg: raise RuntimeError(f"{method} 失败:{msg['error']}") return msg.get("result", {}) raise TimeoutError(f"{method} 超时") def find_chrome() -> str | None: for p in CHROME_CANDIDATES: if os.path.exists(p): return p for pat in CHROME_GLOBS: hit = sorted(glob.glob(pat)) if hit: return hit[-1] return shutil.which("google-chrome") or shutil.which("chromium") def free_port() -> int: with socket.socket() as s: s.bind(("127.0.0.1", 0)) return int(s.getsockname()[1]) # ---------------------------------------------------------------- 页面几何探针 PROBE = r""" (() => { const rect = (el) => { const b = el.getBoundingClientRect(); return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; }; const txt = (s) => (s || "").replace(/\s+/g, " ").trim(); const css = (el) => getComputedStyle(el); const nameOf = (el) => txt(el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.placeholder || el.getAttribute("name") || el.tagName); const ctrlSel = "input:not([type=hidden]), select, textarea, button, a.btn, .btn, .icon-btn"; const controls = [...document.querySelectorAll(ctrlSel)].filter((e) => { const r = e.getBoundingClientRect(); const s = css(e); return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none"; }).map((e) => { const r = rect(e), s = css(e); // 搜索框/字段内嵌的图标按钮:天然比输入框小且垂直居中,不参与「同排等高」 const nested = !!e.closest(".searchbox, .field, .input-group"); return { tag: e.tagName.toLowerCase(), type: e.getAttribute("type") || "", cls: txt(e.className).slice(0, 40), label: nameOf(e).slice(0, 30), aria: !!(e.getAttribute("aria-label") || e.getAttribute("title")), iconOnlyBtn: e.tagName === "BUTTON" && !txt(e.innerText) && !e.getAttribute("data-label"), nestedIcon: nested && e.classList.contains("icon-btn"), // 多行文本域:高度随行数变化,是「内容决定高度」的控件, // 不参与「同排等高」(它本来就该比单行输入高)与「高度取值归一」比较 isTextarea: e.tagName === "TEXTAREA", x: r.x, y: r.y, w: r.w, h: r.h, fs: parseFloat(s.fontSize), radius: s.borderRadius, padL: parseFloat(s.paddingLeft), padR: parseFloat(s.paddingRight), placeholder: txt(e.getAttribute("placeholder")).slice(0, 30), hasVisibleLabel: (() => { const id = e.getAttribute("id"); if (id && document.querySelector(`label[for="${id}"]`)) return true; if (e.closest("label")) return true; const f = e.closest(".field, .form-field, .input-group"); if (f && f.querySelector(".field__label, label")) return true; return false; })(), required: e.hasAttribute("required"), disabled: e.hasAttribute("disabled") || e.disabled === true, // 勾选/单选控件的真实点击目标是外层 label(.check / .radio-row), // 16px 的方块永远达不到 28px;量 label 才是量「用户能不能点到」。 hitH: (() => { if (e.type !== "checkbox" && e.type !== "radio") return r.h; const lab = e.closest("label"); return lab ? +lab.getBoundingClientRect().height.toFixed(1) : r.h; })(), // pill / chip 是刻意的胶囊形控件,不参与圆角统一 intentionalPill: !!e.closest(".pill, .chip, .tabs, .seg"), // 多行选项行(.radio-row:标题 + 说明会换行):高度由文案决定, // 属于「选项热区」而不是「尺寸令牌控件」,不参与高度归一比较 optionRow: !!e.closest(".radio-row") }; }); // 横向溢出(含被压出的子元素) const overflows = []; for (const el of document.querySelectorAll("body *")) { const s = css(el); if (s.display === "none" || s.visibility === "hidden" || s.position === "fixed") continue; if (el.scrollWidth > el.clientWidth + 2 && el.clientWidth > 0 && s.overflowX !== "auto" && s.overflowX !== "scroll") { const tag = el.tagName.toLowerCase() + (el.className && typeof el.className === "string" ? "." + txt(el.className).split(" ")[0] : ""); overflows.push({ el: tag, scrollW: el.scrollWidth, clientW: el.clientWidth, text: txt(el.innerText).slice(0, 40) }); } if (overflows.length > 25) break; } // 提示 / 错误文本是否被裁切 const clipped = []; for (const el of document.querySelectorAll(".field__hint, .hint, .field__error, .err")) { const r = el.getBoundingClientRect(), s = css(el); if (r.width === 0) continue; if (el.scrollHeight > el.clientHeight + 2 || (s.textOverflow === "ellipsis" && el.scrollWidth > el.clientWidth + 2)) { clipped.push({ el: txt(el.className).split(" ")[0], text: txt(el.innerText).slice(0, 40), scrollH: el.scrollHeight, clientH: el.clientHeight }); } } return { path: location.pathname, viewport: { w: innerWidth, h: innerHeight }, docScrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth, controls, overflows, clipped, fontSizes: [...new Set(controls.map((c) => c.fs))].sort((a, b) => a - b), radii: [...new Set(controls.map((c) => c.radius))].sort(), }; })() """ def probe(cdp: CDP, url: str, width: int, height: int, settle: float) -> dict: cdp.call("Emulation.setDeviceMetricsOverride", { "width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 700, }) cdp.call("Page.navigate", {"url": url}) deadline = time.time() + 45 while time.time() < deadline: st = cdp.call("Runtime.evaluate", { "expression": "document.readyState", "returnByValue": True}).get("result", {}).get("value") if st == "complete": break time.sleep(0.3) time.sleep(settle) res = cdp.call("Runtime.evaluate", {"expression": PROBE, "returnByValue": True, "awaitPromise": True}) return res["result"]["value"] # ---------------------------------------------------------------- 检查逻辑 def group_rows(controls: list[dict]) -> list[list[dict]]: """按垂直重叠把控件分成视觉行(重叠超过较矮者一半即视为同一行)。""" rows: list[list[dict]] = [] for c in sorted(controls, key=lambda c: (c["y"], c["x"])): placed = False for row in rows: ref = row[0] top, bot = max(ref["y"], c["y"]), min(ref["y"] + ref["h"], c["y"] + c["h"]) if bot - top > 0.5 * min(ref["h"], c["h"]): row.append(c) placed = True break if not placed: rows.append([c]) return rows def check_geometry(g: dict) -> list[tuple[bool, str, str]]: out: list[tuple[bool, str, str]] = [] ctrls = g["controls"] rows = group_rows(ctrls) # 1 同排等高 mixed = [] for row in rows: heights = [c["hitH"] for c in row if not c["nestedIcon"] and not c["isTextarea"]] if len(heights) < 2: continue if max(heights) - min(heights) > 1.5: mixed.append((row, heights)) detail = "; ".join( f"y={row[0]['y']:.0f} " + " / ".join(f"{c['tag']}{'[' + c['type'] + ']' if c['type'] else ''}「{c['label']}」{c['hitH']}px" for c in row) for row, _ in mixed[:6] ) out.append((not mixed, "同排控件等高", detail or f"{len(rows)} 行全部等高")) # 2 高度取值归一 uniq = sorted({round(c["hitH"], 1) for c in ctrls if not c["optionRow"] and not c["isTextarea"]}) out.append((len(uniq) <= 3, "控件高度取值归一(≤3 种)", f"实际 {len(uniq)} 种:{uniq}")) # 3 点击目标 small = [c for c in ctrls if c["hitH"] < 28 and not c["nestedIcon"]] small_icon = [c for c in ctrls if c["nestedIcon"] and (c["h"] < 22 or c["w"] < 22)] det = "; ".join(f"{c['tag']}「{c['label']}」{c['w']}×{c['hitH']}" for c in small[:6]) or "-" if small_icon: det += " | 图标按钮过小:" + "; ".join(f"「{c['label']}」{c['w']}×{c['h']}" for c in small_icon[:4]) out.append((not small and not small_icon, "点击目标 ≥28px(嵌内图标 ≥22px)", det)) # 4 标签可见 fields = [c for c in ctrls if c["tag"] in ("input", "select", "textarea") and c["type"] not in ("checkbox", "radio", "submit", "button")] nolabel = [c for c in fields if not c["hasVisibleLabel"] and not c["aria"]] aria_only = [c for c in fields if not c["hasVisibleLabel"] and c["aria"]] det = "; ".join(f"{c['tag']}[{c['type']}] placeholder=「{c['placeholder']}」" for c in nolabel[:8]) or "全部有可见标签" if aria_only: det += f"(另 {len(aria_only)} 个用 aria-label:{', '.join('「' + c['label'] + '」' for c in aria_only[:4])})" out.append((not nolabel, "输入控件有可见标签或 aria-label", det)) # 5 图标按钮无障碍名 noname = [c for c in ctrls if c["tag"] == "button" and c["iconOnlyBtn"] and not c["aria"]] det = "; ".join(f"「{c['label']}」" for c in noname[:8]) or "全部有无障碍名" out.append((not noname, "图标按钮有无障碍名", det)) # 6 字号 / 圆角一致 fs = g["fontSizes"] # 原生 checkbox/radio 的 0px 圆角由浏览器绘制,不是本项目的样式选择 radii = sorted({c["radius"] for c in ctrls if not c["intentionalPill"] and c["type"] not in ("checkbox", "radio")}) out.append((len(fs) <= 3, "控件字号 ≤3 种", f"{fs}")) out.append((len(radii) <= 2, "控件圆角 ≤2 种", f"{radii}")) # 7 尺寸匹配内容 # 阈值 240:参数表单约束到 ~1100px 后每列约 210px,数字输入填满单元格属正常; # 真正失调的是早先 flex:1 拉到 359px 那种(截图实测)。 wide_num = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("number", "date") and c["w"] > 240] narrow = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("text", "search", "") and c["w"] < 120 and not c["nestedIcon"]] det = [] if wide_num: det.append("数字/日期输入过宽:" + "; ".join(f"「{c['label']}」{c['w']}" for c in wide_num[:4])) if narrow: det.append("文本输入过窄:" + "; ".join(f"「{c['label']}」{c['w']}" for c in narrow[:4])) out.append((not det, "控件尺寸匹配内容", " | ".join(det) or "无异常")) # 8 无横向溢出 over = g["bodyScrollW"] > g["viewport"]["w"] + 1 det = f"body scrollWidth={g['bodyScrollW']} vs viewport {g['viewport']['w']}" if over and g["overflows"]: det += " | 溢出元素:" + "; ".join(f"{o['el']}({o['scrollW']}>{o['clientW']})" for o in g["overflows"][:4]) out.append((not over, "无横向滚动", det)) # 9 提示不裁切 clipped = g["clipped"] det = "; ".join(f"{c['el']}「{c['text']}」" for c in clipped[:5]) or "无裁切" out.append((not clipped, "提示/错误文本不被裁切", det)) return out def main() -> int: ap = argparse.ArgumentParser(description="UI 对齐与控件一致性自检") ap.add_argument("--pages", nargs="*", default=DEFAULT_PAGES) ap.add_argument("--base", default=WEB, help="前端地址(默认 %(default)s)") ap.add_argument("--width", type=int, default=1500) ap.add_argument("--height", type=int, default=1200) ap.add_argument("--settle", type=float, default=3.0, help="导航后再等多久(懒加载/接口回填)") ap.add_argument("--dump", action="store_true", help="打印几何明细") ap.add_argument("--json", dest="json_out", help="把原始几何写入该文件") args = ap.parse_args() chrome = find_chrome() if not chrome: print("未找到 Chrome / Chromium,可用 QLIB_CHROME 指定可执行文件路径", file=sys.stderr) return 2 chrome = os.environ.get("QLIB_CHROME", chrome) port = free_port() profile = tempfile.mkdtemp(prefix="qlib-ui-check-") proc = subprocess.Popen( [chrome, f"--remote-debugging-port={port}", "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--disable-extensions", f"--user-data-dir={profile}", f"--window-size={args.width},{args.height}", "about:blank"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) ok = bad = 0 raw: dict[str, dict] = {} try: ws_url = None for _ in range(80): try: with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r: for t in json.loads(r.read()): if t.get("type") == "page": ws_url = t["webSocketDebuggerUrl"] break if ws_url: break except (urllib.error.URLError, TimeoutError, OSError): pass time.sleep(0.3) if not ws_url: print("无法连接 Chrome 调试端口", file=sys.stderr) return 2 cdp = CDP(ws_url) cdp.call("Page.enable") cdp.call("Runtime.enable") widths = [args.width] if args.width != 375: widths.append(375) # 小屏复核(UX 规范要求 375px 验证) for path in args.pages: for w in widths: url = args.base.rstrip("/") + path label = f"{path} @{w}px" print(f"\n[{label}]", flush=True) try: g = probe(cdp, url, w, args.height, args.settle) except Exception as e: # noqa: BLE001 - 自检脚本要把失败讲清楚而不是崩掉 print(f" ❌ 探针失败:{e}", flush=True) bad += 1 continue raw[f"{path}@{w}"] = g if args.dump: for row in group_rows(g["controls"]): box = " | ".join( f"{c['tag']}{'[' + c['type'] + ']' if c['type'] else ''}「{c['label']}」" f"{c['w']}×{c['h']}@y{c['y']:.0f} fs{c['fs']}" for c in row) print(f" {box}", flush=True) for good, name, detail in check_geometry(g): mark = "✅" if good else "❌" if good: ok += 1 else: bad += 1 print(f" {mark} {name} — {detail}", flush=True) if args.json_out: with open(args.json_out, "w", encoding="utf-8") as f: json.dump(raw, f, ensure_ascii=False, indent=1) finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() shutil.rmtree(profile, ignore_errors=True) print(f"\n结果:{ok} 项通过 / {bad} 项失败", flush=True) return 1 if bad else 0 if __name__ == "__main__": raise SystemExit(main())