#!/usr/bin/env python3 """三套件统一测试运行器:只加仓库根路径,绝不加 cloud/ 本身。 背景:tests/ 的单测原来各文件用 sys.path 前置 import(先插路径 再 import host/client/run_parallel),产生 ruff E402;cloud 测试 用 try/except ImportError 回落、deploy 测试插 deploy 目录,各自 为政。本运行器把路径收敛到一处,测试文件只写正常 import。 用法(仓库任意目录均可跑,路径由本文件位置推导,不依赖 cwd): python3 run_tests.py # 三套件依次跑,汇总后退出 python3 run_tests.py host|cloud|deploy python3 run_tests.py --test tests.test_host_coverage85 python3 run_tests.py --test cloud.tests.test_server python3 run_tests.py --test test_deploy # deploy 套件只认裸模块名 python3 run_tests.py -v ... # 透传 unittest -v 路径规则(两处陷阱,勿改): 1. 只加仓库根,绝不加 cloud/:cloud/queue.py 会遮蔽标准库 queue, Playwright 同步层会报 module 'queue' has no attribute 'SimpleQueue'(2026-10-30 一期实测)。cloud 测试走 `from cloud.server import ...`,根路径已足够。 2. deploy 套件在根之前再加 deploy/ 目录:deploy.py 是 deploy/ 下的文件而非包,只加根时 `import deploy` 会解析成 deploy/ 命名空间包而非 deploy.py(deploy.py 自身 docstring 已警告 deploy.tests 路径遮蔽问题)。顺序必须是 [deploy/, 根], 且 deploy 测试仍保留文件内的 deploy 目录插入(见下)。 为什么不用其它方案: - `discover -s tests -t .`:start 目录缺 __init__.py,unittest 报 Start directory is not importable,实测三套件全灭。 - conftest.py:那是 pytest 的机制,unittest 不读。 - sitecustomize:会污染仓库外的每一次 python 启动,不可接受。 - 不给 tests/ 加 __init__.py:会改模块名与 discover 语义, 超出本步范围。 与 CI 的分工(host-verify.yml): - verify 作业走本运行器(python3 run_tests.py)。 - coverage 作业仍走三条 `coverage run -m unittest discover` 原命令:coverage 测不到子进程里的测试,运行器是子进程分套件, 走它会量出 0 覆盖;从根目录用 -m 跑时 cwd 本就在 sys.path 上,host/cloud 套件不靠文件内插入也能找到模块,deploy 套件 靠文件内保留的 deploy 目录插入(陷阱 2)。 单文件直跑(迁移后仍可用,须在仓库根目录执行): - python3 -m unittest tests.test_host_coverage85 - python3 -m unittest cloud.tests.test_server - deploy 单文件用 python3 run_tests.py --test test_deploy 不再支持 `python3 tests/test_x.py` 直接脚本执行:那种跑法下 sys.path[0] 是 tests/ 目录,根不在路径上,改用上面 -m 形式。 例外(运行器修不了,如实保留): - deploy/tests/test_coverage85_verify_multiuser.py:目标文件 verify-multiuser.py 带连字符,无法 import,只能继续用 importlib spec 加载;其根路径插入已删(运行器/覆盖率 命令都已提供根路径),spec 加载与 noqa: E402 保留。 """ import subprocess import sys import unittest from pathlib import Path ROOT = Path(__file__).resolve().parent DEPLOY_DIR = ROOT / "deploy" SUITES = { "host": ROOT / "tests", "cloud": ROOT / "cloud" / "tests", "deploy": ROOT / "deploy" / "tests", } def _setup_paths(suite): """按套件把路径摆好。顺序即优先级,勿调换 deploy 与根的次序。""" sys.path.insert(0, str(ROOT)) if suite == "deploy": sys.path.insert(0, str(DEPLOY_DIR)) def _run_discovery(suite, verbose): start = str(SUITES[suite]) _setup_paths(suite) loader = unittest.TestLoader() found = loader.discover(start, pattern="test_*.py", top_level_dir=start) runner = unittest.TextTestRunner(verbosity=2 if verbose else 1) result = runner.run(found) return 0 if result.wasSuccessful() else 1 def _run_single(name, verbose): """单文件/单用例。deploy 套件只认裸模块名(见模块 docstring)。""" if name.startswith("tests."): suite = "host" elif name.startswith("cloud.tests."): suite = "cloud" elif (SUITES["deploy"] / (name.split(".")[0] + ".py")).is_file(): suite = "deploy" else: print("无法推断套件:%s(host 用 tests.…,cloud 用 " "cloud.tests.…,deploy 用裸模块名)" % name, file=sys.stderr) return 2 _setup_paths(suite) if suite == "deploy": sys.path.insert(0, str(SUITES["deploy"])) loader = unittest.TestLoader() try: found = loader.loadTestsFromName(name) except Exception as exc: # noqa: BLE001 - 名字错误直接报给调用方 print("加载失败:%s" % exc, file=sys.stderr) return 2 runner = unittest.TextTestRunner(verbosity=2 if verbose else 1) result = runner.run(found) return 0 if result.wasSuccessful() else 1 def _run_suite_subprocess(suite, verbose): cmd = [sys.executable, str(ROOT / "run_tests.py"), "--suite", suite] if verbose: cmd.append("-v") print("=== %s ===" % suite, flush=True) proc = subprocess.run(cmd) print("=== %s: %s ===" % ( suite, "PASS" if proc.returncode == 0 else "FAIL"), flush=True) return proc.returncode def main(argv): verbose = "-v" in argv or "--verbose" in argv args = [a for a in argv if a not in ("-v", "--verbose")] if args and args[0] == "--suite": # 子进程入口:单套件、当前解释器内跑 discover。 return _run_discovery(args[1], verbose) if args and args[0] == "--test": return _run_single(args[1], verbose) suites = [] if not args: suites = ["host", "cloud", "deploy"] elif len(args) == 1 and args[0] in SUITES: suites = [args[0]] else: print(__doc__.split("用法")[0], file=sys.stderr) print("用法:run_tests.py [host|cloud|deploy] " "[--test <用例>] [-v]", file=sys.stderr) return 2 # 各套件走独立子进程:sys.path 顺序(尤其 deploy)互不污染, # 且与 CI 原来三条独立命令的隔离性一致。 failed = [s for s in suites if _run_suite_subprocess(s, verbose) != 0] if failed: print("失败套件:%s" % ", ".join(failed), file=sys.stderr) return 1 print("全部通过:%s" % ", ".join(suites)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))