#!/usr/bin/env python3 r"""run_smoke.py — 冒烟运行器(脚本错误外部门禁) 用法: python tools/run_smoke.py # 默认跑切片全量冒烟(本机 Godot 固定路径) python tools/run_smoke.py --script res://smoke/smoke_all.gd python tools/run_smoke.py --godot <引擎路径> --path <切片目录> --timeout 180 背景(2026-09-19 台账 83 实证): GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、 不计入冒烟失败数,且会静默中断所在冒烟函数的剩余断言——首跑「309 项 0 失败」实为断言未跑全。 进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, 另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 三闸: ① 进程退出码 = 0;② 摘要行「冒烟完成:N 项检查,0 项失败」在案且失败数为 0; ③ 输出无「SCRIPT ERROR」「Parse Error」标记行。 """ import argparse import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DEFAULT_GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" DEFAULT_SLICE = ROOT / "game" DEFAULT_SCRIPT = "res://smoke/smoke_all.gd" SUMMARY_RE = re.compile(r"冒烟完成:(\d+) 项检查,(\d+) 项失败") ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error") def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") ap = argparse.ArgumentParser(description="冒烟运行器(脚本错误外部门禁)") ap.add_argument("--godot", default=DEFAULT_GODOT) ap.add_argument("--path", default=str(DEFAULT_SLICE), help="切片项目目录(含 project.godot)") ap.add_argument("--script", default=DEFAULT_SCRIPT) ap.add_argument("--timeout", type=int, default=180, help="外部超时秒数(ADR-0004 硬性要求)") args = ap.parse_args() cmd = [args.godot, "--headless", "--path", args.path, "--script", args.script] try: proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=args.timeout) except subprocess.TimeoutExpired: print(f"[run_smoke] FAIL:外部超时(>{args.timeout}s——ADR-0004 硬性要求)") return 1 out = (proc.stdout or "") + (proc.stderr or "") sys.stdout.write(out) if out and not out.endswith("\n"): print() gates = [] if proc.returncode != 0: gates.append(f"退出码 = {proc.returncode}(≠0)") m = SUMMARY_RE.search(out) if m is None: gates.append("摘要行缺失(未检出「冒烟完成:N 项检查,M 项失败」——入口跑完前中断?)") elif int(m.group(2)) != 0: gates.append(f"冒烟失败 {m.group(2)} 项 / 共 {m.group(1)} 项") hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)] if hits: gates.append(f"脚本错误标记命中 {len(hits)} 行(首行: {hits[0][:140]}——脚本错误不计冒烟失败数," "且中断所在函数的剩余断言,必须人工排查)") if m is not None: print(f"[run_smoke] 摘要: {m.group(0)}") if gates: print("[run_smoke] FAIL——") for g in gates: print(f" - {g}") return 1 print("[run_smoke] PASS(退出码 / 摘要失败数 / 脚本错误扫描 三闸全过)") return 0 if __name__ == "__main__": sys.exit(main())