diff --git a/.coveragerc b/.coveragerc index 78a0805..005131d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -18,6 +18,7 @@ omit = tests/* work/* examples/* + */run_tests.py [report] show_missing = False diff --git a/.github/workflows/host-verify.yml b/.github/workflows/host-verify.yml index 68f899c..7b9044a 100644 --- a/.github/workflows/host-verify.yml +++ b/.github/workflows/host-verify.yml @@ -28,9 +28,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: python3 -m unittest discover -s tests -v - - run: python3 -m unittest discover -s cloud/tests -v - - run: python3 -m unittest discover -s deploy/tests -v + # 测试入口统一走 run_tests.py:它在运行器层面加仓库根路径 + # (deploy 套件再加 deploy/ 目录,见 run_tests.py docstring), + # 测试文件本身不再写 sys.path 前置 import(ruff E402 清零轨)。 + # coverage 作业仍用下面三条 discover 原命令:运行器是子进程 + # 分套件,coverage 测不到子进程里的测试。 + - run: python3 run_tests.py -v lint: runs-on: ubuntu-latest steps: diff --git a/deploy/tests/test_coverage85_verify_multiuser.py b/deploy/tests/test_coverage85_verify_multiuser.py index b826d71..45992e9 100644 --- a/deploy/tests/test_coverage85_verify_multiuser.py +++ b/deploy/tests/test_coverage85_verify_multiuser.py @@ -20,7 +20,6 @@ from contextlib import redirect_stdout from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(REPO_ROOT)) _SPEC = importlib.util.spec_from_file_location( "verify_multiuser", os.path.join(REPO_ROOT, "deploy", "verify-multiuser.py")) diff --git a/run_tests.py b/run_tests.py new file mode 100755 index 0000000..5cf911a --- /dev/null +++ b/run_tests.py @@ -0,0 +1,158 @@ +#!/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:])) diff --git a/tests/test_client.py b/tests/test_client.py index 3bfd5d1..4a4fa4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,12 +16,7 @@ import tempfile import types import unittest from contextlib import redirect_stdout, redirect_stderr -from pathlib import Path from unittest import mock - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import client as client_mod diff --git a/tests/test_host.py b/tests/test_host.py index accbcbe..7a2afec 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -6,12 +6,9 @@ import sys import tempfile import unittest from pathlib import Path - +import host REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - -import host class EffectiveCfgTest(unittest.TestCase): diff --git a/tests/test_host_continue.py b/tests/test_host_continue.py index b13a241..baa60f5 100644 --- a/tests/test_host_continue.py +++ b/tests/test_host_continue.py @@ -10,13 +10,7 @@ import os import sys import tempfile import unittest -from pathlib import Path from unittest import mock - - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_coverage85.py b/tests/test_host_coverage85.py index 26d6a3b..5d7b07d 100644 --- a/tests/test_host_coverage85.py +++ b/tests/test_host_coverage85.py @@ -12,17 +12,13 @@ import os import queue import sqlite3 import subprocess -import sys import tempfile import threading import time import unittest -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host + + from host import Server FAKE_SCRIPT = r'''#!/usr/bin/env python3 diff --git a/tests/test_host_coverage85_2.py b/tests/test_host_coverage85_2.py index 4b9ba84..ab8b631 100644 --- a/tests/test_host_coverage85_2.py +++ b/tests/test_host_coverage85_2.py @@ -23,10 +23,6 @@ import time import unittest from contextlib import redirect_stdout from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host from host import ( dispatch, @@ -39,6 +35,9 @@ from host import ( run_task_turn_with_retry, ) +REPO_ROOT = Path(__file__).resolve().parents[1] + + HERE = os.path.dirname(os.path.abspath(host.__file__)) FAKE_SCRIPT = r'''#!/usr/bin/env python3 diff --git a/tests/test_host_dispatch_branch.py b/tests/test_host_dispatch_branch.py index 155c93f..37b9f3a 100644 --- a/tests/test_host_dispatch_branch.py +++ b/tests/test_host_dispatch_branch.py @@ -14,15 +14,10 @@ import os import shutil import subprocess -import sys import tempfile import unittest import uuid from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_resume_missing.py b/tests/test_host_resume_missing.py index 9f7717c..f595c29 100644 --- a/tests/test_host_resume_missing.py +++ b/tests/test_host_resume_missing.py @@ -13,12 +13,7 @@ import sys import tempfile import unittest from contextlib import redirect_stdout -from pathlib import Path from unittest import mock - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_run_verify.py b/tests/test_host_run_verify.py index 488b92f..9b47473 100644 --- a/tests/test_host_run_verify.py +++ b/tests/test_host_run_verify.py @@ -13,14 +13,8 @@ run_verify(1319)、notify(1497)、goal verify 两处 与 `$(touch )` 不执行、只落日志原文),本文件不再重复。 """ -import sys import tempfile import unittest -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_sleep.py b/tests/test_host_sleep.py index 44790f9..4f48265 100644 --- a/tests/test_host_sleep.py +++ b/tests/test_host_sleep.py @@ -5,12 +5,7 @@ import sys import tempfile import unittest from contextlib import redirect_stdout -from pathlib import Path from unittest import mock - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_state_lock.py b/tests/test_host_state_lock.py index 7758da5..a1ae84c 100644 --- a/tests/test_host_state_lock.py +++ b/tests/test_host_state_lock.py @@ -14,16 +14,9 @@ import glob import json import multiprocessing import os -import sys import tempfile import time import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_thread_cli.py b/tests/test_host_thread_cli.py index 9dd91a8..d196a52 100644 --- a/tests/test_host_thread_cli.py +++ b/tests/test_host_thread_cli.py @@ -5,13 +5,7 @@ import sys import tempfile import unittest from contextlib import redirect_stdout -from pathlib import Path from unittest import mock - - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import host diff --git a/tests/test_host_trace.py b/tests/test_host_trace.py index e75e621..e2e2f80 100644 --- a/tests/test_host_trace.py +++ b/tests/test_host_trace.py @@ -7,16 +7,10 @@ import json import os import shutil -import sys import tempfile import unittest -from pathlib import Path +from host import Server, TRACE_MAX_LINES, _summarize_trace_item -try: - from host import Server, TRACE_MAX_LINES, _summarize_trace_item -except ImportError: - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - from host import Server, TRACE_MAX_LINES, _summarize_trace_item def _msg(item, ts=1700000000000): diff --git a/tests/test_run_parallel.py b/tests/test_run_parallel.py index 751ec07..ab969aa 100644 --- a/tests/test_run_parallel.py +++ b/tests/test_run_parallel.py @@ -13,12 +13,7 @@ import sys import tempfile import unittest from contextlib import redirect_stdout -from pathlib import Path from unittest import mock - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) - import run_parallel as rp_mod