diff --git a/game/tests/README.md b/game/tests/README.md index 0b535715..e1a5ad5b 100644 --- a/game/tests/README.md +++ b/game/tests/README.md @@ -70,7 +70,7 @@ tests/ |---|---|---|---| | `tools/run_smoke.py`(冒烟测试) | 这版能否启动、能否加载表、能否进主屏——零逻辑判定 | 退出码+脚本错误扫描 | 开机路径/表加载改动后的快速自检;收口由闸编排 | | `tools/run_system_test.py`(系统测试) | 真实玩法路径的行为断言面(推理→战斗→结算→进度推进) | 退出码+脚本错误扫描 | 玩法链路改动的调试期确认 | -| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) | +| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下);每套件子进程带截止时间守护——超时先 taskkill /T 整树回收再判该套件 FAIL、批继续(默认 120s,环境变量 `RUN_ALL_SUITE_TIMEOUT_SEC` 可覆盖) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) | | `tools/probe_gate.sh`(场景探针,`--click`/`--full` 两模式) | UI 可达性、可见性、状态——真实实例化场景+真实输入路径 | 探针自检+退出码 | 新增 UI 区域的配套探针直跑与断言复核 | | `tools/render_gate.sh`(渲染 golden) | 画面基线——截图与基线图逐像素容差比对 | golden 比对脚本(依赖 PIL) | 主题/素材/排版类改动;窗口化运行,需开显示器 | | `game/tests/static_scan.sh`(S 域) | 项目纪律+第三方质量规则:RNG 单点/core 纯度/类型标注/文件头/注释禁词/旧符号残留;S13=gdstyle 质量规则(重复 load、循环内 await、循环内分配、不可达代码等真缺陷类;基线键=文件|规则|函数名(类/文件级=文件|规则,无数字条目带消息),数字漂移不假红,同键指标恶化超基线登记值即红) | 退出码(逐规则 FAIL 行) | 改产品码前后的快速自检;新增纪律先补扫描规则再修码 | @@ -96,8 +96,9 @@ python tools/run_system_test.py # 场景探针(UI 探针层)——以探针场景为运行目标,走完整项目引导(autoload 在位) godot --headless --path . res://probe/.tscn -# 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置) -timeout 120s godot --headless --script tests/run_all.gd +# 挂死防护内建于 run_all.gd(引擎无死锁探测,超时靠外部判):每套件子进程带截止时间—— +# 默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖;超时先 taskkill /T 整树回收 +#(cmd wrapper 连引擎孙进程一起杀,不留孤儿),该套件判 FAIL、批继续——单套件挂死不拖死整闸 ``` > 套件 manifest 现况:`_suites()` 登记 **64 条**(61 单元+3 集成,2026-10-28 实数;口径=manifest 条目数, diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 7259ef5b..52c01ca9 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -1,12 +1,16 @@ # tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b) -# 用法:godot --headless --path game --script res://tests/run_all.gd (本地闸 02 闸包装外部超时:timeout 120s …) +# 用法:godot --headless --path game --script res://tests/run_all.gd # 选跑态(开发期直跑专用):追加 `-- --only=<词[,词…]>` ——对套件 res:// 路径做子串匹配,只跑命中套件; # 零命中/空值=用法错 quit(2)。选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据; -# 批末全量闸不带参数不经选跑分支——默认(无参数)行为逐字节不变。 +# 批末全量闸不带参数不经选跑分支。 # 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。 # 执行形态:逐套件起独立 Godot 子进程实跑 #(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符, # 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。 +# 挂死守护:每套件子进程带截止时间(默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖)。 +# 套件挂死(_init 在 quit() 前中断,子进程永不退出)=超时先 taskkill /T 整树回收 +#(连 cmd wrapper 带引擎孙进程一起杀,只杀 wrapper 会留孤儿殃及后续全部 Godot 启动) +# 再判该套件 FAIL 继续下一套件——单套件挂死不拖死整批;正常退出路径判定形态不变。 # 场景探针(.tscn 载体)由本地闸(probe_gate.sh)单独步骤调用,不经本入口。 extends SceneTree @@ -14,6 +18,11 @@ extends SceneTree # 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。 const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"] +# 每套件截止时间(秒):默认与 probe_gate 每段同额;环境变量可覆盖——服务挂死守护的 +# 机制验证与日后调参,非法值/非正值一律回落默认。 +const SUITE_TIMEOUT_ENV := "RUN_ALL_SUITE_TIMEOUT_SEC" +const SUITE_TIMEOUT_DEFAULT_SEC := 120 + func _init() -> void: # 选跑态解析:--only=<词[,词…]>(经 -- 分隔的 user args)——对套件 res:// 路径做子串匹配。 @@ -154,21 +163,69 @@ func _run_suite(suite: Dictionary, idx: int, filtered: bool = false) -> int: + "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地") return 1 # 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空 - #(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。 + #(实测 0 行),故整条命令交给 cmd 并重定向到日志。退出码同样走文件通道—— + # 阻塞式 execute 无超时能力(套件挂死时整批陪葬),换 create_process 非阻塞起进程 + # 后又没有退出码读取 API,故由 cmd /V:ON 在套件退出后把 !ERRORLEVEL! 回显落盘, + # 值与阻塞式 execute 经 cmd 回传的同一。 var proj := ProjectSettings.globalize_path("res://") var log := proj + "/tests/.gates/run_all%s_%02d.log" % ["_filter" if filtered else "", idx] + var codefile := log + ".code" + if FileAccess.file_exists(codefile): + DirAccess.remove_absolute(codefile) # 陈值防护:上轮临界残件不许冒充本轮退出码 DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates") - var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log] + # /V:ON=延迟展开:!ERRORLEVEL! 到 echo 执行时(套件已退出)才取值; + # 延迟展开会吞字面 !,命令串各段(引擎路径/工程路径/套件路径/日志路径)不得含 !。 + var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1 & echo !ERRORLEVEL! > "%s"' % [bin, proj, path, log, codefile] var out := PackedStringArray() - var code: int = OS.execute("cmd.exe", PackedStringArray(["/c", cmd]), out, true, false) + var pid: int = OS.create_process("cmd.exe", PackedStringArray(["/V:ON", "/C", cmd])) + if pid < 0: + push_error("[run_all] FAIL %s — cmd 子进程启动失败(pid=%d)" % [path, pid]) + return 1 + # 轮询先查存活再查截止:进程在轮询间隙自然退出时按正常路径判,截止临界不误杀。 + var timed_out := false + var deadline_ms := Time.get_ticks_msec() + _suite_timeout_sec() * 1000 + while true: + if not OS.is_process_running(pid): + break + if Time.get_ticks_msec() >= deadline_ms: + timed_out = true + break + OS.delay_msec(200) var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else "" var negative := bool(suite.get("negative", false)) + if timed_out: + var kill_out := PackedStringArray() + OS.execute("taskkill", PackedStringArray(["/F", "/T", "/PID", str(pid)]), kill_out, true) + # taskkill 落刀到进程对象消失有间隔:等死确认再放行下一套件(10s 宽限), + # 确认不了也如实报红留 PID 供手工清杀,不静默放行。 + var reap_deadline := Time.get_ticks_msec() + 10000 + while OS.is_process_running(pid) and Time.get_ticks_msec() < reap_deadline: + OS.delay_msec(200) + var reaped := not OS.is_process_running(pid) + # 临界窗口:截止到落刀之间套件可能恰好跑完 echo——同删退出码文件,.gates 不积残件。 + if FileAccess.file_exists(codefile): + DirAccess.remove_absolute(codefile) + if negative: + print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path) + else: + print(text) + if reaped: + push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树已整树回收(pid=%d)" % [path, _suite_timeout_sec(), pid]) + else: + push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树回收未确认(pid=%d,需手工清杀残留)" % [path, _suite_timeout_sec(), pid]) + return 1 # 负面套件(坏表/违规输入是它的测试目标)全文折叠,只回显结论行—— # 它输出里的 Parse Error 是预期产物,回显出去会让编排层的日志扫描误判成真实脚本错误。 if negative: print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path) else: print(text) + var code_txt := FileAccess.get_file_as_string(codefile).strip_edges() if FileAccess.file_exists(codefile) else "" + if code_txt == "" or not code_txt.is_valid_int(): + push_error("[run_all] FAIL %s — 退出码文件缺失或非数字(%s)——子进程未走到退出码落盘即死,判红" % [path, codefile]) + return 1 + var code: int = int(code_txt) + DirAccess.remove_absolute(codefile) # 用毕即删:退出码经文件通道但不落 residuum(.gates 只留 *.log) if not negative and _has_script_error(text): push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)]) return 1 @@ -205,6 +262,13 @@ func _engine_bin() -> String: return "" +func _suite_timeout_sec() -> int: + var env := OS.get_environment(SUITE_TIMEOUT_ENV) + if env != "" and env.is_valid_int() and int(env) > 0: + return int(env) + return SUITE_TIMEOUT_DEFAULT_SEC + + func _has_script_error(text: String) -> bool: return _script_error_line(text) != ""