Files
group_xinghuo_jinrong/scripts/dev/gap_routes_e2e_smoke.py
T
zhanghongyu_0626 188bf6a438 feat(analyst_agent): Add support for recent days queries and enhance SQL hint generation
- 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.
2026-09-13 22:36:19 +08:00

468 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""不属任何单线的**孤立路由**端到端补测(真实 HTTP · GX1–GX5)。
盘点(计划 §1.1)查出四条路由**零 E2E 覆盖**,且其中两条**连单测都没有**:
| 路由 | 盘点结论 | 本脚本分组 |
| --- | --- | --- |
| `POST /api/customers/{id}/threshold-check` | ★ 全仓 e2e 零命中;前端**有按钮**(`CustomerHoldingsPage.tsx:21-27`);`tests/test_main.py:104` 仅路径字符串断言 | GX1 |
| `GET /api/staff/me` | 仅单测;前端零调用 | GX2 |
| `GET /api/products/{product_id}` | 仅单测;前端零调用 | GX3 |
| `POST /api/chat/sessions/close-all` · `/sessions/{id}/close` | 仅单测;前端有按钮(`ChatPanel.tsx:72,176`)但**没人点过** | GX4 |
| (文档类)`DEMO-功能测试流程.md` / `功能演示版验收标准.md` | 门槛陈旧 + 编号一号两义 | GX5(**只记录不修**) |
**为什么单独成脚本:** 这四条路由横跨客户/平台/对话三条线,塞进任一业务线包都会
污染那个包的结论边界(用户拍板口径 7「一线一包」)。故归入横切(crosscut)包。
**关于 GX4 的数据副作用(本脚本最需要小心的一处):**
`close-all` / `close` 会**改变 `agent_session.status`**,而 `jinrong_agent` 按纪律
**不还原**(追加型),且会话列表是演示界面的一部分。故:
- 状态变更类用例一律用**备用账号 `STAFF-20003`**(答辩记号本标注为"问数第三账号"),
**不用**主演示账号 —— 避免把 CUST-9527 / STAFF-10086 的对话历史关掉。
- 该账号若无任何会话,则 `close-all` 实测 `closed_count=0`,**零副作用**;
有会话时才走状态机分支,并把实际走的分支记入 INFO。
用法:
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
python scripts/dev/gap_routes_e2e_smoke.py
python scripts/dev/gap_routes_e2e_smoke.py --only GX1 --only GX4
python scripts/dev/gap_routes_e2e_smoke.py --report docs/.../_raw/xc-gap.md
退出码:0 全过 / 1 有 FAIL / 2 前置自检失败。
"""
from __future__ import annotations
import argparse
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,
err_code,
expect_status,
expect_true,
info,
login,
ok,
skip,
)
# ── 演示账号 ──
CUSTOMER = "CUST-9527" # 本人
OTHER_CUSTOMER = "CUST-1002" # 非本人(但同属 STAFF-10086)
ADVISOR = "STAFF-10086"
OWNED_CUSTOMER = "CUST-1001" # STAFF-10086 名下
UNOWNED_CUSTOMER = "CUST-1004" # 存在,但归属 STAFF-10087(实测自 core_customer_advisor)
RISK = "STAFF-30001" # risk_officer(threshold-check / staff-me 的正例角色)
#: GX4 的状态变更专用账号:答辩记号本标注为"问数第三账号(dev 映射)",
#: 非主演示账号 —— 用它关会话不会动到演示叙事。
SPARE = "STAFF-20003"
UNKNOWN_ID = "CUST-000000"
#: 已知存在的产品(用于 GX3 正例;取自 Core 模拟库事实)。
PRODUCT = "PROD-110022"
REQUIRED_PATHS = {
"/api/customers/{customer_id}/threshold-check",
"/api/staff/me",
"/api/products/{product_id}",
"/api/chat/sessions",
"/api/chat/sessions/close-all",
"/api/chat/sessions/{session_id}/close",
"/api/risk/alerts",
}
def expect_code_in(group: str, name: str, resp: httpx.Response, want_status: int, want_codes: set[str]) -> bool:
got = err_code(resp)
if resp.status_code == want_status and got in want_codes:
ok(group, name, f"HTTP {want_status} code∈{sorted(want_codes)}", f"HTTP {resp.status_code} {got}")
return True
bad(group, name, f"HTTP {want_status} code∈{sorted(want_codes)}",
f"HTTP {resp.status_code} {got}", resp.text[:400])
return False
# --------------------------------------------------------------------------
# 前置自检
# --------------------------------------------------------------------------
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"后端不可达: {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}")
# ⚠️ 陷阱(2026-09-13 实测):`login()` 默认 `token_type="staff"`,而客户账号用默认值
# 登录时签发的 JWT **`customer_id` 为 None**(实测 claims:`token_type='staff',
# roles=['customer'], customer_id=None`)。而 `assert_customer_access` 的 self 域
# **靠 `customer_id` 判定** ⇒ 客户查自己也会 403 AUTH_403_NOT_OWNER(假 FAIL)。
# 客户账号必须显式 `token_type="customer"`。
for key, actor, tt in (("customer", CUSTOMER, "customer"),
("other", OTHER_CUSTOMER, "customer"),
("advisor", ADVISOR, "staff"),
("risk", RISK, "staff"),
("spare", SPARE, "staff")):
try:
ctx.h[key] = login(ctx, actor, tt)
print(f" 登录 {actor} -> ok")
except Exception as exc: # noqa: BLE001
if key == "spare":
# 备用账号不可用不该阻断整轮:GX4 会据此 SKIP 而不是假 FAIL。
print(f" ⚠️ 备用账号 {actor} 登录失败:{exc}")
continue
failures.append(f"登录 {actor} 失败: {exc}")
if failures:
print("\n前置自检未通过:")
for f in failures:
print(f" - {f}")
return not failures
# --------------------------------------------------------------------------
# GX1 · POST /api/customers/{id}/threshold-check ★零覆盖
# --------------------------------------------------------------------------
def gx1(ctx: Ctx) -> None:
G = "GX1"
# 该路由走 `get_platform_auth_context`(`customers.py:67`)—— **不要求** X-Agent-Type。
# 前端调用方:`CustomerHoldingsPage.tsx:21-27` 的「检查提醒阈值」按钮。
for key, cid, label in (
("risk", OWNED_CUSTOMER, "risk_officer"),
("customer", CUSTOMER, "customer 本人"),
("advisor", OWNED_CUSTOMER, "归属 advisor"),
):
r = ctx.client.post(f"/api/customers/{cid}/threshold-check", headers=ctx.h[key])
if not expect_status(G, f"1-01 POST {cid}/threshold-check({label})", r, 200):
continue
d = data_of(r)
expect_true(G, f"1-01a 返回体含 alert/pushed 两键({label})",
"alert" in d and "pushed" in d,
"data.alert + data.pushed",
f"keys={sorted(d)} alert={d.get('alert')!r} pushed={d.get('pushed')!r}")
# 不带 `push` 参数时**不得**推送(`threshold_service.py:122-124` 只在 alert 真值时推)
expect_true(G, f"1-01b 未带 push 参数 ⇒ pushed=false({label})",
d.get("pushed") is False, "pushed=False", f"pushed={d.get('pushed')!r}")
info(G, f"1-01c alert 实测值({label})", f"{str(d.get('alert'))[:120]}")
# `push=true` 正例:只在有 alert 时才真的发布(无 alert 时是 no-op,不构成副作用)
r = ctx.client.post(f"/api/customers/{OWNED_CUSTOMER}/threshold-check?push=true", headers=ctx.h["risk"])
expect_status(G, "1-02 push=true 正常返回", r, 200)
if r.status_code == 200:
d = data_of(r)
info(G, "1-02a push=true 实测(alert 为空时按设计为 no-op)",
f"alert={str(d.get('alert'))[:80]!r} pushed={d.get('pushed')!r}")
# ── 负例:越权 ──
r = ctx.client.post(f"/api/customers/{OWNED_CUSTOMER}/threshold-check", headers=ctx.h["other"])
expect_code_in(G, f"1-03 非本人 customer 查 {OWNED_CUSTOMER} → 403(AUTH_403_NOT_OWNER)",
r, 403, {"AUTH_403_NOT_OWNER"})
r = ctx.client.post("/api/customers/CUST-1004/threshold-check", headers=ctx.h["advisor"])
expect_code_in(G, "1-04 非归属 advisor 查 CUST-1004 → 403(AUTH_403_NOT_ASSIGNED)",
r, 403, {"AUTH_403_NOT_ASSIGNED"})
r = ctx.client.post(f"/api/customers/{UNKNOWN_ID}/threshold-check", headers=ctx.h["risk"])
# 事实(已读源码核对):**不 404**。持仓查空 → alert=None → 200。
# 这条是本组最容易被"想当然"写错的地方 —— 断言按**实测事实**写,并把
# "未知客户不报错"这一行为本身记为 INFO(是否算缺陷取决于产品口径,本轮不下判)。
if r.status_code == 200:
ok(G, f"1-05 未知客户 {UNKNOWN_ID} → 200 且 alert=null(实测口径,不 404)",
f"HTTP 200 alert=None", f"HTTP {r.status_code} alert={data_of(r).get('alert')!r}")
info(G, "1-05a 未知客户的实际行为", "持仓查空 ⇒ alert=None ⇒ 200 静默返回(不区分"
"「客户不存在」与「无提醒」,见 `threshold_service.py:64-70`)")
else:
bad(G, f"1-05 未知客户 {UNKNOWN_ID} → 期望与实测口径一致",
"HTTP 200(源码事实)", f"HTTP {r.status_code} {err_code(r)}", r.text[:200])
# --------------------------------------------------------------------------
# GX2 · GET /api/staff/me(仅单测 · 前端零调用)
# --------------------------------------------------------------------------
def gx2(ctx: Ctx) -> None:
G = "GX2"
r = ctx.client.get("/api/staff/me", headers=ctx.h["advisor"])
expect_status(G, "2-01 GET /api/staff/me(staff token)", r, 200)
if r.status_code == 200:
d = data_of(r)
expect_true(G, "2-01a 返回体含 actor_id 且等于登录账号",
d.get("actor_id") == ADVISOR or d.get("staff_id") == ADVISOR,
f"actor_id={ADVISOR}",
f"keys={sorted(d)} actor_id={d.get('actor_id')!r} staff_id={d.get('staff_id')!r}")
info(G, "2-01b /staff/me 返回字段", f"{sorted(d)}")
expect_true(G, "2-01c roles 为 list(非 JSON 字符串)",
isinstance(d.get("roles"), list), "list", f"{type(d.get('roles')).__name__}")
# customer token → `require_staff_token`(`deps.py:353-356`)拒
r = ctx.client.get("/api/staff/me", headers=ctx.h["customer"])
expect_code_in(G, "2-02 customer token → 403(AUTH_403_SCOPE)", r, 403, {"AUTH_403_SCOPE"})
# 无 token 的 401 **不是**由 token 校验发出的,而是先撞上开发态的调试头网关
# (`deps.py:268-270`:dev 下无 Authorization 时改用 `X-Debug-Role`/`X-Debug-Actor`,
# 两样都没有 → `AUTH_401_MISSING_DEBUG_HEADERS`)。实测首跑冻结,按集合接受。
r = ctx.client.get("/api/staff/me")
expect_code_in(G, "2-03 无 token → 401", r, 401,
{"AUTH_401_MISSING_DEBUG_HEADERS", "AUTH_401_MISSING_TOKEN", "AUTH_401"})
# --------------------------------------------------------------------------
# GX3 · GET /api/products/{product_id}(仅单测 · 前端零调用)
# --------------------------------------------------------------------------
def gx3(ctx: Ctx) -> None:
G = "GX3"
r = ctx.client.get(f"/api/products/{PRODUCT}", headers=ctx.h["risk"])
expect_status(G, f"3-01 GET /api/products/{PRODUCT}", r, 200)
if r.status_code == 200:
d = data_of(r)
expect_true(G, "3-01a 返回体含 product_id 且与请求一致",
d.get("product_id") == PRODUCT, f"product_id={PRODUCT}",
f"keys={sorted(d)[:12]}")
info(G, "3-01b 产品字段清单", f"{sorted(d)}")
# customer 角色也在 `PLATFORM_FULL_READ_ROLES` 白名单(`deps.py:40`)内
r = ctx.client.get(f"/api/products/{PRODUCT}", headers=ctx.h["customer"])
expect_status(G, "3-02 customer 角色可读产品目录", r, 200)
r = ctx.client.get("/api/products/PROD-DOES-NOT-EXIST", headers=ctx.h["risk"])
expect_code_in(G, "3-03 未知产品 → 404(NOT_FOUND)", r, 404, {"NOT_FOUND"})
r = ctx.client.get(f"/api/products/{PRODUCT}")
# 同 2-03:dev 态下先撞调试头网关。
expect_code_in(G, "3-04 无 token → 401", r, 401,
{"AUTH_401_MISSING_DEBUG_HEADERS", "AUTH_401_MISSING_TOKEN", "AUTH_401"})
# --------------------------------------------------------------------------
# GX2b · 登录接口的 token_type × actor 组合面(**只记录**,供后续拍板)
# --------------------------------------------------------------------------
def gx2b(ctx: Ctx) -> None:
"""`/api/auth/login` 允许给**客户账号**签发 `token_type="staff"` 的 token。
实测三条事实(本组不判缺陷,只取证):
1. `token_type="staff"` + actor=`CUST-9527` → JWT `customer_id=None`,
于是 `assert_customer_access` 的 self 域判定失败 ⇒ **客户查自己 403 NOT_OWNER**;
2. 同一 token 打 `GET /api/staff/me`:`require_staff_token`(`deps.py:353`)**只看
`token_type` 不看角色** ⇒ 放行 → 再查 staff 表查无此人 → **404 `staff not found`**;
3. 换 `token_type="customer"` → `customer_id` 正常签发,self 域通过(200),
而 `/api/staff/me` 正确拒为 403 AUTH_403_SCOPE。
⇒ 结论:客户身份**必须**用 `token_type="customer"` 登录;用错类型不会越权
(staff/me 只回自己且 404),但会**制造 403/404 的语义混淆**。
"""
G = "GX2b"
wrong = login(ctx, CUSTOMER) # 故意用默认的 token_type="staff"
right = login(ctx, CUSTOMER, "customer")
r = ctx.client.get("/api/staff/me", headers=wrong)
expect_code_in(G, "2b-01 staff 型 token 载客户号 → /staff/me 实测为 404(非 403)",
r, 404, {"NOT_FOUND"})
info(G, "2b-01a 该行为的原因",
"`require_staff_token` 只比 `auth.token_type`(deps.py:353-356),staff 型即放行,"
"随后 `staff_service.get_staff_me('CUST-9527')` 查无此人 → 404")
r = ctx.client.get("/api/staff/me", headers=right)
expect_code_in(G, "2b-02 customer 型 token → /staff/me 正确拒 403(AUTH_403_SCOPE)",
r, 403, {"AUTH_403_SCOPE"})
# --------------------------------------------------------------------------
# GX4 · 对话会话侧栏(close-all / close)—— 仅单测,前端有按钮但没人点过
# --------------------------------------------------------------------------
def gx4(ctx: Ctx) -> None:
G = "GX4"
spare = ctx.h.get("spare")
if not spare:
skip(G, "4-01..4-08", f"备用账号 {SPARE} 登录失败,按纪律不用主演示账号做状态变更")
return
# `/api/chat/*` 走 `get_auth_context` ⇒ **要求** X-Agent-Type(`deps.py:248-251`)。
h = {**spare, "X-Agent-Type": "analyst"}
# (a) 缺头负例
r = ctx.client.post("/api/chat/sessions/close-all", headers=spare)
expect_code_in(G, "4-01 缺 X-Agent-Type → 401(AUTH_401_MISSING_AGENT_TYPE)",
r, 401, {"AUTH_401_MISSING_AGENT_TYPE"})
# (b) 非法头负例
r = ctx.client.post("/api/chat/sessions/close-all", headers={**spare, "X-Agent-Type": "nope"})
expect_code_in(G, "4-02 非法 X-Agent-Type → 400(BAD_REQUEST)", r, 400, {"BAD_REQUEST"})
# (c) 正常路径
r = ctx.client.post("/api/chat/sessions/close-all", headers=h)
expect_status(G, f"4-03 POST /sessions/close-all({SPARE} · 非主演示账号)", r, 200)
if r.status_code != 200:
return
d = body(r)
expect_true(G, "4-03a 返回体为裸 dict 且含 closed_count/agent_type",
"closed_count" in d and d.get("agent_type") == "analyst",
"closed_count:int + agent_type='analyst'",
f"keys={sorted(d)} closed_count={d.get('closed_count')!r} agent_type={d.get('agent_type')!r}")
info(G, "4-03b close-all 实际关闭条数(0 = 该账号本次零副作用)", f"{d.get('closed_count')}")
# (d) 幂等:紧接着再调一次,两次都不该报错
r2 = ctx.client.post("/api/chat/sessions/close-all", headers=h)
expect_status(G, "4-04 close-all 幂等(紧接着再调仍 200)", r2, 200)
if r2.status_code == 200:
expect_true(G, "4-04a 第二次 close-all 的 closed_count=0",
body(r2).get("closed_count") == 0, "closed_count=0",
f"closed_count={body(r2).get('closed_count')!r}")
# (e) 列表
r = ctx.client.get("/api/chat/sessions", headers=h)
expect_status(G, "4-05 GET /sessions(会话侧栏数据源)", r, 200)
sessions: list = []
if r.status_code == 200:
dd = data_of(r)
sessions = dd if isinstance(dd, list) else (dd.get("items") or [])
info(G, "4-05a 该账号会话条数与状态分布",
f"n={len(sessions)} statuses={sorted({s.get('status') for s in sessions if isinstance(s, dict)})}")
# (f) 不存在的会话 → 404
r = ctx.client.post("/api/chat/sessions/does-not-exist/close", headers=h)
expect_code_in(G, "4-06 关闭不存在的会话 → 404(NOT_FOUND)", r, 404, {"NOT_FOUND"})
# (g) 状态机:已 closed 的会话再关闭 → 409(**并发双点的兜底码**,`chat.py:400-403`)
target = next((s for s in sessions if isinstance(s, dict) and s.get("status") == "closed"), None)
if target is None:
info(G, "4-07 已关闭会话再关闭 → 409", "该账号无 closed 会话,状态机分支未覆盖(记录)")
else:
r = ctx.client.post(f"/api/chat/sessions/{target['session_id']}/close", headers=h)
expect_code_in(G, "4-07 已关闭会话再关闭 → 409(STATE_CONFLICT)",
r, 409, {"STATE_CONFLICT"})
# (h) 越权:会话归属他人 → AUTH_403_SESSION_AGENT
other = ctx.h.get("advisor")
if other and target is not None:
h2 = {**other, "X-Agent-Type": "analyst"}
r = ctx.client.post(f"/api/chat/sessions/{target['session_id']}/close", headers=h2)
expect_code_in(G, "4-08 他人会话(actor 不一致)→ 403(AUTH_403_SESSION_AGENT)",
r, 403, {"AUTH_403_SESSION_AGENT"})
else:
skip(G, "4-08 他人会话越权 → 403", "无可用对照会话")
# --------------------------------------------------------------------------
# GX5 · 文档类发现(**只记录不修**,静态取证)
# --------------------------------------------------------------------------
def gx5(ctx: Ctx) -> None:
G = "GX5"
root = Path(__file__).resolve().parents[2]
# ── 5-01 `D2`/`D3` 一号两义(答辩稿缺陷)──
doc = root / "docs" / "答辩" / "DEMO-功能测试流程.md"
if not doc.exists():
skip(G, "5-01 D2/D3 编号歧义取证", f"文档不存在:{doc}")
else:
lines = doc.read_text(encoding="utf-8", errors="ignore").splitlines()
# 行号按 ADV-001 v1.0 记录(第 64-65 行 = 问数线用例;第 79/89 行 = 缺陷编号)
hits = [(i + 1, ln.strip()[:90]) for i, ln in enumerate(lines)
if ("D2" in ln or "D3" in ln)]
use_lines = [h for h in hits if "D2" in h[1] and "D3" in h[1]]
info(G, "5-01 D2/D3 在答辩稿中的出现位置",
f"共 {len(hits)} 行;同时含 D2 与 D3 的行={[h[0] for h in use_lines]}")
info(G, "5-01a 前 6 行原文", " | ".join(f"L{n}:{t}" for n, t in hits[:6]))
info(G, "5-01b 结论", "同一符号既作用例编号又作缺陷编号 ⇒ 本包报告一律不用裸 D2/D3,"
"改写为「问数线 D2」/「ADV 缺陷-2」")
# ── 5-02 验收标准陈旧门槛 ──
std = root / "docs" / "演示文档" / "功能演示版验收标准.md"
if std.exists():
txt = std.read_text(encoding="utf-8", errors="ignore")
for needle, why in (("/api/v1/ping", "该路由在当前 openapi 中不存在"),
("run_demo_smoke.ps1", "该脚本仍打 /api/v1/*,实测已失效")):
info(G, f"5-02 验收标准引用 {needle}",
"命中" if needle in txt else "未命中", why)
try:
paths = set(ctx.client.get("/openapi.json").json().get("paths", {}))
info(G, "5-02a 运行时 openapi 是否含 /api/v1/ping",
"含" if any(p.startswith("/api/v1") for p in paths) else "不含(文档门槛已陈旧)")
except Exception as exc: # noqa: BLE001
info(G, "5-02a 运行时 openapi 探测", f"失败:{type(exc).__name__}: {exc}")
else:
skip(G, "5-02 验收标准陈旧门槛取证", f"文档不存在:{std}")
# ── 5-03 失效脚本仍在仓库 ──
for rel, note in (("scripts/demo/run_demo_smoke.ps1", "唯一 demo 冒烟脚本"),
("scripts/eval/evaluate_agent.py", "第 49/59 行同打 /api/v1/*")):
p = root / rel
if not p.exists():
info(G, f"5-03 {rel}", "不存在", note)
continue
t = p.read_text(encoding="utf-8", errors="ignore")
n = t.count("/api/v1/")
info(G, f"5-03 {rel} 引用 /api/v1/ 次数", f"{n} 次",
f"{note} ⇒ {'已失效(会 404)' if n else '无 /api/v1 引用'}")
GROUPS = {"GX1": gx1, "GX2": gx2, "GX2b": gx2b, "GX3": gx3, "GX4": gx4, "GX5": gx5}
ORDER = ["GX1", "GX2", "GX2b", "GX3", "GX4", "GX5"]
def main() -> int:
parser = argparse.ArgumentParser(description="孤立路由端到端补测(GX1–GX5)")
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())