Bladeren bron

test(ci): 新增接口测试闭环定时任务(pull→检测新需求→回归+补全用例→提交→push)

- run_interface_tests_cron.sh: 闭环主体(git pull → 扫描前端接口面检测新增/变更需求 →
  冒烟回归对比 → 为新增接口自动补全用例并跑新测试 → 提交结果 → push 与开发团队闭环)
- scan_frontend_endpoints.py: 扫描 cfc-frontend 提取所有 request() 的 method+path,用于需求变更检测
- run_interface_tests_cron.bat: Windows 任务计划程序启动器(定位 git-bash 调用 .sh)
- ops_interface_smoke.py: 新增 --extra 用例文件支持,便于新需求自动补全测试覆盖
E2E Test Bot 2 weken geleden
bovenliggende
commit
e823ddfd59
4 gewijzigde bestanden met toevoegingen van 309 en 0 verwijderingen
  1. 17 0
      ops_interface_smoke.py
  2. 18 0
      run_interface_tests_cron.bat
  3. 202 0
      run_interface_tests_cron.sh
  4. 72 0
      scan_frontend_endpoints.py

+ 17 - 0
ops_interface_smoke.py

@@ -1116,8 +1116,25 @@ def main():
     ap.add_argument('--timeout', type=int, default=10)
     ap.add_argument('--only-page', default='', help='只跑指定页面路由(含匹配即可)')
     ap.add_argument('--report', default='', help='结果报告输出文件')
+    ap.add_argument('--extra', default='', help='额外用例文件 TSV(page fn method path auth param),追加到 CASES,用于新需求/变更需求补全测试')
     args = ap.parse_args()
 
+    # 加载额外用例(新需求/变更需求补全):追加到 CASES 后再跑
+    if args.extra and os.path.exists(args.extra):
+        _added = 0
+        with open(args.extra, 'r', encoding='utf-8') as _ef:
+            for _line in _ef:
+                _line = _line.rstrip('\n')
+                if not _line or _line.startswith('page'):
+                    continue
+                _p = _line.split('\t')
+                if len(_p) < 6:
+                    continue
+                _page, _fn, _method, _path, _auth, _param = _p[:6]
+                CASES.append((_page, _method, _path, _auth.lower() == 'true', _param.lower() == 'true', _fn))
+                _added += 1
+        print(f'已追加额外用例: {_added} 条(来自 {args.extra})')
+
     token = os.getenv('CFC_TOKEN')
     if args.auto_login and not token:
         token = auto_login(args.base, host=args.host, timeout=args.timeout)

+ 18 - 0
run_interface_tests_cron.bat

@@ -0,0 +1,18 @@
+@echo off
+REM CFC 接口测试闭环 - Windows 任务计划程序启动器
+REM 由任务计划程序每 30 分钟调用;以当前用户身份运行(需能访问 git 凭证与 192.168.16.251)
+setlocal
+cd /d D:\cfc
+
+REM 定位 git-bash(常见安装路径,按顺序尝试)
+set "BASH="
+if exist "%ProgramFiles%\Git\bin\bash.exe" set "BASH=%ProgramFiles%\Git\bin\bash.exe"
+if not defined BASH if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" set "BASH=%ProgramFiles(x86)%\Git\bin\bash.exe"
+if not defined BASH for %%i in (bash.exe) do set "BASH=%%~$PATH:i"
+if not defined BASH (
+  echo [ERROR] 未找到 bash.exe,请安装 Git for Windows 或将 git\bin 加入 PATH
+  exit /b 1
+)
+
+"%BASH%" --login -c "cd /d/cfc && ./run_interface_tests_cron.sh"
+endlocal

+ 202 - 0
run_interface_tests_cron.sh

@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+# =============================================================================
+# CFC 接口测试闭环(定时任务主体)
+# 流程:git pull 最新 -> 检测新增/变更需求 -> 跑冒烟(含回归对比) ->
+#       为新增接口补全用例并跑新测试 -> 提交测试结果 -> push 到远端(与开发团队闭环)
+#
+# 网络说明:测试目标 192.168.16.251 只能在「前台/宿主机」网络可达。
+#           本脚本设计为在宿主机用 Windows 任务计划程序每 30 分钟调用,
+#           不要在受限沙箱后台运行(后台网络被拦截)。
+# =============================================================================
+set -uo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$REPO_DIR" || exit 1
+
+BRANCH="${CFC_BRANCH:-feature/ui-token-u5-color-tokens}"
+BASE_URL="${CFC_BASE:-http://192.168.16.251}"
+HOST_HDR="${CFC_HOST:-cfc.iwintrue.com}"
+TIMEOUT="${CFC_TIMEOUT:-3}"
+TEST_SCRIPT="ops_interface_smoke.py"
+SCANNER="scan_frontend_endpoints.py"
+
+LOG_DIR="$REPO_DIR/logs"
+mkdir -p "$LOG_DIR"
+
+TS="$(date +%Y%m%d_%H%M%S)"
+RUN_DIR="$LOG_DIR/run_$TS"
+mkdir -p "$RUN_DIR"
+
+CUR_TSV="$RUN_DIR/results_$TS.tsv"
+ENDPOINTS_CUR="$RUN_DIR/endpoints_$TS.txt"
+EXTRA_CASES="$REPO_DIR/interface_test_extra_cases.tsv"
+
+BASELINE_EP="$REPO_DIR/interface_test_baseline_endpoints.txt"
+PREV_TSV="$REPO_DIR/interface_test_prev.tsv"
+TREND="$REPO_DIR/interface_test_trend.log"
+
+MANAGED="INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md ops_interface_smoke.py"
+
+log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_DIR/interface_cron.log"; }
+
+log "===== CFC 接口测试闭环 开始 $TS ====="
+log "REPO=$REPO_DIR BRANCH=$BRANCH BASE=$BASE_URL HOST=$HOST_HDR"
+
+# ---------------------------------------------------------------------------
+# 1) 拉取远端最新(含开发团队的修复/新需求)
+#    仅丢弃我们生成的报告类文件本地改动(每次都会重新生成),其余未提交改动用
+#    --autostash 处理,避免破坏用户其他在途工作。
+# ---------------------------------------------------------------------------
+git checkout -- INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md 2>/dev/null
+if git pull --rebase --autostash origin "$BRANCH" >>"$LOG_DIR/interface_cron.log" 2>&1; then
+  log "git pull 成功"
+else
+  log "git pull 失败(可能本地有冲突或凭证问题),用当前代码继续"
+fi
+
+# ---------------------------------------------------------------------------
+# 2) 检测新增/变更需求(前端接口面变化)
+# ---------------------------------------------------------------------------
+python "$SCANNER" --out "$ENDPOINTS_CUR" 2>/dev/null
+NEWEP=""; RMVEP=""
+if [ -f "$BASELINE_EP" ]; then
+  NEWEP=$(comm -13 <(sort "$BASELINE_EP") <(sort "$ENDPOINTS_CUR"))
+  RMVEP=$(comm -23 <(sort "$BASELINE_EP") <(sort "$ENDPOINTS_CUR"))
+  n_new=$(echo "$NEWEP" | grep -c . || true)
+  n_rmv=$(echo "$RMVEP" | grep -c . || true)
+  log "接口面变化:新增 $n_new 个,移除 $n_rmv 个"
+  if [ "$n_new" -gt 0 ]; then
+    : > "$EXTRA_CASES"
+    while IFS= read -r line; do
+      [ -z "$line" ] && continue
+      m=$(echo "$line" | awk '{print $1}')
+      p=$(echo "$line" | awk '{print $2}')
+      printf 'auto\tauto\t%s\t%s\tfalse\tfalse\n' "$m" "$p" >> "$EXTRA_CASES"
+    done <<< "$NEWEP"
+    log "已为新增接口生成补全用例 -> $EXTRA_CASES"
+  fi
+else
+  log "首次运行:建立接口基线(不触发补全)"
+fi
+cp "$ENDPOINTS_CUR" "$BASELINE_EP"
+
+# ---------------------------------------------------------------------------
+# 3) 跑冒烟测试(前台调用,含登录态;新增接口通过 --extra 一并测试)
+# ---------------------------------------------------------------------------
+EXTRA_FLAG=""
+[ -s "$EXTRA_CASES" ] && EXTRA_FLAG="--extra $EXTRA_CASES"
+log "开始执行冒烟测试 $EXTRA_FLAG"
+python "$TEST_SCRIPT" --base "$BASE_URL" --host "$HOST_HDR" \
+  --timeout "$TIMEOUT" --auto-login --authed $EXTRA_FLAG \
+  --report "$CUR_TSV" > "$RUN_DIR/smoke_stdout.txt" 2>&1
+log "冒烟测试完成,结果 -> $CUR_TSV"
+
+# ---------------------------------------------------------------------------
+# 4) 汇总 + 回归对比 + 生成报告
+# ---------------------------------------------------------------------------
+python - "$CUR_TSV" "$PREV_TSV" "$RUN_DIR/report_$TS.md" "$TS" "$TREND" <<'PY'
+import sys, collections
+cur_tsv, prev_tsv, rpt, ts, trend = sys.argv[1:6]
+
+def load(path):
+    d = {}
+    try:
+        with open(path, encoding='utf-8') as f:
+            next(f, None)
+            for line in f:
+                c = line.rstrip('\n').split('\t')
+                if len(c) < 7: continue
+                page, fn, method, path, http, biz, cat = c
+                d[(method, path)] = (page, fn, http, biz, cat)
+    except FileNotFoundError:
+        pass
+    return d
+
+cur = load(cur_tsv)
+prev = load(prev_tsv) if prev_tsv and os.path.exists(prev_tsv) else {}
+
+cats = collections.Counter(v[4] for v in cur.values())
+total = len(cur)
+ok = cats.get('BIZ_OK', 0)
+rate = ok * 100 // total if total else 0
+
+# 回归对比
+fixed = []      # 之前非 OK -> 现在 OK
+regressed = []  # 之前 OK -> 现在非 OK
+for k, v in cur.items():
+    cat = v[4]
+    if k in prev:
+        pcat = prev[k][4]
+        if pcat != 'BIZ_OK' and cat == 'BIZ_OK':
+            fixed.append((k, pcat, cat))
+        elif pcat == 'BIZ_OK' and cat != 'BIZ_OK':
+            regressed.append((k, pcat, cat))
+
+def top(cat, n=20):
+    return [(k[0], k[1], v[3], v[4]) for k, v in cur.items() if v[4] == cat][:n]
+
+lines = []
+lines.append(f"# CFC 接口冒烟测试报告 ({ts})\n")
+lines.append(f"- 目标: {os.environ.get('CFC_HOST', 'cfc.iwintrue.com')}  用例总数: **{total}**")
+lines.append(f"- 业务成功率(BIZ_OK): **{ok}/{total} = {rate}%**\n")
+lines.append("## 分类汇总")
+for k in ('BIZ_OK','BIZ_FAIL','NOT_FOUND','SERVER_ERR','NON_JSON','CONN_ERR'):
+    if cats.get(k): lines.append(f"- {k}: {cats[k]}")
+lines.append("")
+if fixed:
+    lines.append(f"## ✅ 回归修复({len(fixed)} 个,之前失败现已通过)")
+    for (m,p),pc,_ in fixed[:30]:
+        lines.append(f"- `{m} {p}`  (原 {pc})")
+    lines.append("")
+if regressed:
+    lines.append(f"## ⚠️ 回归劣化({len(regressed)} 个,之前通过现失败)")
+    for (m,p),_,c in regressed[:30]:
+        lines.append(f"- `{m} {p}`  (现 {c})")
+    lines.append("")
+lines.append(f"## 需开发团队关注的契约缺口 (NOT_FOUND={cats.get('NOT_FOUND',0)})")
+for m,p,b,c in top('NOT_FOUND'):
+    lines.append(f"- `{m} {p}`  (biz={b}, {c})")
+lines.append("")
+lines.append(f"## 健壮性风险 (SERVER_ERR={cats.get('SERVER_ERR',0)})")
+for m,p,b,c in top('SERVER_ERR'):
+    lines.append(f"- `{m} {p}`  (http={b}, {c})")
+lines.append("")
+lines.append("> 说明:冒烟为空体请求,部分 500/400 属预期(缺必填参数)。NOT_FOUND 与回归劣化才需优先处理。")
+
+report = "\n".join(lines) + "\n"
+with open(rpt, 'w', encoding='utf-8') as f:
+    f.write(report)
+
+# 趋势日志
+with open(trend, 'a', encoding='utf-8') as f:
+    f.write(f"{ts}\ttotal={total}\tBIZ_OK={ok}\trate={rate}%\t"
+            f"fixed={len(fixed)}\tregressed={len(regressed)}\t"
+            f"NOT_FOUND={cats.get('NOT_FOUND',0)}\tSERVER_ERR={cats.get('SERVER_ERR',0)}\n")
+print(report)
+print(f"\n[趋势] 已写入 {trend}")
+PY
+
+# ---------------------------------------------------------------------------
+# 5) 提交测试结果 + 补全用例
+# ---------------------------------------------------------------------------
+git add -f ops_interface_smoke.py
+git add INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md "$EXTRA_CASES" 2>/dev/null
+if git diff --cached --quiet; then
+  log "无测试结果变更,跳过提交"
+else
+  git commit -m "test(interface): 接口冒烟闭环 $(date '+%Y-%m-%d %H:%M') — 通过率 ${rate:-?}% (fix=$(echo $fixed|wc -l) reg=$(echo $regressed|wc -l))" \
+    >>"$LOG_DIR/interface_cron.log" 2>&1 && log "已提交测试结果"
+fi
+
+# ---------------------------------------------------------------------------
+# 6) 推送远端,与开发团队形成闭环
+# ---------------------------------------------------------------------------
+if git push origin "$BRANCH" >>"$LOG_DIR/interface_cron.log" 2>&1; then
+  log "git push 成功 — 测试闭环已同步远端"
+else
+  log "git push 失败(凭证/网络),下次运行将重试"
+fi
+
+# 更新回归基线
+cp "$CUR_TSV" "$PREV_TSV"
+log "===== 本轮结束 ====="

+ 72 - 0
scan_frontend_endpoints.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+"""
+扫描 cfc-frontend,提取所有 request() 调用的 method+path,用于检测
+「新增 / 变更需求」——与基线对比即可知道前端新增了哪些接口,从而自动补全用例。
+
+输出(默认 endpoints 模式):每行 `METHOD /api/path`(排序去重),便于 comm 对比。
+  python scan_frontend_endpoints.py --out endpoints.txt
+  python scan_frontend_endpoints.py --mode cases --out extra.tsv   # 直接产出冒烟用例 TSV
+"""
+import os, re, sys, argparse
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+FRONTEND = os.path.join(HERE, 'cfc-frontend')
+
+# 两种 request 调用风格:
+# 1) request('/api/x', 'POST', data)
+# 2) request({ url: BASE_URL + '/api/x', method: 'POST' })
+path_re_pos = re.compile(r"request\(\s*['\"](/api/[^'\"]+)['\"]\s*,\s*['\"](GET|POST|PUT|DELETE|PATCH)['\"]", re.I)
+path_re_obj = re.compile(r"url\s*:\s*(?:BASE_URL\s*\+\s*)?['\"](/api/[^'\"]+)['\"]")
+method_re_obj = re.compile(r"method\s*:\s*['\"](GET|POST|PUT|DELETE|PATCH)['\"]", re.I)
+
+SKIP_DIRS = ('node_modules', 'unpackage', '.git', 'dist', 'assets')
+
+
+def scan_file(path):
+    out = set()
+    try:
+        with open(path, 'r', encoding='utf-8', errors='ignore') as f:
+            text = f.read()
+    except Exception:
+        return out
+    for m in path_re_pos.finditer(text):
+        out.add((m.group(2).upper(), m.group(1)))
+    for um in path_re_obj.finditer(text):
+        p = um.group(1)
+        window = text[um.end(): um.end() + 200]
+        mm = method_re_obj.search(window)
+        method = mm.group(1).upper() if mm else 'POST'
+        out.add((method, p))
+    return out
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--frontend', default=FRONTEND)
+    ap.add_argument('--out')
+    ap.add_argument('--mode', choices=['endpoints', 'cases'], default='endpoints')
+    args = ap.parse_args()
+
+    results = set()
+    for root, dirs, files in os.walk(args.frontend):
+        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
+        for fn in files:
+            if fn.endswith(('.js', '.vue')):
+                results |= scan_file(os.path.join(root, fn))
+
+    if args.mode == 'cases':
+        # page fn method path auth param —— 自动补全的用例默认匿名、无路径参数
+        lines = sorted(f"auto\tauto\t{m}\t{p}\tfalse\tfalse" for m, p in results)
+    else:
+        lines = sorted(f"{m} {p}" for m, p in results)
+
+    text = "\n".join(lines) + ("\n" if lines else "")
+    if args.out:
+        with open(args.out, 'w', encoding='utf-8') as f:
+            f.write(text)
+    else:
+        sys.stdout.write(text)
+
+
+if __name__ == '__main__':
+    main()