验收:覆盖率改用 -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 # 全量验收(默认,约 8 分钟)
|
||||||
# ./scripts/verify.sh --fast # 只做 gofmt / vet / build(约 20 秒)
|
# ./scripts/verify.sh --fast # 只做 gofmt / vet / build(约 20 秒)
|
||||||
# ./scripts/verify.sh --race # 全量 + -race(更慢,需 mingw 在 PATH)
|
# ./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' # 定点跑
|
# ./scripts/verify.sh --pkg ./internal/web/ --run 'TestMapPreview' # 定点跑
|
||||||
#
|
#
|
||||||
# 退出码:0 = 全绿且 0 skip;非 0 = 有失败或有跳过。
|
# 退出码:0 = 全绿且 0 skip;非 0 = 有失败或有跳过。
|
||||||
@@ -24,14 +26,17 @@ FOG_SRC="${FOG_SRC:-$ABS/data/Snapshot-20260914T154839+0800.fwss}"
|
|||||||
OUT="$ABS/storage/.verify"
|
OUT="$ABS/storage/.verify"
|
||||||
CLEAN_OUT="$OUT/acceptance-clean.rondbackup"
|
CLEAN_OUT="$OUT/acceptance-clean.rondbackup"
|
||||||
LOG="$OUT/verify.log"
|
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
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--fast) FAST=1 ;;
|
--fast) FAST=1 ;;
|
||||||
--race) RACE=1 ;;
|
--race) RACE=1 ;;
|
||||||
--cover) COVER=1 ;;
|
--cover) COVER=1 ;;
|
||||||
|
--smoke) SMOKE=1; SMOKE_ONLY=1 ;;
|
||||||
|
--all) SMOKE=1 ;;
|
||||||
--pkg) PKGS="${2:?--pkg 后面要给包路径}"; shift ;;
|
--pkg) PKGS="${2:?--pkg 后面要给包路径}"; shift ;;
|
||||||
--run) RUN="${2:?--run 后面要给正则}"; shift ;;
|
--run) RUN="${2:?--run 后面要给正则}"; shift ;;
|
||||||
*) echo "未知参数:$1" >&2; exit 2 ;;
|
*) echo "未知参数:$1" >&2; exit 2 ;;
|
||||||
@@ -44,6 +49,19 @@ ok() { echo "✓ $*"; }
|
|||||||
|
|
||||||
echo "== 仓库:$ROOT"
|
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
|
if [ "$FAST" = 1 ]; then
|
||||||
unformatted="$(gofmt -l internal/ main.go 2>&1)"
|
unformatted="$(gofmt -l internal/ main.go 2>&1)"
|
||||||
[ -n "$unformatted" ] && fail "gofmt 不干净(注意 CRLF 会假警):$(echo "$unformatted" | tr '\n' ' ')"
|
[ -n "$unformatted" ] && fail "gofmt 不干净(注意 CRLF 会假警):$(echo "$unformatted" | tr '\n' ' ')"
|
||||||
@@ -55,6 +73,11 @@ if [ "$FAST" = 1 ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$SMOKE_ONLY" = 1 ]; then
|
||||||
|
run_smoke
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 六个环境变量 ---------------------------------------------------------------
|
# --- 六个环境变量 ---------------------------------------------------------------
|
||||||
[ -f "$CONF" ] || fail "找不到 $CONF"
|
[ -f "$CONF" ] || fail "找不到 $CONF"
|
||||||
[ -f "$ROND_SRC" ] || fail "找不到备份包 ROND_SRC=$ROND_SRC"
|
[ -f "$ROND_SRC" ] || fail "找不到备份包 ROND_SRC=$ROND_SRC"
|
||||||
@@ -82,7 +105,10 @@ echo "== 库:$DBNAME 备份:$(basename "$ROND_SRC") / $(basename "$FOG_SRC
|
|||||||
|
|
||||||
GOFLAGS_EXTRA=""
|
GOFLAGS_EXTRA=""
|
||||||
[ "$RACE" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -race"
|
[ "$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
|
if [ "$RACE" = 1 ] && command -v gcc >/dev/null 2>&1; then :; else
|
||||||
for d in "$HOME/scoop/apps/mingw/current/bin"; do
|
for d in "$HOME/scoop/apps/mingw/current/bin"; do
|
||||||
@@ -135,3 +161,22 @@ if [ "$SKIP" != 0 ]; then
|
|||||||
fail "有 $SKIP 个用例被跳过 ⇒ 这次不算验收过"
|
fail "有 $SKIP 个用例被跳过 ⇒ 这次不算验收过"
|
||||||
fi
|
fi
|
||||||
ok "全绿且 0 skip(PASS=$PASS)"
|
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
|
||||||
|
|||||||
@@ -17,4 +17,36 @@
|
|||||||
./scripts/verify.sh # 全量,0 skip 才算过
|
./scripts/verify.sh # 全量,0 skip 才算过
|
||||||
./scripts/verify.sh --fast # gofmt + vet + build
|
./scripts/verify.sh --fast # gofmt + vet + build
|
||||||
./scripts/verify.sh --race --pkg ./internal/web/
|
./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`)上跑着,否则直接报错退出。
|
||||||
|
|||||||
Reference in New Issue
Block a user