用户给了真实截图,指出两处肉眼可见的不协调,逐项定位后修复:
1. 「一句话说明」是单行 input 被塞进 ~360px 的格子,placeholder
「例:全市场股息率最高的 2」直接截断 —— 内容太多、框太小。
→ 改为整宽多行 <textarea>(rows=2、min-height 64px、可纵向拉伸),
与定宽 340px 的策略名并排(.params-meta flex),窄屏自动上下堆叠;
实时字数 (N/300) 已在提示里。
2. 「太多地方没有对齐」的根因:表单铺满 ~1900px 内容区,5 列各 ~360px,
控件拉得太开;因子下拉用 1fr 撑到 ~1500px 而权重框才 118px,比例失调;
数字输入还有 max-width:180px 上限,比同行下拉窄一截。
→ .params-form 约束到 ~1100px(每列 ~210px,所有控件按 1fr 等宽,
左右边缘严格对齐);移除已多余的 max-width:180px(限宽后它反而
制造新的不等宽),数值/日期改为填满单元格;因子行首列封顶 460px、
整行限宽 720px,条件行同理(首列 ≤360px、限宽 760px);
复权口径选项去掉括号里的「股息策略推荐」(已在 hint 说明),不再截断。
验证方式也升级了:这次是**截图后用 read_image 看渲染像素**确认,而不只量几何。
对齐自检同步把 textarea(高度随行数变、本就该比单行高)排除出「同排等高 /
高度归一」比较,并把数字过宽阈值调到 240px(限宽后 ~210px 属正常)。
门禁:tsc 0 错误、图表单测 7/7、next build 成功、页面 200、
对齐自检 140/140、契约自检 59/59;/backtest 与 /strategies 截图复核通过。
493 lines
22 KiB
Python
493 lines
22 KiB
Python
#!/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()) |