Files

304 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())