#!/usr/bin/env python3 """关键页冒烟:确认页面真的渲染出来了,而不只是「模板测试通过」。 Go 侧的模板守卫只能证明模板里有那段文字,证明不了浏览器打开后能看到它—— 白屏、JS 抛错、关键区块高度为 0,模板测试全都是绿的。这里按三种口径各走一遍: 1. 站长(浏览器):每页断言 HTTP 200、落点路径未跳走、关键区块在、零 JS 报错 2. 访客(纯 HTTP):公开页 200;后台页必须 303 跳登录(绝不能 200) 3. 访客(浏览器):抽两页验零 JS 报错(只在访客侧复现的 bug 就藏在这里) 依赖 rond-web-verify 技能里的 site_cdp.py(CDP 直连本机 Chrome)。 用法: python scripts/smoke.py python scripts/smoke.py --only /map --shot-dir storage/.verify/smoke python scripts/smoke.py --base http://localhost:8081 环境变量:ROND_CDP / ROND_BASE / ROND_PY """ import argparse import json import os import re import subprocess import sys import urllib.error import urllib.request ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_CDP = os.path.join(os.path.expanduser("~"), ".workbuddy", "skills", "rond-web-verify", "scripts", "site_cdp.py") # kind: public=谁都能看 guard=受「公开页面」总开关控制 admin=仅站长 # 关键区块选的是**页面内容区**,不是布局骨架:nav.nav 在 layout.html 里, # 内容块整个没渲染出来它照样在,等于没断言。地图页取地图容器,其余取首个 .card。 # 用 .card 而不是 div.card:专题列表的卡片是 , # 写 div.card 会一个都选不中,而 null 的尺寸也是 0,看着像「元素被压扁了」。 PAGES = [ ("/", "public", "#home-map", "首页 + 足迹地图"), ("/map", "guard", "#map", "全屏地图 + 筛选面板"), ("/places", "guard", ".card", "地点列表"), ("/timeline", "guard", ".card", "时间线"), ("/stats", "guard", ".card", "统计"), ("/report", "guard", ".card", "年报"), ("/topics", "public", ".card", "专题列表"), ("{place}", "public", "#place-map", "地点详情"), ("{topic}", "public", "#map", "专题详情"), ("/admin", "admin", ".card", "后台首页"), ("/admin/edit", "admin", "#taxonomy", "数据编辑"), ("/admin/photos", "admin", ".card", "照片导入"), ("/admin/notes", "admin", ".card", "备注"), ("/admin/fillup", "admin", ".card", "待补全"), ("/admin/topics", "admin", ".card", "专题管理"), ("/admin/settings", "admin", ".card", "设置"), ] # 选择器里可能带引号,走 --eval-file 而不是 --eval:Git Bash 会把 \" 吃掉, # 脚本只丢一个 exit 23、连错误都看不到。 PROBE = """(function () { var nav = (performance.getEntriesByType('navigation') || [])[0] || {}; var el = document.querySelector(%(sel)s); var box = el ? el.getBoundingClientRect() : null; var errs = window.__errs || []; return JSON.stringify({ st: nav.responseStatus || 0, path: location.pathname, found: !!el, w: box ? Math.round(box.width) : 0, h: box ? Math.round(box.height) : 0, body: (document.body.innerText || '').length, errs: errs.length, e0: errs.length ? String(errs[0]) : '' }); })() """ def http(path, base, cookie=None, timeout=20): """不跟随跳转,拿原始状态码——后台守卫的 303 正是要断言的东西。""" class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **kw): return None req = urllib.request.Request(base.rstrip("/") + path) if cookie: req.add_header("Cookie", cookie) opener = urllib.request.build_opener(NoRedirect) try: with opener.open(req, timeout=timeout) as r: return r.status, "" except urllib.error.HTTPError as e: return e.code, (e.headers.get("Location") or "") except Exception as e: return 0, str(e) def session_cookie(cdp, root, user="admin"): """复用 site_cdp.py 自签会话的能力(它自己读 conf/app.ini 与 users 表)。""" code = ( "import sys; sys.path.insert(0, %r)\n" "import site_cdp as S\n" "cfg = S.read_config(%r)\n" "print(S.session_token(cfg, S.find_psql(None), %r)[1])\n" % ( os.path.dirname(cdp), root, user) ) out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=root, timeout=90) tok = (out.stdout or "").strip().splitlines() if not tok: raise SystemExit("没能自签会话 cookie:\n" + (out.stderr or out.stdout or "")[-500:]) return "rond_session=" + tok[-1] def discover(base): """地点 id 从公开的 /api/points 取;专题 id 从 /topics 页面里抠。""" place = topic = None try: with urllib.request.urlopen(base.rstrip("/") + "/api/points", timeout=20) as r: pts = json.loads(r.read().decode("utf-8")).get("points") or [] if pts: place = str(pts[0]["id"]) except Exception: pass try: with urllib.request.urlopen(base.rstrip("/") + "/topics", timeout=20) as r: html = r.read().decode("utf-8", "replace") m = re.search(r"/topics/(\d+)", html) if m: topic = m.group(1) except Exception: pass return place, topic def probe(cdp, python, base, path, width, height, visitor, sel, shot, wait): expr = os.path.join(ROOT, "storage", ".verify", "smoke", "_probe.js") os.makedirs(os.path.dirname(expr), exist_ok=True) with open(expr, "w", encoding="utf-8") as f: f.write(PROBE % {"sel": json.dumps(sel)}) cmd = [python, cdp, "--root", ROOT, "--base", base, "--path", path, "--width", str(width), "--height", str(height), "--wait", str(wait), "--eval-file", expr] if visitor: cmd.append("--visitor") if shot: cmd += ["--shot", shot] p = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT, timeout=180) data = None jserrs = "?" for line in (p.stdout or "").splitlines(): line = line.strip() if line.startswith('"') and data is None: try: data = json.loads(json.loads(line)) except Exception: pass if line.startswith("# JS 报错:"): jserrs = line.split(":", 1)[1].strip() if data is None: return None, jserrs, ((p.stderr or p.stdout or "")[-300:]) return data, jserrs, None def main(): ap = argparse.ArgumentParser() ap.add_argument("--base", default=os.environ.get("ROND_BASE", "http://localhost:8080")) ap.add_argument("--cdp", default=os.environ.get("ROND_CDP", DEFAULT_CDP)) ap.add_argument("--python", default=os.environ.get("ROND_PY", sys.executable)) ap.add_argument("--width", type=int, default=1440) ap.add_argument("--height", type=int, default=900) ap.add_argument("--wait", type=float, default=3.0) ap.add_argument("--only", help="只跑匹配这个子串的页面") ap.add_argument("--shot-dir", help="每页存一张整屏截图(站长口径)") ap.add_argument("--min-body", type=int, default=100, help="正文字符数下限,只用来兜「整页空白」;内容稀疏的页面(如专题列表)本来就少") args = ap.parse_args() if not os.path.isfile(args.cdp): raise SystemExit( "找不到 site_cdp.py:%s\n" "设 ROND_CDP 指过去,或先装 rond-web-verify 技能。" % args.cdp) base = args.base.rstrip("/") st, _ = http("/healthz", base) if st != 200: raise SystemExit( "站点没在 %s 上响应(/healthz => %s)。先起服务:\n" " go build -o bin/rond.exe .\n" " 然后用 Bash 工具的后台任务跑 ./bin/rond.exe" % (base, st or "连不上")) place, topic = discover(base) subs = {"{place}": "/places/" + place if place else None, "{topic}": "/topics/" + topic if topic else None} cookie = session_cookie(args.cdp, ROOT) # 公开页面总开关:以访客能否看 /places 为准,同类的 guard 页必须一致 pub_on = http("/places", base)[0] == 200 pages = [] for raw, kind, sel, desc in PAGES: path = subs.get(raw, raw) if path is None: pages.append((raw, kind, sel, desc, None)) else: pages.append((raw, kind, sel, desc, path)) print("== %s 公开页面开关=%s 地点=%s 专题=%s" % ( base, "开" if pub_on else "关", place or "?", topic or "?")) fails, warns, skipped = [], [], [] for raw, kind, sel, desc, path in pages: if path is None: skipped.append("%s(发现不到 id)" % raw) print("%-18s 跳过 %s" % (raw, desc)) continue if args.only and args.only not in path: continue # 站长:浏览器,全量断言 shot = None if args.shot_dir: os.makedirs(args.shot_dir, exist_ok=True) shot = os.path.join(args.shot_dir, path.strip("/").replace("/", "_") or "home") shot += ".png" data, jserrs, err = probe(args.cdp, args.python, base, path, args.width, args.height, False, sel, shot, args.wait) tag = "%-18s" % path if data is None: fails.append("%s 站长:跑失败 %s" % (path, err)) print("%s ✗ 跑失败 %s" % (tag, err)) continue # 「没选中」和「选中了但宽高为 0」是两回事,别都印成 =0×0 box = "%s=%d×%d" % (sel, data["w"], data["h"]) if data["found"] else "%s 不存在" % sel bits = "st=%s path=%s %s body=%d" % (data["st"], data["path"], box, data["body"]) problems = [] if data["st"] != 200: problems.append("HTTP %s" % data["st"]) if data["path"] != path: problems.append("跳到了 %s" % data["path"]) if not data["found"]: problems.append("缺关键区块 %s" % sel) if data["body"] < args.min_body: problems.append("正文只有 %d 字" % data["body"]) if jserrs not in ("[]", "?"): problems.append("JS 报错 %s" % jserrs) if data["errs"]: problems.append("window.onerror: %s" % data["e0"]) if data["found"] and (data["w"] == 0 or data["h"] == 0): warns.append("%s 站长:%s 尺寸为 0" % (path, sel)) if problems: fails.append("%s 站长:%s" % (path, ";".join(problems))) print("%s ✗ %s [%s]" % (tag, ";".join(problems), bits)) else: print("%s ✓ %s" % (tag, bits)) # 访客:公开页 200、guard 页跟随总开关、后台页 303 跳登录 vst, loc = http(path, base) if kind == "admin": if vst != 303 or "/admin/login" not in loc: fails.append("%s 访客:后台页竟是 %s(应 303 跳登录)" % (path, vst)) print("%s ↳ 访客 ✗ %s %s" % (" " * 18, vst, loc)) else: print("%s ↳ 访客 ✓ 303 → 登录" % (" " * 18)) else: want = 200 if kind == "public" or pub_on else 303 if vst != want: fails.append("%s 访客:%s(期望 %s)" % (path, vst, want)) print("%s ↳ 访客 ✗ %s" % (" " * 18, vst)) else: print("%s ↳ 访客 ✓ %s" % (" " * 18, vst)) # 访客浏览器抽检:双身份下 DOM 不一样,只在访客侧挂的 JS 报错只有这里能抓到 for path in ["/", subs["{place}"]]: if not path or (args.only and args.only not in path): continue sel = "#home-map" if path == "/" else "#place-map" data, jserrs, err = probe(args.cdp, args.python, base, path, args.width, args.height, True, sel, None, args.wait) if data is None: fails.append("%s 访客浏览器:跑失败 %s" % (path, err)) print("%-18s 访客浏览器 ✗ 跑失败 %s" % (path, err)) continue ok = data["st"] == 200 and jserrs in ("[]", "?") and not data["errs"] if not ok: fails.append("%s 访客浏览器:st=%s JS报错=%s" % (path, data["st"], jserrs)) print("%-18s 访客浏览器 %s st=%s body=%d errs=%s" % ( path, "✓" if ok else "✗", data["st"], data["body"], data["errs"])) print("-" * 78) for w in warns: print("⚠ %s" % w) for s in skipped: print("· 跳过 %s" % s) if fails: print("冒烟失败 %d 项:" % len(fails)) for f in fails: print(" ✗ %s" % f) return 1 print("冒烟通过(%d 页)" % (len(pages) - len(skipped))) return 0 if __name__ == "__main__": sys.exit(main())