验收:覆盖率改用 -coverpkg 口径,新增关键页冒烟

This commit is contained in:
tamakyi
2026-09-24 22:48:31 +08:00
parent 05c6e970db
commit 256620ee3d
3 changed files with 383 additions and 3 deletions
+303
View File
@@ -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:专题列表的卡片是 <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())
+48 -3
View File
@@ -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