304 lines
13 KiB
Python
304 lines
13 KiB
Python
#!/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:专题列表的卡片是 <a class="card topic-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())
|