验收:覆盖率改用 -coverpkg 口径,新增关键页冒烟
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user