From 75b2ec4123323a76a50152b4aa09eaa87c785538 Mon Sep 17 00:00:00 2001 From: sorrow404null Date: Thu, 17 Sep 2026 16:40:06 +0800 Subject: [PATCH] test: add cross-platform test aggregator (run_all) Subprocess-based runner with logic/offscreen groups: per-entry temp dirs, explicit timeouts, dependency/platform SKIP gates, UTF-8 safe output, and an exit code that reflects failures without blocking on a single crash or timeout. --- tests/run_all.py | 265 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 tests/run_all.py diff --git a/tests/run_all.py b/tests/run_all.py new file mode 100644 index 0000000..a878e22 --- /dev/null +++ b/tests/run_all.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +"""P2-04 跨平台聚合测试入口(无 pytest / npm / 网络要求)。 + +用法: + python tests/run_all.py --group logic + python tests/run_all.py --group offscreen + python tests/run_all.py --group all + python tests/run_all.py --group all --list + python tests/run_all.py --group all --keep-logs + python tests/run_all.py --group logic --only test_tool_params + +设计(对应 REPAIR_BACKLOG.md P2-04 硬约束): + * 保留所有独立命令:每个条目就是 [解释器, 测试文件] 的普通子进程调用, + 与各测试文件头部的“运行:”命令等价(同一解释器、同一 cwd、同组环境)。 + * 聚合入口不要求 pytest / npm / 网络;缺 node 或第三方依赖的条目的 + 呈现方式(SKIP + 理由),不伪装通过。 + * 每个子测试使用独立临时目录(Windows: TEMP/TMP;POSIX: TMPDIR), + GUI 测试的临时配置/临时数据库由各测试自身在 import MainWindow 前 + 通过 tests/_test_env.isolate() 完成(仓库既有惯例)。 + * 默认不运行 diag_* / verify_* / tune_* / 真实 API(smoke_live_*、 + diag_live_*)/ 真实桌面 / 需要凭据的脚本,也不运行使用真实 DB 的 + 遗留脚本(smoke_persist、smoke_repro_real)。 + * 平台不适用项以明确 SKIP + 理由呈现;平台共同项失败 → 退出码非零。 + * 单个子测试崩溃或超时不阻止后续测试与最终汇总;退出码反映失败: + 0 = 无 FAIL/TIMEOUT(SKIP 不影响);1 = 存在 FAIL 或 TIMEOUT。 + * 不读取真实配置、不访问网络、不删除用户运行数据。 +""" +import argparse +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile +import time +from datetime import datetime + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PY = sys.executable # 用启动本入口的解释器跑所有 Python 子测试(与独立命令一致) +NODE = shutil.which("node") + +# 控制台编码安全化(与 main.py 同惯例):git-bash/GBK/cp1252 终端下 +# 聚合器自身的中文输出不得崩溃(子测试输出经 UTF-8 解码后同样安全打印)。 +for _name in ("stdout", "stderr"): + _s = getattr(sys, _name, None) + if _s is not None: + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +# --------------------------------------------------------------------------- +# 测试清单 / 分组元数据 +# (id, groups, argv, timeout_sec, needs, note) +# needs 取值: +# "node" —— 需要 node 可执行文件 +# "openai" —— 需要 openai 包(当前解释器) +# "pymupdf" —— 需要 PyMuPDF (fitz) +# "pyqt6" —— 需要 PyQt6(GUI/offscreen 条目) +# --------------------------------------------------------------------------- +JS = lambda f: [NODE, os.path.join(REPO, "tests", f)] if NODE else [None] +PYT = lambda f: [PY, os.path.join(REPO, "tests", f)] + +TESTS = [ + # ---- logic:纯逻辑 / 无 GUI(平台共同项,失败不得豁免)---- + ("run_tests.py (agent core 41)", ("logic",), PYT("run_tests.py"), 300, + ("openai",), "core.agent 单测套件(既有 run_tests.py 入口,原样保留)"), + ("test_tool_params.py", ("logic",), PYT("test_tool_params.py"), 120, + ("openai",), "工具参数解析(import core.agent.tools → openai)"), + ("test_bash_stream.py", ("logic",), PYT("test_bash_stream.py"), 180, + ("openai",), "bash 流式 / 进程树(P1-02、P2-01)"), + ("test_copy_session.py", ("logic",), PYT("test_copy_session.py"), 180, + (), "会话复制 DB 逻辑"), + ("test_compaction_persist.py", ("logic",), PYT("test_compaction_persist.py"), 180, + ("openai",), "压缩持久化"), + ("test_file_attach.py", ("logic",), PYT("test_file_attach.py"), 180, + (), "附件链路(P1-04)"), + ("test_cross_platform_shell.py", ("logic",), PYT("test_cross_platform_shell.py"), 240, + ("openai",), "跨平台 shell 契约 + 进程树终止(P1-02)"), + ("test_global_hotkey_platforms.py", ("logic",), PYT("test_global_hotkey_platforms.py"), 240, + ("pyqt6",), "全局热键平台矩阵 mock(P1-04;含 QShortcut/overlay 构造)"), + ("test_wv2_guard.py", ("logic",), PYT("test_wv2_guard.py"), 240, + ("platform:win32",), "WebView2 守卫(T0 事故锁死;msvcrt 单实例互斥为 Windows 专属,Linux 无 WebView2 链路)"), + ("test_pdf_reader.py", ("logic",), PYT("test_pdf_reader.py"), 240, + ("pymupdf",), "PDF 提取"), + ("test_math_extract.js", ("logic",), JS("test_math_extract.js"), 120, + ("node",), "数学提取 JS 单测(P1-03)"), + ("test_render_window.js", ("logic",), JS("test_render_window.js"), 180, + ("node",), "渲染窗口状态机 JS 单测(P1-01)"), + + # ---- offscreen:Qt GUI(QT_QPA_PLATFORM=offscreen 子环境)---- + ("smoke_offscreen.py", ("offscreen",), PYT("smoke_offscreen.py"), 420, + ("pyqt6",), "主窗口 + 核心链路冒烟"), + ("smoke_mode.py", ("offscreen",), PYT("smoke_mode.py"), 420, + ("pyqt6",), "模式切换(P0-02)"), + ("smoke_copy_session.py", ("offscreen",), PYT("smoke_copy_session.py"), 420, + ("pyqt6",), "会话复制 UI 端到端"), + ("smoke_bash_panel.py", ("offscreen",), PYT("smoke_bash_panel.py"), 480, + ("pyqt6",), "右侧 Bash 面板端到端(P2-01、P2-02)"), + ("smoke_timeline.py", ("offscreen",), PYT("smoke_timeline.py"), 420, + ("pyqt6",), "时间线持久化/还原"), + ("smoke_midswitch.py", ("offscreen",), PYT("smoke_midswitch.py"), 420, + ("pyqt6",), "流式中途切会话(P1-01)"), + ("test_main_window_event_filter.py", ("offscreen",), PYT("test_main_window_event_filter.py"), 300, + ("pyqt6",), "MainWindow.eventFilter(P0-02)"), + ("test_config_isolation.py", ("offscreen",), PYT("test_config_isolation.py"), 300, + ("pyqt6",), "配置/DB 隔离铁律(P0-01)"), + ("test_error_persist.py", ("offscreen",), PYT("test_error_persist.py"), 300, + ("pyqt6",), "错误持久化"), + ("test_think_code_neutral.py", ("offscreen",), PYT("test_think_code_neutral.py"), 420, + ("pyqt6",), "思考/代码块渲染中立性"), + ("test_debug_window.py", ("offscreen",), PYT("test_debug_window.py"), 300, + ("pyqt6",), "调试窗口"), + ("test_renderer_matrix.py", ("offscreen",), PYT("test_renderer_matrix.py"), 600, + ("pyqt6",), "渲染器矩阵(P1-03,含子进程并行)"), + ("test_screen_capture_platforms.py", ("offscreen",), PYT("test_screen_capture_platforms.py"), 300, + ("pyqt6",), "截图平台矩阵 mock + overlay 守卫(P1-04)"), +] + +# 默认不聚合(人工/半自动/真实资源):diag_*、verify_*、tune_*、 +# smoke_live_guard.py、diag_live_*.py、smoke_manual.py、smoke_repro_real.py、 +# smoke_persist.py(真实 DB)、smoke_probe.py、_probe_*.py、 +# debug_inject.py、inject_math_demo.py、check_db_migration.py + + +def _has_module(name): + try: + return importlib.util.find_spec(name) is not None + except Exception: + return False + + +def skip_reason(entry): + """返回 SKIP 理由;None 表示可运行。""" + _id, _groups, _argv, _to, needs, _note = entry + for need in needs: + if need.startswith("platform:"): + want = need.split(":", 1)[1] + if sys.platform != want: + return f"平台不适用:该条目仅 {want} 路径适用(当前 {sys.platform})" + continue + if need == "node" and not NODE: + return "node 不可用(聚合入口不要求 npm/node;安装 node 后自动纳入)" + if need == "openai" and not _has_module("openai"): + return "当前解释器无 openai 包(本环境不允许联网安装)" + if need == "pymupdf" and not _has_module("fitz"): + return "当前解释器无 PyMuPDF(本环境不允许联网安装)" + if need == "pyqt6" and not _has_module("PyQt6"): + return "当前解释器无 PyQt6(GUI 条目需在装有依赖的解释器运行)" + return None + + +def _child_env(extra_temp, offscreen): + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONUNBUFFERED"] = "1" + if extra_temp: + if os.name == "nt": + env["TEMP"] = extra_temp + env["TMP"] = extra_temp + else: + env["TMPDIR"] = extra_temp + if offscreen: + env.setdefault("QT_QPA_PLATFORM", "offscreen") + env.setdefault("HAOCODE_RENDER", "software") + env.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") + return env + + +def run_group(group, keep_logs, only): + entries = [e for e in TESTS if group in ("all",) or e[1][0] == group] + if only: + subs = [s.strip() for s in only.split(",") if s.strip()] + entries = [e for e in entries if any(s in e[0] for s in subs)] + if not entries: + print("没有匹配的测试条目(--group/--only 组合为空)") + return 1 + + run_dir = tempfile.mkdtemp(prefix="haocode_runall_") + print("=" * 72) + print("haocode 聚合测试入口 (P2-04)") + print(f"platform={sys.platform} python={sys.version.split()[0]} ({PY})") + print(f"group={group} entries={len(entries)} 开始={datetime.now():%H:%M:%S}") + print("=" * 72) + + results = [] # (id, status, seconds, note, argv) + t_all = time.time() + for entry in entries: + tid, _groups, argv, budget, needs, note = entry + off = "offscreen" in entry[1] + reason = skip_reason(entry) + if reason: + results.append((tid, "SKIP", 0.0, reason, argv)) + print(f" SKIP {tid} [{reason}]") + continue + t0 = time.time() + log_path = os.path.join(run_dir, tid.replace(" ", "_").replace("(", "").replace(")", "") + ".log") + try: + with tempfile.TemporaryDirectory(prefix="runall_t_") as td: + proc = subprocess.run( + argv, cwd=REPO, env=_child_env(td, off), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=budget) + rc, err = proc.returncode, None + out = proc.stdout + except subprocess.TimeoutExpired as e: + rc, out, err = 124, (e.stdout or b""), f"超时(预算 {budget}s)" + except Exception as e: # 聚合器自身健壮性:单条崩溃不阻止汇总 + rc, out, err = 125, b"", f"聚合器异常: {e!r}" + dt = max(0.0, time.time() - t0) # WSL/9P 时钟回拨保护 + status = "PASS" if rc == 0 else ("TIMEOUT" if rc == 124 else "FAIL") + results.append((tid, status, dt, note, argv)) + tag = f" {status:<7} {dt:7.1f}s {tid}" + print(tag, flush=True) + # 非 PASS:打印最后 15 行帮助定位 + tail = (out or b"").decode("utf-8", "replace").splitlines()[-15:] + if status != "PASS" and tail: + for line in tail: + print(f" | {line}") + with open(log_path, "wb") as f: + f.write(b"# argv: " + " ".join(str(a) for a in argv).encode() + b"\n") + if err: + f.write(f"# {err}\n".encode()) + f.write(out or b"") + + dt_all = time.time() - t_all + n_pass = sum(1 for r in results if r[1] == "PASS") + n_fail = sum(1 for r in results if r[1] in ("FAIL", "TIMEOUT")) + n_skip = sum(1 for r in results if r[1] == "SKIP") + print("-" * 72) + print(f"总计 {len(results)}: PASS {n_pass} FAIL {n_fail} SKIP {n_skip}" + f" 总耗时 {dt_all:.1f}s") + if keep_logs or n_fail: + print(f"日志目录(保留): {run_dir}") + else: + shutil.rmtree(run_dir, ignore_errors=True) + print("日志目录(无失败,已清理)") + bad = [r for r in results if r[1] in ("FAIL", "TIMEOUT")] + if bad: + print("失败命令:") + for tid, status, dt, _note, argv in bad: + print(f" [{status}] {' '.join(str(a) for a in argv)}") + print("=" * 72) + return 1 if n_fail else 0 + + +def main(): + ap = argparse.ArgumentParser(description="haocode 跨平台聚合测试入口(P2-04)") + ap.add_argument("--group", choices=("logic", "offscreen", "all"), default="all", + help="logic=纯逻辑; offscreen=Qt GUI offscreen; all=两者(默认)") + ap.add_argument("--list", action="store_true", help="只列出条目,不执行") + ap.add_argument("--keep-logs", action="store_true", help="保留全部子测试日志") + ap.add_argument("--only", default="", help="只运行 id 含子串的条目(逗号分隔)") + args = ap.parse_args() + + if args.list: + for tid, groups, argv, budget, needs, note in TESTS: + mark = "SKIP?" if skip_reason((tid, groups, argv, budget, needs, note)) else "run " + print(f" [{mark}] {groups[0]:<10} {budget:>4}s {tid} {note}") + return 0 + sys.exit(run_group(args.group, args.keep_logs, args.only)) + + +if __name__ == "__main__": + main()