run_interface_tests_cron.sh 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. #!/usr/bin/env bash
  2. # =============================================================================
  3. # CFC 接口测试闭环(定时任务主体)
  4. # 流程:git pull 最新 -> 检测新增/变更需求 -> 跑冒烟(含回归对比) ->
  5. # 为新增接口补全用例并跑新测试 -> 输出测试用例完成情况(总数/已完成/未开始原因) ->
  6. # 提交测试结果 -> push 到远端(与开发团队闭环)
  7. #
  8. # 网络说明:原默认目标 192.168.16.251 已不可用(上游失效)。
  9. # 当前默认测试目标为 cfc.iwintrue.com(公网可达、后端在线)。
  10. # 本脚本设计为在宿主机用 Windows 任务计划程序每 30 分钟调用,
  11. # 不要在受限沙箱后台运行(后台网络被拦截)。
  12. # =============================================================================
  13. set -uo pipefail
  14. REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  15. cd "$REPO_DIR" || exit 1
  16. LOG_DIR="$REPO_DIR/logs"
  17. mkdir -p "$LOG_DIR"
  18. # 防重入:若上轮仍在 25 分钟内,跳过本轮(避免 30 分钟间隔下长耗时测试重叠)
  19. LOCK="$LOG_DIR/.loop.lock"
  20. NOW=$(date +%s)
  21. if [ -f "$LOCK" ]; then
  22. LAST=$(cat "$LOCK" 2>/dev/null || echo 0)
  23. if [ $((NOW - LAST)) -lt 1500 ]; then
  24. echo "[$(date +%H:%M:%S)] 上轮仍在运行(锁 $(( (NOW - LAST) / 60 )) 分钟前),跳过本轮"
  25. exit 0
  26. fi
  27. fi
  28. echo "$NOW" > "$LOCK"
  29. trap 'rm -f "$LOCK"' EXIT
  30. BRANCH="${CFC_BRANCH:-cfclub}"
  31. BASE_URL="${CFC_BASE:-http://cfc.iwintrue.com}"
  32. HOST_HDR="${CFC_HOST:-cfc.iwintrue.com}"
  33. TIMEOUT="${CFC_TIMEOUT:-8}"
  34. TEST_SCRIPT="ops_interface_smoke.py"
  35. SCANNER="scan_frontend_endpoints.py"
  36. TS="$(date +%Y%m%d_%H%M%S)"
  37. RUN_DIR="$LOG_DIR/run_$TS"
  38. mkdir -p "$RUN_DIR"
  39. CUR_TSV="$RUN_DIR/results_$TS.tsv"
  40. ENDPOINTS_CUR="$RUN_DIR/endpoints_$TS.txt"
  41. EXTRA_CASES="$REPO_DIR/interface_test_extra_cases.tsv"
  42. BASELINE_EP="$REPO_DIR/interface_test_baseline_endpoints.txt"
  43. PREV_TSV="$REPO_DIR/interface_test_prev.tsv"
  44. TREND="$REPO_DIR/interface_test_trend.log"
  45. MANAGED="INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md ops_interface_smoke.py interface_test_bodies.tsv"
  46. log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_DIR/interface_cron.log"; }
  47. log "===== CFC 接口测试闭环 开始 $TS ====="
  48. log "REPO=$REPO_DIR BRANCH=$BRANCH BASE=$BASE_URL HOST=$HOST_HDR"
  49. # ---------------------------------------------------------------------------
  50. # 1) 拉取远端最新(含开发团队的修复/新需求)
  51. # 仅丢弃我们生成的报告类文件本地改动(每次都会重新生成),其余未提交改动用
  52. # --autostash 处理,避免破坏用户其他在途工作。
  53. # ---------------------------------------------------------------------------
  54. git checkout -- INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md 2>/dev/null
  55. # 预检:若工作区已存在未解决冲突(多因上轮 autostash 回填冲突残留),先告警,避免本轮静默空转
  56. if git ls-files -u | grep -q .; then
  57. log "⚠️ 工作区已存在未解决冲突(unmerged),pull 前请先人工 git status 处理;本轮继续但提交大概率失败"
  58. fi
  59. if git pull --rebase --autostash origin "$BRANCH" >>"$LOG_DIR/interface_cron.log" 2>&1; then
  60. log "git pull 成功"
  61. else
  62. log "git pull 失败(可能本地有冲突或凭证问题),用当前代码继续"
  63. fi
  64. # 后检:pull+autostash 回填后出现未解决冲突时,明确提示——此状态会令后续 commit 被拒,
  65. # 由下方「提交守卫」拦截,绝不会误报 push 成功。
  66. if git ls-files -u | grep -q .; then
  67. log "⚠️ git pull --rebase --autostash 引入/残留未解决冲突,后续 git commit 将被拒绝(提交守卫会拦截,不假成功推送)"
  68. fi
  69. # ---------------------------------------------------------------------------
  70. # 2) 检测新增/变更需求(前端接口面变化)
  71. # ---------------------------------------------------------------------------
  72. python "$SCANNER" --out "$ENDPOINTS_CUR" 2>/dev/null
  73. NEWEP=""; RMVEP=""
  74. if [ -f "$BASELINE_EP" ]; then
  75. NEWEP=$(comm -13 <(sort "$BASELINE_EP") <(sort "$ENDPOINTS_CUR"))
  76. RMVEP=$(comm -23 <(sort "$BASELINE_EP") <(sort "$ENDPOINTS_CUR"))
  77. n_new=$(echo "$NEWEP" | grep -c . || true)
  78. n_rmv=$(echo "$RMVEP" | grep -c . || true)
  79. log "接口面变化:新增 $n_new 个,移除 $n_rmv 个"
  80. if [ "$n_new" -gt 0 ]; then
  81. # 合并而非截断:旧实现 `: > "$EXTRA_CASES"` 会先清空整个文件再只写「本轮新增」,
  82. # 导致上一轮自动补全的用例被静默丢弃(每出现 1 个新接口就抹掉上一批,持续性漏测)。
  83. # 改为与既有内容取并集去重后写回。
  84. _EXTRA_TMP="$EXTRA_CASES.tmp.$$"
  85. : > "$_EXTRA_TMP"
  86. [ -f "$EXTRA_CASES" ] && cat "$EXTRA_CASES" >> "$_EXTRA_TMP"
  87. while IFS= read -r line; do
  88. [ -z "$line" ] && continue
  89. m=$(echo "$line" | awk '{print $1}')
  90. p=$(echo "$line" | awk '{print $2}')
  91. printf 'auto\tauto\t%s\t%s\tfalse\tfalse\n' "$m" "$p" >> "$_EXTRA_TMP"
  92. done <<< "$NEWEP"
  93. # 按 method+path 去重排序后回写
  94. sort -u -t$'\t' -k3,4 "$_EXTRA_TMP" > "$EXTRA_CASES"
  95. rm -f "$_EXTRA_TMP"
  96. _extra_n=$(grep -c . "$EXTRA_CASES" 2>/dev/null || echo 0)
  97. log "已为新增接口生成补全用例(并集去重后共 $_extra_n 条)-> $EXTRA_CASES"
  98. fi
  99. else
  100. log "首次运行:建立接口基线(不触发补全)"
  101. fi
  102. cp "$ENDPOINTS_CUR" "$BASELINE_EP"
  103. # ---------------------------------------------------------------------------
  104. # 3) 跑冒烟测试(前台调用,含登录态;新增接口通过 --extra 一并测试)
  105. # ---------------------------------------------------------------------------
  106. EXTRA_FLAG=""
  107. [ -s "$EXTRA_CASES" ] && EXTRA_FLAG="--extra $EXTRA_CASES"
  108. # 真实请求体映射:存在则注入带参冒烟,破除空体 45% 天花板;缺失则自动跳过
  109. BODY_FLAG=""
  110. [ -s "interface_test_bodies.tsv" ] && BODY_FLAG="--body-map interface_test_bodies.tsv"
  111. log "开始执行冒烟测试 $EXTRA_FLAG $BODY_FLAG"
  112. python "$TEST_SCRIPT" --base "$BASE_URL" --host "$HOST_HDR" \
  113. --timeout "$TIMEOUT" --auto-login --authed $EXTRA_FLAG $BODY_FLAG \
  114. --report "$CUR_TSV" > "$RUN_DIR/smoke_stdout.txt" 2>&1
  115. log "冒烟测试完成,结果 -> $CUR_TSV"
  116. # ---------------------------------------------------------------------------
  117. # 4) 汇总 + 回归对比 + 生成报告
  118. # ---------------------------------------------------------------------------
  119. python - "$CUR_TSV" "$PREV_TSV" "INTERFACE_TEST_REPORT.md" "$TS" "$TREND" <<'PY'
  120. import sys, collections, os
  121. cur_tsv, prev_tsv, rpt, ts, trend = sys.argv[1:6]
  122. def load(path):
  123. d = {}
  124. try:
  125. with open(path, encoding='utf-8') as f:
  126. next(f, None)
  127. for line in f:
  128. c = line.rstrip('\n').split('\t')
  129. if len(c) < 7: continue
  130. page, fn, method, path, http, biz, cat = c
  131. d[(method, path)] = (page, fn, http, biz, cat)
  132. except FileNotFoundError:
  133. pass
  134. return d
  135. cur = load(cur_tsv)
  136. prev = load(prev_tsv) if prev_tsv and os.path.exists(prev_tsv) else {}
  137. cats = collections.Counter(v[4] for v in cur.values())
  138. total = len(cur)
  139. ok = cats.get('BIZ_OK', 0)
  140. rate = ok * 100 // total if total else 0
  141. # 回归对比
  142. fixed = [] # 之前非 OK -> 现在 OK
  143. regressed = [] # 之前 OK -> 现在非 OK
  144. for k, v in cur.items():
  145. cat = v[4]
  146. if k in prev:
  147. pcat = prev[k][4]
  148. if pcat != 'BIZ_OK' and cat == 'BIZ_OK':
  149. fixed.append((k, pcat, cat))
  150. elif pcat == 'BIZ_OK' and cat != 'BIZ_OK':
  151. regressed.append((k, pcat, cat))
  152. # CONN_ERR 参与的翻转不算真实修复/劣化:CONN_ERR 是连接中断/超时,
  153. # 长耗时 AI 接口(如 portrait 族)本身耗时在数秒到数十秒间抖动,会骑在客户端
  154. # 超时阈值上反复翻转,同一接口每轮 fix/reg 互换但后端实际一直是 200。
  155. # 故单列 flaky 计数,并在报告与 commit message 中标注,避免被误读成业绩/劣化。
  156. flaky_fix = [x for x in fixed if x[1] == 'CONN_ERR']
  157. flaky_reg = [x for x in regressed if x[2] == 'CONN_ERR']
  158. flaky_note = ''
  159. if flaky_fix or flaky_reg:
  160. flaky_note = f"(其中 CONN_ERR 翻转 fix={len(flaky_fix)}/reg={len(flaky_reg)} 属口径抖动,非真实修复/劣化)"
  161. def top(cat, n=20):
  162. return [(k[0], k[1], v[3], v[4]) for k, v in cur.items() if v[4] == cat][:n]
  163. lines = []
  164. lines.append(f"# CFC 接口冒烟测试报告 ({ts})\n")
  165. lines.append(f"- 目标: {os.environ.get('CFC_HOST', 'cfc.iwintrue.com')} 用例总数: **{total}**")
  166. lines.append(f"- 业务成功率(BIZ_OK): **{ok}/{total} = {rate}%**\n")
  167. lines.append("## 分类汇总")
  168. for k in ('BIZ_OK','BIZ_FAIL','AUTH_FAIL','NOT_FOUND','SERVER_ERR','NON_JSON','CONN_ERR'):
  169. if cats.get(k): lines.append(f"- {k}: {cats[k]}")
  170. lines.append("")
  171. if fixed:
  172. lines.append(f"## ✅ 回归修复({len(fixed)} 个,之前失败现已通过)")
  173. for (m,p),pc,_ in fixed[:30]:
  174. _tag = ' ← CONN_ERR 恢复,疑为口径抖动而非真实修复,需直连复核' if pc == 'CONN_ERR' else ''
  175. lines.append(f"- `{m} {p}` (原 {pc}){_tag}")
  176. lines.append("")
  177. if regressed:
  178. lines.append(f"## ⚠️ 回归劣化({len(regressed)} 个,之前通过现失败)")
  179. for (m,p),_,c in regressed[:30]:
  180. _tag = ' ← CONN_ERR(连接中断/超时),多为耗时抖动,需直连复核再判劣化' if c == 'CONN_ERR' else ''
  181. lines.append(f"- `{m} {p}` (现 {c}){_tag}")
  182. lines.append("")
  183. if flaky_note:
  184. lines.append(f"> {flaky_note}")
  185. lines.append("")
  186. lines.append(f"## 需开发团队关注的契约缺口 (NOT_FOUND={cats.get('NOT_FOUND',0)})")
  187. for m,p,b,c in top('NOT_FOUND'):
  188. lines.append(f"- `{m} {p}` (biz={b}, {c})")
  189. lines.append("")
  190. lines.append(f"## 健壮性风险 (SERVER_ERR={cats.get('SERVER_ERR',0)})")
  191. for m,p,b,c in top('SERVER_ERR'):
  192. lines.append(f"- `{m} {p}` (http={b}, {c})")
  193. lines.append("")
  194. lines.append("> 说明:冒烟为空体请求,部分 500/400 属预期(缺必填参数)。NOT_FOUND 与回归劣化才需优先处理。")
  195. # ───────────────────────── 守卫:识别无效轮 ─────────────────────────
  196. # 上游宕机 / 网关抖动 / 登录失败 / 部分轮 不应被当作真实回归提交,亦不可污染
  197. # 回归基线(PREV_TSV)。命中则标 INVALID,由 bash 侧跳过 commit/push/基线更新。
  198. non_json = cats.get('NON_JSON', 0)
  199. server_err = cats.get('SERVER_ERR', 0)
  200. conn_err = cats.get('CONN_ERR', 0)
  201. http_502 = sum(1 for v in cur.values() if v[2] == '502') # v[2]=http 列
  202. nz_rate = (non_json * 100 // total) if total else 0
  203. NON_JSON_THRESHOLD = 20 # NON_JSON 占比 > 20% 视为上游风暴
  204. HTTP502_THRESHOLD = 10 # HTTP 502 数 > 10 视为网关抖动
  205. MIN_TOTAL = 400 # 用例数 < 400 视为部分轮(如登录失败跳过 auth 用例)
  206. login_failed = False
  207. _smoke_out = os.path.join(os.path.dirname(cur_tsv), 'smoke_stdout.txt')
  208. try:
  209. with open(_smoke_out, encoding='utf-8', errors='ignore') as _f:
  210. _so = _f.read()
  211. if ('父/师 token 均未取得' in _so) or ('未取到 token' in _so) or ('登录态接口将被跳过' in _so):
  212. login_failed = True
  213. except FileNotFoundError:
  214. pass
  215. reasons = []
  216. if login_failed:
  217. reasons.append('登录失败(auto-login 未取得 token,auth 用例被跳过)')
  218. if nz_rate > NON_JSON_THRESHOLD:
  219. reasons.append(f'NON_JSON 风暴({nz_rate}%)')
  220. if http_502 > HTTP502_THRESHOLD:
  221. reasons.append(f'HTTP502 网关抖动({http_502})')
  222. if total < MIN_TOTAL:
  223. reasons.append(f'用例数异常({total}<{MIN_TOTAL},疑似部分轮/中断)')
  224. INVALID = len(reasons) > 0
  225. if INVALID:
  226. banner = ("\n> ⚠️ **本轮判定 INVALID:结果不提交、不推送、不更新回归基线。**\n"
  227. f"> 原因:{';'.join(reasons)}\n"
  228. "> 处置:保留上次有效基线不变,待上游稳定后重跑即可;勿将此轮数值当作真实回归。\n")
  229. lines.append(banner)
  230. report = "\n".join(lines) + "\n"
  231. # 报告恒写入本轮运行目录(供排查);仅当有效时同步到仓库根(避免污染已提交产物)
  232. _run_report = os.path.join(os.path.dirname(cur_tsv), 'INTERFACE_TEST_REPORT.md')
  233. with open(_run_report, 'w', encoding='utf-8') as f:
  234. f.write(report)
  235. if not INVALID:
  236. with open(rpt, 'w', encoding='utf-8') as f:
  237. f.write(report)
  238. # 趋势日志(本地,不随提交推送;INVALID 也记录以留痕)
  239. with open(trend, 'a', encoding='utf-8') as f:
  240. f.write(f"{ts}\ttotal={total}\tBIZ_OK={ok}\trate={rate}%\t"
  241. f"fixed={len(fixed)}\tregressed={len(regressed)}\t"
  242. f"NOT_FOUND={cats.get('NOT_FOUND',0)}\tSERVER_ERR={cats.get('SERVER_ERR',0)}\t"
  243. f"NON_JSON={non_json}\tHTTP502={http_502}\tINVALID={1 if INVALID else 0}"
  244. + (f"({ '|'.join(reasons) })" if INVALID else "") + "\n")
  245. # 导出真实指标供 bash 判定(修复 rate/fixed/regressed 在子进程不可见导致
  246. # commit 失败 + 假通过率 ?%;bash 侧 set -u 下 unbound variable 亦会导致整条命令展开失败)
  247. _metrics_path = os.path.join(os.path.dirname(trend), 'interface_test_metrics.env')
  248. with open(_metrics_path, 'w', encoding='utf-8') as _mf:
  249. _mf.write(f"RATE={rate}\nFIXED_COUNT={len(fixed)}\nREGRESSED_COUNT={len(regressed)}\n"
  250. f"FLAKY_FIX_COUNT={len(flaky_fix)}\nFLAKY_REG_COUNT={len(flaky_reg)}\n"
  251. f"TOTAL={total}\nOK_COUNT={ok}\nNON_JSON_COUNT={non_json}\n"
  252. f"SERVER_ERR_COUNT={server_err}\nCONN_ERR_COUNT={conn_err}\n"
  253. f"HTTP_502_COUNT={http_502}\nINVALID={1 if INVALID else 0}\n"
  254. # 必须加引号:reason 含 "(90%)" 括号与 "|",不加引号被 . 源引时会报
  255. # "syntax error near unexpected token `('" 并使 INVALID_REASON 为空
  256. f"INVALID_REASON=\"{'|'.join(reasons)}\"\n")
  257. print(report)
  258. print(f"\n[趋势] 已写入 {trend}")
  259. if INVALID:
  260. print(f"[守卫] INVALID 触发:{';'.join(reasons)} --> 跳过提交/推送/基线更新")
  261. PY
  262. # ---------------------------------------------------------------------------
  263. # 4.5) 测试用例完成情况检查(总数 / 已完成 / 未完成原因)
  264. # 统计自动化用例(test()) 与文档化用例(TC-xxxx),并标注未开始执行的原因,
  265. # 追加到报告与趋势日志,供人工与闭环跟踪。
  266. # ---------------------------------------------------------------------------
  267. python - "$CUR_TSV" "$RUN_DIR" "$REPO_DIR" "$TREND" <<'PY'
  268. import sys, os, re, glob, json
  269. cur_tsv, run_dir, repo_dir, trend = sys.argv[1:5]
  270. # —— 读取 step4 导出的指标(接口冒烟)——
  271. metrics = {}
  272. _mp = os.path.join(repo_dir, 'interface_test_metrics.env')
  273. try:
  274. for line in open(_mp, encoding='utf-8', errors='ignore'):
  275. line = line.strip()
  276. if '=' in line and not line.startswith('#'):
  277. k, v = line.split('=', 1)
  278. metrics[k.strip()] = v.strip()
  279. except FileNotFoundError:
  280. pass
  281. INVALID = metrics.get('INVALID', '0') == '1'
  282. OK_COUNT = int(metrics.get('OK_COUNT', '0') or 0)
  283. tests_dir = os.path.join(repo_dir, 'tests')
  284. # 1) 文档化用例 TC-xxxx
  285. doc_tcs = set()
  286. for f in glob.glob(os.path.join(tests_dir, 'test-cases', '*.md')):
  287. try:
  288. t = open(f, encoding='utf-8', errors='ignore').read()
  289. except Exception:
  290. continue
  291. for m in re.findall(r'TC-[A-Z]?-?\d+', t):
  292. doc_tcs.add(m)
  293. TOTAL_DOC = len(doc_tcs)
  294. # 2) 自动化用例 test()(按层级分类)
  295. specs = glob.glob(os.path.join(tests_dir, 'e2e', '**', '*.spec.js'), recursive=True)
  296. e2e_total = dom = roles = api = skip = 0
  297. for f in specs:
  298. try:
  299. src = open(f, encoding='utf-8', errors='ignore').read()
  300. except Exception:
  301. continue
  302. n = len(re.findall(r'^\s*test\s*\(', src, re.M))
  303. e2e_total += n
  304. s = (len(re.findall(r'test\s*\.\s*(?:skip|fixme)\s*\(', src))
  305. + len(re.findall(r'test\s*\.\s*describe\s*\.\s*skip\s*\(', src)))
  306. skip += s
  307. p = f.replace('\\', '/')
  308. if '/e2e/api/' in p:
  309. api += n
  310. elif '/e2e/roles/' in p:
  311. roles += n
  312. else:
  313. dom += n
  314. completed_runnable = e2e_total - skip
  315. # 3) API e2e 末次实跑(.last-run.json)
  316. # 注意:.last-run.json 只含 {"status","failedTests"},不含 passed 数,
  317. # 旧实现用 j.get('passed', ...) 永远取不到,导致报告长期显示「末次 0 通过」。
  318. # 改为优先解析 playwright_smoke.log 的汇总行("<n> passed"),并以 .last-run.json 兜底。
  319. api_pass = api_fixme = 0
  320. api_pass_src = '未取到结果'
  321. _pw_log = os.path.join(repo_dir, 'playwright_smoke.log')
  322. if os.path.exists(_pw_log):
  323. try:
  324. _txt = open(_pw_log, encoding='utf-8', errors='ignore').read()
  325. _m = re.findall(r'(\d+)\s+passed', _txt)
  326. _s = re.findall(r'(\d+)\s+skipped', _txt)
  327. if _m:
  328. api_pass = int(_m[-1])
  329. api_fixme = int(_s[-1]) if _s else 0
  330. api_pass_src = 'playwright_smoke.log'
  331. except Exception:
  332. pass
  333. if api_pass_src != 'playwright_smoke.log':
  334. lr = os.path.join(tests_dir, 'test-results', '.last-run.json')
  335. if os.path.exists(lr):
  336. try:
  337. j = json.load(open(lr, encoding='utf-8'))
  338. if isinstance(j, dict):
  339. api_pass = int(j.get('passed', j.get('stats', {}).get('expected', 0) or 0))
  340. api_fixme = int(j.get('fixme', j.get('stats', {}).get('skipped', 0) or 0))
  341. api_pass_src = '.last-run.json'
  342. except Exception:
  343. pass
  344. # 3b) NOT_FOUND 唯一端点数(按 method+path 去重)
  345. # CUR_TSV 中同一端点会因多调用点重复出现(历史实测 6 行 -> 实际 2 个唯一端点),
  346. # 旧实现按行数统计,导致报告写「6 个 NOT_FOUND 端点」而摘要写 2,前后矛盾。
  347. nf = 0
  348. nf_unique = set()
  349. try:
  350. for line in open(cur_tsv, encoding='utf-8', errors='ignore'):
  351. c = line.rstrip('\n').split('\t')
  352. if len(c) >= 7 and c[6].strip() == 'NOT_FOUND':
  353. nf += 1
  354. nf_unique.add((c[2].strip(), c[3].strip()))
  355. nf = len(nf_unique)
  356. except Exception:
  357. pass
  358. def pct(a, b):
  359. return f"{a * 100 // b}%" if b else "0%"
  360. reasons = []
  361. if dom > 0:
  362. reasons.append((dom, f"DOM 型 E2E 无法执行(CFC-ENV-001):{dom} 个用例已编写,但远程 cfc.iwintrue.com 为纯 Spring Boot API 后端、未部署 uni-app H5 / cfc-web 前端,浏览器型用例无法运行,未开始执行。"))
  363. if skip > 0:
  364. reasons.append((skip, f"已知缺陷/待上线导致跳过:{skip} 个用例被 test.skip/test.fixme 标记(能量发放 500-CFC-EN-001、昵称未持久化-CFC-FM-001 等),待后端修复后启用。"))
  365. if nf > 0:
  366. reasons.append((nf, f"接口冒烟发现 {nf} 个唯一端点返回 404(NOT_FOUND):前后端契约路径不一致(如前端 child/* 与后端 member/*),需开发对齐路径或补别名后重部署,相关 E2E 暂挂起。"))
  367. if TOTAL_DOC > 0:
  368. reasons.append((TOTAL_DOC, f"文档化用例未全量自动化:{TOTAL_DOC} 条设计用例(TC-xxxx),核心主链路已落地 e2e,边缘/异常流仍以文档形式待补自动化。"))
  369. section = []
  370. section.append("\n## 测试用例完成情况")
  371. section.append(f"- 自动化用例总数(可执行 `test()`):**{e2e_total}**(api 层 {api} / 角色分包 {roles} / DOM 层 {dom})")
  372. section.append(f"- 文档化用例(设计层 TC-xxxx):**{TOTAL_DOC}**")
  373. section.append(f"- 已完成(自动化且未被跳过):**{completed_runnable}/{e2e_total} = {pct(completed_runnable, e2e_total)}**")
  374. section.append(f"- 已实跑通过:接口冒烟 BIZ_OK **{OK_COUNT}** 端点 + API e2e 末次 **{api_pass}** 通过 / {api_fixme} skipped(来源 {api_pass_src})")
  375. section.append("")
  376. section.append(f"### 未完成 / 未开始的原因(共 {len(reasons)} 类)")
  377. if reasons:
  378. for i, (c, t) in enumerate(reasons, 1):
  379. section.append(f"{i}. [{c} 项] {t}")
  380. else:
  381. section.append("- 无(全部用例均已落地并可执行)")
  382. section.append("")
  383. section.append("> 说明:自动化完成率按 `test()` 未被 skip/fixme 计;DOM 层受 CFC-ENV-001 限制需前端部署后执行,属环境阻塞而非用例缺失。")
  384. report_txt = "\n".join(section)
  385. # 追加到本轮运行目录报告(始终)
  386. _run_rpt = os.path.join(run_dir, 'INTERFACE_TEST_REPORT.md')
  387. try:
  388. _b = open(_run_rpt, encoding='utf-8', errors='ignore').read()
  389. open(_run_rpt, 'w', encoding='utf-8').write(_b.rstrip() + "\n\n" + report_txt)
  390. except FileNotFoundError:
  391. open(_run_rpt, 'w', encoding='utf-8').write(report_txt)
  392. # 仅有效轮才写回仓库根报告
  393. if not INVALID:
  394. _repo_rpt = os.path.join(repo_dir, 'INTERFACE_TEST_REPORT.md')
  395. try:
  396. _b = open(_repo_rpt, encoding='utf-8', errors='ignore').read()
  397. open(_repo_rpt, 'w', encoding='utf-8').write(_b.rstrip() + "\n\n" + report_txt)
  398. except FileNotFoundError:
  399. pass
  400. # JSON 供工具读取
  401. js = dict(e2e_total=e2e_total, api=api, roles=roles, dom=dom, skip=skip,
  402. completed_runnable=completed_runnable, doc_total=TOTAL_DOC,
  403. smoke_ok=OK_COUNT, api_pass=api_pass, api_fixme=api_fixme,
  404. reasons=[{"count": c, "reason": t} for c, t in reasons], invalid=INVALID)
  405. json.dump(js, open(os.path.join(run_dir, 'test_case_completion.json'), 'w', encoding='utf-8'),
  406. ensure_ascii=False, indent=2)
  407. if not INVALID:
  408. json.dump(js, open(os.path.join(repo_dir, 'test_case_completion.json'), 'w', encoding='utf-8'),
  409. ensure_ascii=False, indent=2)
  410. # 趋势日志
  411. with open(trend, 'a', encoding='utf-8') as f:
  412. f.write(f"\tTC_E2E={e2e_total}\tTC_DONE={completed_runnable}\tTC_DOC={TOTAL_DOC}\tTC_SKIP={skip}\tTC_DOM={dom}\n")
  413. # 导出指标给 bash(追加,供 commit 信息使用)
  414. with open(os.path.join(repo_dir, 'interface_test_metrics.env'), 'a', encoding='utf-8') as f:
  415. f.write(f"TC_E2E_TOTAL={e2e_total}\nTC_DONE={completed_runnable}\nTC_DOC={TOTAL_DOC}\nTC_SKIP={skip}\n")
  416. print(f"[测试用例] 总数(自动化)={e2e_total} 已完成(可运行)={completed_runnable} 文档化={TOTAL_DOC} 跳过={skip} | 原因 {len(reasons)} 类")
  417. PY
  418. # ---------------------------------------------------------------------------
  419. # 4.6) Playwright E2E 冒烟(独立于接口冒烟;失败不阻断主闭环)
  420. # 仅跑 api 层(playwright.config.api.js -> tests/e2e/api/*.spec.js),
  421. # 含 nonfunc-security / nonfunc-perf-compat 安全与非功能断言。
  422. # 结果独立成报告(固定名覆盖式),不并入 step4 接口冒烟报告,亦不阻断 commit/push。
  423. # ---------------------------------------------------------------------------
  424. PW_BIN="$REPO_DIR/tests/node_modules/.bin/playwright"
  425. PW_CFG="$REPO_DIR/tests/playwright.config.api.js"
  426. PW_RPT="$REPO_DIR/playwright_smoke_report.md"
  427. PW_LOG="$REPO_DIR/playwright_smoke.log"
  428. if [ -x "$PW_BIN" ]; then
  429. PW_RUN=("$PW_BIN")
  430. elif [ -f "$REPO_DIR/tests/node_modules/playwright/cli.js" ]; then
  431. PW_RUN=(node "$REPO_DIR/tests/node_modules/playwright/cli.js")
  432. else
  433. PW_RUN=()
  434. fi
  435. if [ "${#PW_RUN[@]}" -gt 0 ] && [ -f "$PW_CFG" ]; then
  436. log "开始执行 Playwright E2E 冒烟(api 层,独立、不阻断主闭环)"
  437. ( cd "$REPO_DIR/tests" && "${PW_RUN[@]}" test -c "$PW_CFG" --project=desktop --reporter=line \
  438. > "$PW_LOG" 2>&1 ) || true
  439. PW_PASS=$(grep -oE '[0-9]+ passed' "$PW_LOG" | grep -oE '[0-9]+' | head -1)
  440. PW_FAIL=$(grep -oE '[0-9]+ failed' "$PW_LOG" | grep -oE '[0-9]+' | head -1)
  441. PW_SKIP=$(grep -oE '[0-9]+ skipped' "$PW_LOG" | grep -oE '[0-9]+' | head -1)
  442. # 崩溃保护:若日志里没有任何用例汇总行(如被沙箱 safe-delete/ETIMEDOUT 打断),
  443. # 旧实现会因 grep 取空而回落成「0 passed / 0 failed」,看上去像「全绿」,实为「根本没跑」。
  444. # 此时显式标记为「未执行」,避免误报。
  445. PW_CRASHED=0
  446. if ! grep -qE '[0-9]+ (passed|failed|skipped)' "$PW_LOG"; then
  447. PW_CRASHED=1
  448. fi
  449. {
  450. echo ""
  451. echo "## Playwright E2E 冒烟(api 层,独立,不阻断主闭环)"
  452. echo "- 执行器: ${PW_RUN[*]} test -c playwright.config.api.js --project=desktop"
  453. if [ "$PW_CRASHED" = "1" ]; then
  454. echo "- 结果: **未执行(执行器异常中断,日志无用例汇总行)**"
  455. echo "- ⚠️ 本轮 E2E 未产出结果,**不可**解读为 0 failed / 全绿;需人工重跑确认。"
  456. echo "- 失败特征: $(grep -oE 'Error: [^\\n]*' "$PW_LOG" | head -1)"
  457. else
  458. echo "- 结果: **${PW_PASS:-0} passed / ${PW_FAIL:-0} failed / ${PW_SKIP:-0} skipped**"
  459. fi
  460. echo "- 覆盖: tests/e2e/api/*.spec.js(含 nonfunc-security 安全契约、nonfunc-perf-compat 性能兼容)"
  461. echo "- 说明: 红用例=已知后端缺口(权限提升/开放端点/上传未校验/缺鉴权过滤器),作持续告警;失败不阻断本闭环。"
  462. echo "- 明细: $PW_LOG"
  463. } > "$PW_RPT"
  464. if [ "$PW_CRASHED" = "1" ]; then
  465. log "⚠️ Playwright E2E 冒烟未产出结果(执行器异常中断,非 0 failed)—— 报告已标注「未执行」,不可解读为全绿"
  466. else
  467. log "Playwright E2E 冒烟完成: ${PW_PASS:-0} passed / ${PW_FAIL:-0} failed / ${PW_SKIP:-0} skipped(报告 $PW_RPT)"
  468. fi
  469. else
  470. log "⚠️ 未找到 Playwright 执行器或配置,跳过 E2E 冒烟"
  471. fi
  472. # ---------------------------------------------------------------------------
  473. # 5) 守卫判定:无效轮(宕机/NON_JSON 风暴/502 抖动/登录失败/部分轮)
  474. # 不提交、不推送、不更新回归基线,避免污染趋势与开发侧闭环
  475. # ---------------------------------------------------------------------------
  476. INVALID=0; INVALID_REASON=""
  477. RATE="?"; FIXED_COUNT=0; REGRESSED_COUNT=0; FLAKY_FIX_COUNT=0; FLAKY_REG_COUNT=0
  478. METRICS_ENV="$REPO_DIR/interface_test_metrics.env"
  479. [ -f "$METRICS_ENV" ] && . "$METRICS_ENV"
  480. # CONN_ERR 参与的翻转标 flaky:该类别是连接中断/超时,长耗时 AI 接口会骑在客户端
  481. # 超时阈值上反复翻转(后端实际一直 200),写进 commit 标题需显式区分,避免 fix/reg
  482. # 被当成真实业绩或真实劣化。
  483. FLAKY_TAG=""
  484. if [ "${FLAKY_FIX_COUNT:-0}" -gt 0 ] || [ "${FLAKY_REG_COUNT:-0}" -gt 0 ]; then
  485. FLAKY_TAG=" [CONN_ERR翻转标flaky: fix=${FLAKY_FIX_COUNT:-0} reg=${FLAKY_REG_COUNT:-0}]"
  486. fi
  487. if [ "${INVALID:-0}" -eq 1 ]; then
  488. log "⚠️ 守卫触发 INVALID:本轮结果不提交 / 不推送 / 不更新回归基线"
  489. log " 原因: ${INVALID_REASON}"
  490. log " 处置: 保留 PREV_TSV 基线不变,待上游稳定后重跑即可"
  491. log " 排查: 本轮明细见 $RUN_DIR/results_$TS.tsv 与 $RUN_DIR/smoke_stdout.txt"
  492. log "===== 本轮结束(INVALID,已跳过推送)====="
  493. exit 0
  494. fi
  495. # ---------------------------------------------------------------------------
  496. # 5b) 修正报告 E2E 指标「滞后一轮」(与 13:49 修的 tsv 滞后一轮同类缺陷)
  497. # 报告在 Playwright 之前生成,读到的 playwright_smoke.log 是**上一轮**的,
  498. # 于是本轮的 report 写「末次 52 通过 / 3 skipped」,而同一次提交里的新 log
  499. # 写的是「39 passed / 10 failed」——人类读到前后矛盾的数字。
  500. # 此处用本轮真实结果覆写该句(仅有效轮、仅当本轮 E2E 正常产出时)。
  501. # ---------------------------------------------------------------------------
  502. if [ "${PW_CRASHED:-1}" = "0" ] && [ -n "${PW_PASS:-}" ]; then
  503. python - "$REPO_DIR/INTERFACE_TEST_REPORT.md" "$RUN_DIR/INTERFACE_TEST_REPORT.md" \
  504. "$PW_PASS" "${PW_FAIL:-0}" "${PW_SKIP:-0}" <<'PY'
  505. import re, sys
  506. files = sys.argv[1:3]
  507. passed, failed, skipped = sys.argv[3], sys.argv[4], sys.argv[5]
  508. new = ("API e2e 本轮 **%s** 通过 / %s failed / %s skipped(来源 playwright_smoke.log)"
  509. % (passed, failed, skipped))
  510. pat = re.compile(r"API e2e 末次 \*\*\d+\*\* 通过 / \d+ skipped(来源 [^)]*)")
  511. pat_done = re.compile(r"API e2e 本轮 \*\*\d+\*\* 通过 / \d+ failed / \d+ skipped(来源 [^)]*)")
  512. for f in files:
  513. try:
  514. txt = open(f, encoding="utf-8", errors="ignore").read()
  515. except FileNotFoundError:
  516. continue
  517. hits = list(pat.finditer(txt)) or list(pat_done.finditer(txt))
  518. if not hits:
  519. continue
  520. m = hits[-1] # 只改最后一段 = 本轮追加的那段
  521. open(f, "w", encoding="utf-8").write(txt[:m.start()] + new + txt[m.end():])
  522. PY
  523. log "报告 E2E 指标已对齐本轮实跑:${PW_PASS} passed / ${PW_FAIL:-0} failed / ${PW_SKIP:-0} skipped"
  524. fi
  525. # ---------------------------------------------------------------------------
  526. # 6) 提交测试结果 + 补全用例(仅有效轮)
  527. # ---------------------------------------------------------------------------
  528. # 同步仓库根目录的全量结果 TSV(供人工/工具读取)。
  529. # 必须在 git add 之前执行:旧实现在 commit+push 之后才 cp,导致每轮提交进远端的
  530. # 都是「上一轮」的 tsv(永远滞后一轮),且工作区每轮都残留未提交的 tsv 改动。
  531. cp "$CUR_TSV" "interface_test_report.tsv"
  532. git add -f ops_interface_smoke.py
  533. # 强制纳入报告产物(防止未来 .gitignore 规则或路径规范导致漏提);
  534. # EXTRA_CASES 仅在存在且有内容时才加入,避免缺失路径使整个 git add 失败(旧逻辑缺陷)
  535. git add -f INTERFACE_TEST_REPORT.md interface_test_report.tsv cfc-frontend/INTERFACE_TEST_MATRIX.md interface_test_bodies.tsv
  536. [ -s "$EXTRA_CASES" ] && git add -f "$EXTRA_CASES"
  537. # 纳入 Playwright E2E 冒烟报告(仅当本轮实际执行过,PW_RPT 才存在)
  538. [ -f "$PW_RPT" ] && git add -f "$PW_RPT" "$PW_LOG"
  539. COMMIT_OK=1 # 1=已提交 0=提交失败 2=无变更跳过
  540. HEAD_BEFORE=$(git rev-parse HEAD)
  541. if git diff --cached --quiet; then
  542. log "无测试结果变更,跳过提交"
  543. COMMIT_OK=2
  544. else
  545. if git commit -m "test(interface): 接口冒烟闭环 $(date '+%Y-%m-%d %H:%M') — 通过率 ${RATE}% (fix=${FIXED_COUNT} reg=${REGRESSED_COUNT})${FLAKY_TAG}; 测试用例 ${TC_DONE:-?}/${TC_E2E_TOTAL:-?} 完成(文档化${TC_DOC:-?})" \
  546. >>"$LOG_DIR/interface_cron.log" 2>&1; then
  547. HEAD_AFTER=$(git rev-parse HEAD)
  548. if [ "$HEAD_BEFORE" = "$HEAD_AFTER" ]; then
  549. # git commit 返回 0 但未产生新提交(unmerged/钩子拦截等):必须按失败处理,否则会假成功
  550. log "⚠️ git commit 返回成功但未产生新提交(HEAD 未变化,疑似工作区未解决冲突 unmerged 或 pre-commit 钩子拦截)——测试结果未入库"
  551. COMMIT_OK=0
  552. else
  553. log "已提交测试结果(测试用例完成 ${TC_DONE:-?}/${TC_E2E_TOTAL:-?})"
  554. fi
  555. else
  556. log "⚠️ git commit 失败(详见 $LOG_DIR/interface_cron.log)——测试结果未入库"
  557. COMMIT_OK=0
  558. fi
  559. fi
  560. # ---------------------------------------------------------------------------
  561. # 7) 推送远端,与开发团队形成闭环
  562. # 增强:远端领先(非快进)时自动 rebase 后重试,避免测试结果不落远端。
  563. # 凭证/网络类错误不盲目重试,直接失败并留待下次运行。
  564. # ---------------------------------------------------------------------------
  565. # 提交守卫:仅当测试产物确实已提交(COMMIT_OK=1)才推送远端;否则绝不推送,
  566. # 避免「远端无新提交 → push 返回成功」造成假成功误报。
  567. if [ "${COMMIT_OK:-1}" -ne 1 ]; then
  568. if [ "${COMMIT_OK:-1}" -eq 0 ]; then
  569. log "⛔ 提交未成功(COMMIT_OK=0),跳过 git push —— 不假成功误报;请人工解决工作区冲突后重跑(或手动提交推送)"
  570. else
  571. log "(无提交内容,无需推送)"
  572. fi
  573. push_ok=0
  574. else
  575. PUSH_LOG="$RUN_DIR/push.log"
  576. MAX_PUSH_RETRY=2
  577. push_ok=0
  578. attempt=0
  579. while [ "$attempt" -lt $((MAX_PUSH_RETRY + 1)) ]; do
  580. attempt=$((attempt + 1))
  581. if git push origin "$BRANCH" >"$PUSH_LOG" 2>&1; then
  582. push_ok=1
  583. log "git push 成功 — 测试闭环已同步远端(第 ${attempt} 次尝试)"
  584. break
  585. fi
  586. # 仅在「远端领先/非快进」时自动 rebase 后重试
  587. if grep -qiE "non-fast-forward|fetch first|Updates were rejected|rejected" "$PUSH_LOG"; then
  588. log "git push 被拒(远端领先/非快进)。rebase 后重试(${attempt}/${MAX_PUSH_RETRY})"
  589. # rebase 期间用 --autostash 保护未提交改动(如事后刷新的 interface_test_report.tsv)
  590. if git pull --rebase --autostash origin "$BRANCH" >>"$LOG_DIR/interface_cron.log" 2>&1; then
  591. log "rebase 完成,重新尝试 push"
  592. else
  593. log "rebase 失败(可能存在冲突),中止 push 重试"
  594. break
  595. fi
  596. else
  597. # 凭证/网络等其他错误:不盲目重试,避免空耗与误报
  598. log "git push 失败(疑似凭证/网络,非非快进类): $(grep -iE 'error|fatal|timed out|could not|permission denied' "$PUSH_LOG" | head -3 | tr '\n' ' ')"
  599. break
  600. fi
  601. done
  602. if [ "$push_ok" -ne 1 ]; then
  603. log "git push 最终失败,本轮结果未同步远端(下次运行将重试)"
  604. fi
  605. fi
  606. # 更新回归基线(仅有效轮才覆盖,避免无效轮污染下一轮回归对比)
  607. cp "$CUR_TSV" "$PREV_TSV"
  608. # 仓库根目录 tsv 已在第 6 步提交前同步(见上),此处不再重复 cp,避免提交后又产生未提交改动
  609. if cmp -s "$CUR_TSV" "interface_test_report.tsv"; then
  610. log "全量结果已同步(提交前完成)-> interface_test_report.tsv"
  611. else
  612. log "⚠️ interface_test_report.tsv 与本轮结果不一致(提交后不应发生)"
  613. fi
  614. # 提交守卫收尾:若测试产物提交失败(COMMIT_OK=0),以非零退出码标记本轮未真正闭环,
  615. # 便于自动化调度与 owner 识别「假成功」已被消除(而非静默通过)。
  616. if [ "${COMMIT_OK:-1}" -eq 0 ]; then
  617. log "⛔ 本轮提交失败(COMMIT_OK=0),闭环未完成;退出码 1 标记"
  618. log "===== 本轮结束(提交失败,未闭环)====="
  619. exit 1
  620. fi
  621. log "===== 本轮结束 ====="