diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100644 index 0000000..451df08 --- /dev/null +++ b/scripts/smoke.py @@ -0,0 +1,303 @@ +#!/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()) diff --git a/scripts/verify.sh b/scripts/verify.sh index d4622e7..d49e58a 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -5,7 +5,9 @@ # ./scripts/verify.sh # 全量验收(默认,约 8 分钟) # ./scripts/verify.sh --fast # 只做 gofmt / vet / build(约 20 秒) # ./scripts/verify.sh --race # 全量 + -race(更慢,需 mingw 在 PATH) -# ./scripts/verify.sh --cover # 全量 + 覆盖率 +# ./scripts/verify.sh --cover # 全量 + 覆盖率(跨包口径,见下) +# ./scripts/verify.sh --smoke # 只跑关键页冒烟(需站点已在 --base 上运行) +# ./scripts/verify.sh --all # 全量 + 冒烟 # ./scripts/verify.sh --pkg ./internal/web/ --run 'TestMapPreview' # 定点跑 # # 退出码:0 = 全绿且 0 skip;非 0 = 有失败或有跳过。 @@ -24,14 +26,17 @@ FOG_SRC="${FOG_SRC:-$ABS/data/Snapshot-20260914T154839+0800.fwss}" OUT="$ABS/storage/.verify" CLEAN_OUT="$OUT/acceptance-clean.rondbackup" LOG="$OUT/verify.log" +COVOUT="$OUT/cover.out" -FAST=0; RACE=0; COVER=0; PKGS="./..."; RUN="" +FAST=0; RACE=0; COVER=0; SMOKE=0; SMOKE_ONLY=0; PKGS="./..."; RUN="" while [ $# -gt 0 ]; do case "$1" in --fast) FAST=1 ;; --race) RACE=1 ;; --cover) COVER=1 ;; + --smoke) SMOKE=1; SMOKE_ONLY=1 ;; + --all) SMOKE=1 ;; --pkg) PKGS="${2:?--pkg 后面要给包路径}"; shift ;; --run) RUN="${2:?--run 后面要给正则}"; shift ;; *) echo "未知参数:$1" >&2; exit 2 ;; @@ -44,6 +49,19 @@ ok() { echo "✓ $*"; } echo "== 仓库:$ROOT" +# 冒烟单独跑:它要一个真在跑的站点(浏览器口径),跟单元测试是两回事 +run_smoke() { + local py="${ROND_PY:-python}" + command -v "$py" >/dev/null 2>&1 || py="${ROND_PY:-python3}" + command -v "$py" >/dev/null 2>&1 || fail "找不到 python(可用 ROND_PY 指定)" + echo "== 冒烟:关键页渲染(站长浏览器 + 访客 HTTP/浏览器)" + # 路径必须给 Windows 形式:这里传的是 Git Bash 的 $ROOT(/c/...), + # Windows 版 python 会把它拼成 C:\c\... 然后报 No such file + "$py" "$ABS/scripts/smoke.py" --base "${ROND_BASE:-http://localhost:8080}" \ + --shot-dir "$OUT/smoke" || fail "关键页冒烟" + ok "关键页冒烟" +} + if [ "$FAST" = 1 ]; then unformatted="$(gofmt -l internal/ main.go 2>&1)" [ -n "$unformatted" ] && fail "gofmt 不干净(注意 CRLF 会假警):$(echo "$unformatted" | tr '\n' ' ')" @@ -55,6 +73,11 @@ if [ "$FAST" = 1 ]; then exit 0 fi +if [ "$SMOKE_ONLY" = 1 ]; then + run_smoke + exit 0 +fi + # --- 六个环境变量 --------------------------------------------------------------- [ -f "$CONF" ] || fail "找不到 $CONF" [ -f "$ROND_SRC" ] || fail "找不到备份包 ROND_SRC=$ROND_SRC" @@ -82,7 +105,10 @@ echo "== 库:$DBNAME 备份:$(basename "$ROND_SRC") / $(basename "$FOG_SRC GOFLAGS_EXTRA="" [ "$RACE" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -race" -[ "$COVER" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -cover" +# 必须 -coverpkg:-cover 只统计「测试文件所在包」自己的执行,凡是主要由别的包驱动的 +# 代码(rond 的导出被 internal/web 驱动、geo/gpx/notes/votes 被 web/stats 驱动) +# 一律假报 0%,看着像没测,其实是量错了。 +[ "$COVER" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -coverpkg=./internal/... -coverprofile=$COVOUT" if [ "$RACE" = 1 ] && command -v gcc >/dev/null 2>&1; then :; else for d in "$HOME/scoop/apps/mingw/current/bin"; do @@ -135,3 +161,22 @@ if [ "$SKIP" != 0 ]; then fail "有 $SKIP 个用例被跳过 ⇒ 这次不算验收过" fi ok "全绿且 0 skip(PASS=$PASS)" + +if [ "$COVER" = 1 ] && [ -s "$COVOUT" ]; then + echo "== 覆盖率(按包,跨包执行已计入)" + # 必须按 (文件,行段) 取 max 而不是求和:-coverpkg 下每个测试二进制都会给同一段代码 + # 写一条记录,求和等于把语句数乘上二进制份数,每包都会看着只有 1/N。 + awk 'NR>1 { key=$1; n=$(NF-1); c=$NF+0 + if (!(key in seen)) seen[key]=c; else if (c>seen[key]) seen[key]=c + stmt[key]=n; split(key,a,":"); pkg=a[1]; sub(/\/[^/]+$/,"",pkg); pk[key]=pkg } + END { for (k in stmt) { p=pk[k]; tot[p]+=stmt[k]; if (seen[k]>0) cov[p]+=stmt[k] } + for (p in tot) printf " %-36s %5.1f%% %d/%d\n", p, 100*cov[p]/tot[p], cov[p], tot[p] }' \ + "$COVOUT" | sort -k2 -n + echo -n " == 项目合计 " + go tool cover -func="$COVOUT" | tail -1 + echo " 明细:go tool cover -html=$COVOUT" +fi + +if [ "$SMOKE" = 1 ]; then + run_smoke +fi diff --git a/tools/README.md b/tools/README.md index 063adaf..6bf68d4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -17,4 +17,36 @@ ./scripts/verify.sh # 全量,0 skip 才算过 ./scripts/verify.sh --fast # gofmt + vet + build ./scripts/verify.sh --race --pkg ./internal/web/ +./scripts/verify.sh --cover # 覆盖率,-coverpkg 口径 +./scripts/verify.sh --smoke # 关键页冒烟(要站点在 8080 上跑着) +./scripts/verify.sh --all # 全量 + 冒烟 ``` + +### `--cover` 为什么要 `-coverpkg` + +`go test -cover` 只统计「测试文件所在的那个包」自己的执行。本项目大量代码是被**别的包** +驱动的——`internal/rond` 的导出流程由 `internal/web` 的用例跑,`geo`/`gpx`/`notes`/`votes` +由 `web`/`stats` 跑——按 `-cover` 量这些包会一律报 **0%**,看着像没测,其实全都在跑。 +`--cover` 用 `-coverpkg=./internal/...` 把跨包执行算进来。实测差别: + +| 包 | `-cover`(假) | `-coverpkg`(真) | +|---|---|---| +| `internal/rond` | 38.8% | 79.1%(由 `internal/web` 的用例贡献) | + +### 关键页冒烟:`../scripts/smoke.py` + +Go 侧模板守卫只能证明「模板里有那段文字」,证明不了浏览器打开后看得到——白屏、JS 抛错、 +内容区块被压成 0 高,模板测试全都是绿的。冒烟按三种口径各走一遍: + +1. **站长(浏览器)**:每页断言 HTTP 200、落点路径没跳走、内容区块在且尺寸非 0、零 JS 报错 +2. **访客(纯 HTTP)**:公开页 200;后台页必须 303 跳登录(绝不能 200) +3. **访客(浏览器)抽检**:`/` 与地点详情,只在访客侧复现的 bug 藏在这里 + +```bash +python scripts/smoke.py --only /map # 单页调试 +python scripts/smoke.py --shot-dir storage/.verify/smoke # 顺带存整屏截图 +``` + +依赖 `rond-web-verify` 技能里的 `site_cdp.py`(CDP 直连本机 Chrome);用 `ROND_CDP` +指过去,默认取 `~/.workbuddy/skills/rond-web-verify/scripts/site_cdp.py`。 +站点得先在 `ROND_BASE`(默认 `http://localhost:8080`)上跑着,否则直接报错退出。