- Introduced `_recent_days_series_hint` to handle queries related to "最近N天" for time series data aggregation. - Added `_cn_num_to_int` function to convert Chinese numerals to integers for better query parsing. - Updated `_nl_sql_hints` to incorporate the new hint generation logic, ensuring accurate SQL output for recent days queries. - Enhanced documentation to reflect these changes and improve clarity on the new functionalities.
400 lines
21 KiB
Python
400 lines
21 KiB
Python
"""风控线端到端补测(真实 HTTP · RK7–RK8)。
|
||
|
||
**为什么另起脚本而不扩 `risk_e2e_smoke.py`:** 那支是 Part C 的**回归基线**,其价值在于
|
||
「退出码仍为 0」。RK8 要做的是**修复前/后浏览器结果对照**,天然会产出差异记录;
|
||
把它塞进基线脚本会让「真回归退化」与「补测发现」不可区分。故基线脚本**一个字都不动**。
|
||
|
||
盘点的两个结论决定了本脚本只做**补漏**而非铺开:
|
||
1. 风控线**后端 4 条路由已被 `risk_e2e_smoke.py` 全覆盖**(R1 鉴权/R2 列表与分页/
|
||
R3 处置状态机/R4 适当性/R5 AML/R6 模拟交易),其中已含 `page_size=101→422`、
|
||
`handler_result 非法→422`、`重复处置→409` 等边界。→ 本脚本**只补它没测的面**,
|
||
不重复已有断言(重复不会增加证据,只会让报告变长)。
|
||
2. 本线真正的缺口在**跨接口自洽**与**未对账的边界**:
|
||
- `page=0` 下界(R2 只测了 `page_size` 上界)
|
||
- 分页「不重不漏」(page1 ∩ page2 = ∅)
|
||
- 同一响应内 `stats` 与 `total` 自洽
|
||
- 处置动作与 `stats` 的**前后差值**(跨接口一致性 —— 只测单接口的话,
|
||
「处置了但统计没动」这类缺陷永远测不出来)
|
||
- 未知资源在**归属校验阶段**与**判定阶段**的两种语义分裂
|
||
(`app/api/risk.py:134` 实测:未知**客户** → 404;未知**产品** → 200 结构化 not_found)
|
||
|
||
**RK8** 是文件级对照:把 `10-risk-pages.mjs` 的本轮产物与上一轮基线
|
||
`results-10-risk.json` 按**用例 id** 对齐,专盯 **PASS→FAIL** ——
|
||
那正是原来被 `03-risk.mjs:23` 恒真断言(F-01)掩盖的真缺陷。
|
||
|
||
用法:
|
||
python scripts/dev/risk_gap_e2e_smoke.py
|
||
python scripts/dev/risk_gap_e2e_smoke.py --only RK7
|
||
python scripts/dev/risk_gap_e2e_smoke.py --report docs/.../_raw/rk-gap.md
|
||
|
||
退出码:0 全过 / 1 有 FAIL / 2 前置自检失败。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import traceback
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
||
import customer_trade_e2e_smoke as e2e # noqa: E402
|
||
from customer_trade_e2e_smoke import ( # noqa: E402
|
||
FAIL,
|
||
INFO,
|
||
PASS,
|
||
SKIP,
|
||
Ctx,
|
||
bad,
|
||
body,
|
||
data_of,
|
||
db_query,
|
||
err_code,
|
||
expect_status,
|
||
expect_true,
|
||
info,
|
||
login,
|
||
ok,
|
||
skip,
|
||
)
|
||
|
||
OFFICER = "STAFF-30001" # risk_officer + risk_demo
|
||
MANAGER = "STAFF-31001" # risk_manager
|
||
COMPLIANCE = "STAFF-40001" # compliance
|
||
ADVISOR = "STAFF-10086" # advisor(需归属查询的对照角色)
|
||
CUSTOMER = "CUST-9527" # customer(self 域对照角色)
|
||
UNKNOWN_CUSTOMER = "CUST-000000" # 格式合法但库中不存在
|
||
KNOWN_CUSTOMER = "CUST-1001" # 库中确实存在
|
||
KNOWN_PRODUCT = "PROD-110022" # 库中确实存在
|
||
UNKNOWN_PRODUCT = "PROD-DOES-NOT-EXIST"
|
||
|
||
REQUIRED_PATHS = {
|
||
"/api/risk/alerts",
|
||
"/api/risk/alerts/{alert_id}/handle",
|
||
"/api/risk/suitability/check",
|
||
"/api/risk/aml/scan",
|
||
}
|
||
|
||
#: 上一轮的浏览器基线产物(仓库外,只读引用;本轮不复制进仓库,避免制造第二份事实源)
|
||
BASELINE_10 = Path("C:/Users/Windows/e2e-jinrong/results-10-risk.json")
|
||
#: 本轮 `10-risk-pages.mjs` 的产物(由 `_raw/` 下的 env 指定输出目录写出)
|
||
CURRENT_10 = (
|
||
Path(__file__).resolve().parents[2]
|
||
/ "docs" / "memory" / "tests" / "2026-09-13-risk-e2e" / "_raw" / "results-10-risk.json"
|
||
)
|
||
|
||
|
||
def risk_h(base: dict) -> dict:
|
||
"""风控线请求头:Bearer + **必需的** X-Agent-Type(`risk.py` 走 `get_auth_context`)。"""
|
||
return {**base, "X-Agent-Type": "risk"}
|
||
|
||
|
||
def preflight(ctx: Ctx, base_url: str) -> bool:
|
||
print(f"== 风控线端到端补测 @ {base_url} ==\n")
|
||
failures: list[str] = []
|
||
try:
|
||
r = ctx.client.get("/api/ready")
|
||
print(f" /api/ready -> HTTP {r.status_code}")
|
||
if r.status_code != 200:
|
||
failures.append(f"/api/ready HTTP {r.status_code}")
|
||
except Exception as exc: # noqa: BLE001
|
||
failures.append(f"后端不可达 /api/ready: {exc}")
|
||
|
||
try:
|
||
paths = set(ctx.client.get("/openapi.json").json().get("paths", {}))
|
||
missing = sorted(REQUIRED_PATHS - paths)
|
||
print(f" openapi 路径总数 {len(paths)}(基线 66)· 风控线必需路径缺 {len(missing)}")
|
||
failures.extend(f"openapi 缺路径 {p}(后端跑的是旧进程?)" for p in missing)
|
||
except Exception as exc: # noqa: BLE001
|
||
failures.append(f"取 openapi 失败: {exc}")
|
||
|
||
# ⚠️ 客户账号必须显式 `token_type="customer"`:默认的 `"staff"` 签不出 `customer_id`,
|
||
# self 域判定会失败(实测:客户查自己也得 403 NOT_OWNER)。与 GX 包同一陷阱。
|
||
for key, actor, tt in (("officer", OFFICER, "staff"),
|
||
("manager", MANAGER, "staff"),
|
||
("compliance", COMPLIANCE, "staff"),
|
||
("advisor", ADVISOR, "staff"),
|
||
("customer", CUSTOMER, "customer")):
|
||
try:
|
||
ctx.h[key] = login(ctx, actor, tt)
|
||
print(f" 登录 {actor}(token_type={tt})-> ok")
|
||
except Exception as exc: # noqa: BLE001
|
||
failures.append(f"登录 {actor} 失败: {exc}")
|
||
|
||
if failures:
|
||
print("\n前置自检未通过:")
|
||
for f in failures:
|
||
print(f" - {f}")
|
||
return not failures
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# RK7 · 后端补漏(只补 `risk_e2e_smoke.py` 未覆盖的面)
|
||
# --------------------------------------------------------------------------
|
||
def rk7(ctx: Ctx) -> None:
|
||
G = "RK7"
|
||
h = risk_h(ctx.h["officer"])
|
||
path = "/api/risk/alerts"
|
||
|
||
# ── 7-01 分页**下界**(R2 只测了 page_size 上界)──
|
||
r = ctx.client.get(path, headers=h, params={"page": 0})
|
||
expect_true(G, "7-01 page=0 → 422(Query(ge=1) 下界)",
|
||
r.status_code == 422, "HTTP 422 REQUEST_VALIDATION_FAILED",
|
||
f"HTTP {r.status_code} {err_code(r)}")
|
||
|
||
r = ctx.client.get(path, headers=h, params={"page": -3})
|
||
expect_true(G, "7-01b page=-3 → 422(负数同样越界)",
|
||
r.status_code == 422, "HTTP 422",
|
||
f"HTTP {r.status_code} {err_code(r)}")
|
||
|
||
# ── 7-02 远超末页:不得报错,且分页元数据要自洽 ──
|
||
r_first = ctx.client.get(path, headers=h, params={"page": 1, "page_size": 5})
|
||
d_first = body(r_first) if r_first.status_code == 200 else {}
|
||
total = d_first.get("total")
|
||
r_far = ctx.client.get(path, headers=h, params={"page": 999999, "page_size": 5})
|
||
if expect_status(G, "7-02 超末页 page=999999 → 200(不报错)", r_far, 200):
|
||
d_far = body(r_far)
|
||
expect_true(G, "7-02a 超末页 items 为空列表",
|
||
d_far.get("items") == [], "items=[]", f"items={d_far.get('items')!r}")
|
||
expect_true(G, "7-02b 超末页 total 与首页一致(分页元数据不随页码漂移)",
|
||
d_far.get("total") == total, f"total={total}", f"total={d_far.get('total')!r}")
|
||
expect_true(G, "7-02c 超末页回显 page 与请求一致",
|
||
d_far.get("page") == 999999, "page=999999", f"page={d_far.get('page')!r}")
|
||
|
||
# ── 7-03 不重不漏:相邻两页的 alert_id 交集必须为空 ──
|
||
r1 = ctx.client.get(path, headers=h, params={"page": 1, "page_size": 5})
|
||
r2 = ctx.client.get(path, headers=h, params={"page": 2, "page_size": 5})
|
||
if r1.status_code == 200 and r2.status_code == 200:
|
||
ids1 = {i.get("alert_id") for i in (body(r1).get("items") or [])}
|
||
ids2 = {i.get("alert_id") for i in (body(r2).get("items") or [])}
|
||
dup = ids1 & ids2
|
||
expect_true(G, "7-03 page1 ∩ page2 = ∅(分页不重复)",
|
||
not dup, "交集为空", f"重复 {sorted(x for x in dup if x)}")
|
||
expect_true(G, "7-03a 两页各自非空(否则上一条会因空集假过)",
|
||
len(ids1) > 0 and len(ids2) > 0, "两页均非空",
|
||
f"page1={len(ids1)} page2={len(ids2)}")
|
||
else:
|
||
skip(G, "7-03 分页不重不漏", f"取两页失败 {r1.status_code}/{r2.status_code}")
|
||
|
||
# ── 7-04 同一响应内自洽:按 status 过滤时,items 状态必须全等于该过滤值 ──
|
||
r = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
|
||
if expect_status(G, "7-04 status=pending_review 过滤生效", r, 200):
|
||
d = body(r)
|
||
items = d.get("items") or []
|
||
states = {i.get("status") for i in items}
|
||
expect_true(G, "7-04a 过滤后 items 状态集合 = {pending_review}",
|
||
states <= {"pending_review"} and len(items) > 0,
|
||
"items 全为 pending_review 且非空", f"n={len(items)} states={sorted(s for s in states if s)}")
|
||
# 同一响应内 stats 与 total 的自洽(不看库,纯接口内部一致性)
|
||
stats = d.get("stats") or {}
|
||
pr = stats.get("pending_review_count")
|
||
expect_true(G, "7-04b stats.pending_review_count 与 total 自洽(同响应内不矛盾)",
|
||
pr == d.get("total"), f"stats.pending_review_count == total == {d.get('total')}",
|
||
f"stats={pr!r} total={d.get('total')!r}")
|
||
|
||
# ── 7-05 只读对账:接口 total 与库计数一致(agent 库)──
|
||
rows = db_query("SELECT COUNT(*) FROM risk_alert WHERE status='pending_review'", "agent")
|
||
if rows and r.status_code == 200:
|
||
db_n = rows[0][0]
|
||
api_n = body(r).get("total")
|
||
expect_true(G, "7-05 接口 total 与库计数一致(pending_review)",
|
||
db_n == api_n, f"DB={db_n}", f"API total={api_n!r} / DB={db_n}")
|
||
else:
|
||
info(G, "7-05 接口 total 与库计数对账", "库查询不可用,跳过对账(不代表通过)")
|
||
|
||
# ── 7-06 处置动作 → stats 的**前后差值**(跨接口一致性)──
|
||
# 这是本组最有价值的一条:只测单接口时,「处置成功但统计没动」永远测不出来。
|
||
# 副作用说明:本用例会把 1 条 pending 预警推进为已处置;`risk_alert` 属追加型
|
||
# 审计表,按纪律**不还原**,故这是**有意的、记录在案的残留**(不新增行,只改状态)。
|
||
before = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
|
||
cand = (body(before).get("items") or []) if before.status_code == 200 else []
|
||
if not cand:
|
||
skip(G, "7-06 处置 → stats 前后差值", "取不到 pending_review 预警,无法构造")
|
||
else:
|
||
aid = cand[0].get("alert_id")
|
||
n_before = body(before).get("total")
|
||
rh = ctx.client.post(f"/api/risk/alerts/{aid}/handle", headers=h,
|
||
json={"handler_result": "confirmed_normal", "handler_comment": "E2E RK7-06 前后差值取证"})
|
||
if expect_status(G, f"7-06 处置 {aid}", rh, 200):
|
||
after = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
|
||
n_after = body(after).get("total")
|
||
expect_true(G, "7-06a 处置后 pending_review 计数恰好 -1(前后差值,非绝对计数)",
|
||
isinstance(n_after, int) and isinstance(n_before, int) and n_before - n_after == 1,
|
||
f"{n_before} → {n_before - 1}", f"{n_before} → {n_after}")
|
||
info(G, "7-06b 处置残留登记", f"alert_id={aid} 已由 pending_review 推进为 confirmed_normal"
|
||
"(追加型审计表,按纪律不还原)")
|
||
|
||
# ── 7-07 未知资源的语义分裂(R10 边界)──
|
||
# 首跑我按源码 `risk.py:134-135`(LookupError → 404)**推断**「未知客户必 404」,
|
||
# 实测**该分支没被触发**:`assert_customer_access` 对全量读角色**先 return**、
|
||
# 根本不做存在性查询,于是未知客户直接进入判定阶段 → 200 结构化 not_found。
|
||
# 三个演示角色实测三分(详见 7-07a/b/c),**没有任何一条走到 404**。
|
||
r = ctx.client.post("/api/risk/suitability/check", headers=h,
|
||
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
|
||
if expect_status(G, "7-07 未知客户(risk_officer 全量读)→ 200 结构化返回", r, 200):
|
||
d = body(r)
|
||
expect_true(G, "7-07a officer 视图下未知客户被判 blocked(fail-closed,非静默放行)",
|
||
d.get("blocked") is True and d.get("mismatch_type") == "not_found",
|
||
"blocked=True + mismatch_type='not_found'",
|
||
f"blocked={d.get('blocked')!r} mismatch_type={d.get('mismatch_type')!r} "
|
||
f"code={d.get('block_response_code')!r}")
|
||
info(G, "7-07b `risk.py:134` 的 404 分支本轮**未被触发**",
|
||
"三个演示角色(officer/advisor/customer)打未知客户分别得 200/403/403,"
|
||
"无一走 404 —— 该分支的可达条件本轮未能构造,属**未覆盖路径**而非缺陷")
|
||
|
||
# ⚠️ `X-Agent-Type` 必须与**账号真实类型**一致,否则先在矩阵层被拦:
|
||
# advisor 账号带 `X-Agent-Type: risk` → `AUTH_403_AGENT_MISMATCH`(实测,见下条对照),
|
||
# 根本走不到归属校验。故这里用 advisor 通道,才能验证「归属查询分支」。
|
||
hr = {**ctx.h["advisor"], "X-Agent-Type": "advisor"}
|
||
r_bad_channel = ctx.client.post("/api/risk/suitability/check",
|
||
headers={**ctx.h["advisor"], "X-Agent-Type": "risk"},
|
||
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
|
||
expect_true(G, "7-07c 通道值与账号类型不符 → 矩阵层拦 AGENT_MISMATCH(早于归属校验)",
|
||
r_bad_channel.status_code == 403 and err_code(r_bad_channel) == "AUTH_403_AGENT_MISMATCH",
|
||
"HTTP 403 AUTH_403_AGENT_MISMATCH",
|
||
f"HTTP {r_bad_channel.status_code} {err_code(r_bad_channel)}")
|
||
|
||
r = ctx.client.post("/api/risk/suitability/check", headers=hr,
|
||
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
|
||
expect_true(G, "7-07d 未知客户(advisor 归属查询分支)→ 403 NOT_ASSIGNED(非 404)",
|
||
r.status_code == 403 and err_code(r) == "AUTH_403_NOT_ASSIGNED",
|
||
"HTTP 403 AUTH_403_NOT_ASSIGNED", f"HTTP {r.status_code} {err_code(r)}")
|
||
|
||
hc = {**ctx.h["customer"], "X-Agent-Type": "customer"}
|
||
r = ctx.client.post("/api/risk/suitability/check", headers=hc,
|
||
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
|
||
expect_true(G, "7-07e 未知客户(customer self 域)→ 403 NOT_OWNER(非 404)",
|
||
r.status_code == 403 and err_code(r) == "AUTH_403_NOT_OWNER",
|
||
"HTTP 403 AUTH_403_NOT_OWNER", f"HTTP {r.status_code} {err_code(r)}")
|
||
|
||
r = ctx.client.post("/api/risk/suitability/check", headers=h,
|
||
json={"customer_id": KNOWN_CUSTOMER, "product_id": UNKNOWN_PRODUCT})
|
||
if expect_status(G, "7-08 已知客户 + 未知**产品** → 200(判定阶段结构化返回,不 404)", r, 200):
|
||
d = body(r)
|
||
expect_true(G, "7-08a 未知产品被判为 blocked 且 mismatch_type='not_found'",
|
||
d.get("blocked") is True and d.get("mismatch_type") == "not_found",
|
||
"blocked=True + mismatch_type='not_found'",
|
||
f"blocked={d.get('blocked')!r} mismatch_type={d.get('mismatch_type')!r} "
|
||
f"code={d.get('block_response_code')!r}")
|
||
info(G, "7-08b 同路由的「找不到」语义分裂(记录,非缺陷判定)",
|
||
"未知**产品**=200+blocked;未知**客户**则按角色三分(officer 200 / advisor 403 / customer 403),"
|
||
"**三者都不是 404** —— 见 7-07 组;`risk.py:134` 的 404 分支本轮未触发")
|
||
|
||
# ── 7-09 阻断类响应必须带 G-08 三要素(advice/notice)──
|
||
# `risk.py:157-161`:仅当 blocked 为真才补 advice/notice。
|
||
if r.status_code == 200:
|
||
d = body(r)
|
||
expect_true(G, "7-09 阻断响应补齐 advice/notice(G-08 三要素)",
|
||
bool(d.get("advice")) and bool(d.get("notice")),
|
||
"advice + notice 均非空",
|
||
f"advice={str(d.get('advice'))[:40]!r} notice={str(d.get('notice'))[:40]!r}")
|
||
|
||
# ── 7-10 时间字段口径(记录):接口返回的是 UTC naive 还是本地时间 ──
|
||
r = ctx.client.get(path, headers=h, params={"page_size": 1})
|
||
if r.status_code == 200:
|
||
items = body(r).get("items") or []
|
||
if items:
|
||
created = items[0].get("created_at")
|
||
info(G, "7-10 预警 created_at 口径", f"{created!r}"
|
||
"(与本地时区关系见报告;影响「近 N 分钟」类查询,R4 窗口陷阱的根源)")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# RK8 · 浏览器结果对照(修复前后 PASS→FAIL 追踪)
|
||
# --------------------------------------------------------------------------
|
||
def rk8(ctx: Ctx) -> None:
|
||
"""把本轮 `10-risk-pages.mjs` 产物与上一轮基线按**用例 id** 对齐。
|
||
|
||
**专盯 PASS→FAIL**:`03-risk.mjs:23` 的恒真断言(F-01)被修掉之后,原本被它
|
||
掩盖的真缺陷会在此暴露。PASS→PASS 只计数,不逐条列(避免噪音淹没信号)。
|
||
"""
|
||
G = "RK8"
|
||
if not BASELINE_10.exists():
|
||
skip(G, "8-00 浏览器结果对照", f"基线产物不存在:{BASELINE_10}")
|
||
return
|
||
if not CURRENT_10.exists():
|
||
skip(G, "8-00 浏览器结果对照",
|
||
f"本轮产物不存在:{CURRENT_10}(须先跑 Part B 的 10-risk-pages.mjs 并指定 E2E_OUT_DIR)")
|
||
return
|
||
|
||
def load(p: Path) -> dict:
|
||
d = json.loads(p.read_text(encoding="utf-8"))
|
||
return {r.get("id"): r for r in d.get("results", []) if isinstance(r, dict)}
|
||
|
||
old, new = load(BASELINE_10), load(CURRENT_10)
|
||
info(G, "8-00 对照规模", f"基线 {len(old)} 条 / 本轮 {len(new)} 条 · 交集 {len(set(old) & set(new))} 条")
|
||
|
||
regressed = [k for k in set(old) & set(new)
|
||
if old[k].get("status") == "PASS" and new[k].get("status") != "PASS"]
|
||
fixed = [k for k in set(old) & set(new)
|
||
if old[k].get("status") != "PASS" and new[k].get("status") == "PASS"]
|
||
only_new = sorted(set(new) - set(old))
|
||
only_old = sorted(set(old) - set(new))
|
||
|
||
for k in sorted(regressed):
|
||
bad(G, f"8-01 ★PASS→FAIL★ {k} {new[k].get('name')}",
|
||
"修复恒真断言后仍应 PASS", f"{new[k].get('status')} · {str(new[k].get('actual'))[:160]}",
|
||
json.dumps({"before": old[k].get("actual"), "after": new[k].get("actual")},
|
||
ensure_ascii=False)[:400])
|
||
if not regressed:
|
||
ok(G, "8-01 无 PASS→FAIL 退化(恒真断言修复后原 PASS 项全部仍 PASS)",
|
||
"∅", f"比较 {len(set(old) & set(new))} 条交集")
|
||
|
||
info(G, "8-02 修复的旧 FAIL 条目", f"{sorted(fixed) if fixed else '无'}")
|
||
info(G, "8-03 仅本轮新增的用例", f"{only_new if only_new else '无'}")
|
||
info(G, "8-04 仅基线有的用例(本轮未复跑)", f"{only_old if only_old else '无'}")
|
||
|
||
|
||
GROUPS = {"RK7": rk7, "RK8": rk8}
|
||
ORDER = ["RK7", "RK8"]
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="风控线端到端补测(RK7–RK8)")
|
||
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||
parser.add_argument("--report", default=None)
|
||
parser.add_argument("--only", action="append", default=None)
|
||
parser.add_argument("--timeout", type=float, default=120.0)
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
ctx = Ctx(client=httpx.Client(base_url=args.base_url.rstrip("/"), timeout=args.timeout))
|
||
try:
|
||
if not preflight(ctx, args.base_url):
|
||
print("\n前置自检未通过 —— 未产生任何用例判定。")
|
||
return 2
|
||
selected = [g for g in ORDER if not args.only or g in args.only]
|
||
print(f"\n-- 执行分组: {', '.join(selected)} --\n")
|
||
for name in selected:
|
||
try:
|
||
GROUPS[name](ctx)
|
||
except Exception as exc: # noqa: BLE001
|
||
bad(name, f"{name}-00 分组执行异常", "分组正常跑完",
|
||
f"{type(exc).__name__}: {exc}", traceback.format_exc()[-800:])
|
||
finally:
|
||
ctx.client.close()
|
||
|
||
counts = {s: sum(1 for r in e2e.RESULTS if r.status == s) for s in (PASS, FAIL, SKIP, INFO)}
|
||
print(f"\n== 结果: {counts[PASS]} PASS / {counts[FAIL]} FAIL / "
|
||
f"{counts[SKIP]} SKIP / {counts[INFO]} INFO ==")
|
||
for r in e2e.RESULTS:
|
||
if r.status == FAIL:
|
||
print(f" - {r.group} · {r.name}: 期望 {r.expected} / 实际 {r.actual[:160]}")
|
||
if args.report:
|
||
e2e.write_report(Path(args.report))
|
||
print(f"\n报告已写入 {args.report}")
|
||
return 1 if counts[FAIL] else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|