#!/usr/bin/env python3 r"""run_system_test.py — 系统测试运行器(脚本错误外部门禁) 用法: python tools/run_system_test.py # 跑系统测试(宿主 res://tests/system_test.gd) python tools/run_system_test.py --script <宿主脚本> python tools/run_system_test.py --godot <引擎路径> --path <切片目录> --timeout 300 系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一)。 宿主:res://tests/system_test.gd(--script 薄壳自持 check 通道;断言面自 smoke_all.gd 迁入——沿革查 git log)。 背景: GDScript 运行时脚本错误(SCRIPT ERROR)不改变进程退出码、不计入失败数, 且会静默中断所在断言函数的剩余断言——摘要零失败也可能断言未跑全。 进程内无脚本错误陷阱 API(GDScript 无 try/catch),故闸设在外部:本包装除退出码与摘要行外, 另扫输出中的 SCRIPT ERROR / Parse Error,命中即判失败。 三闸: ① 进程退出码 = 0;② 摘要行(「冒烟完成:N 项检查,0 项失败」或 「[system_test] 摘要:N 项检查,0 项失败」)在案且失败数为 0; ③ 输出无「SCRIPT ERROR」「Parse Error」标记行(标记清单经 tools/error_scan.py 复用,禁另写一份)。 """ import argparse import os import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # 引擎定位:GODOT_BIN 环境变量优先(远端 CI 与非 Windows 平台的注入口),Windows 本机固定路径保底。 DEFAULT_GODOT = os.environ.get("GODOT_BIN") or r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe" DEFAULT_SLICE = ROOT / "game" DEFAULT_SCRIPT = "res://tests/system_test.gd" # 系统测试宿主 SUMMARY_RE = re.compile(r"(?:冒烟完成|\[system_test\] 摘要):(\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, help="系统测试宿主脚本") ap.add_argument("--timeout", type=int, default=300, help="外部超时秒数(硬性要求)") 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_system_test] FAIL:外部超时(>{args.timeout}s——硬性要求)") 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_system_test] 摘要: {m.group(0)}") if gates: print("[run_system_test] FAIL——") for g in gates: print(f" - {g}") return 1 print("[run_system_test] PASS(退出码 / 摘要失败数 / 脚本错误扫描 三闸全过)") return 0 if __name__ == "__main__": sys.exit(main())