执行摘要
- 一句话:15 个示例测试迁入 tests/fast 纳入 CI,并用守卫测试杜绝孤儿测试
- 推荐动作:值得精读,尤其是
tests/ci/test/test_ci_discovery_coverage.py 的三个守卫设计和 git ls-files 替代文件系统遍历的决策——这是“用测试约束测试放置”的治理范式,比一次性迁移更有长期价值。值得关注的决策点:①“无法运行的测试应自我声明 skip 而不是隐形缺席”的原则;②_KNOWN_ORPHANS 设计为只收缩,条目随文件迁移自动过期,避免豁免永久化;③test_the_example_suites_reach_the_cpu_plan 直接调用 collect_tests(discover_ci_files()) 校验真实计划,而不是断言文件存在这种弱检查。对 CI 治理、测试编排感兴趣的读者可重点参考其结构。
功能与动机
关联 Issue #2546 明确提出:“Offline CPU test suites that need no API, no SDK and no GPU should run in CI. Three of them live under examples/ today and are collected by nobody.” PR body 进一步点出代价:“#2545 is what that costs”——一个阻止 16 节点 GB300 任务在 9144 个文件描述符处停摆的回归测试,被加进了没有 CI 任务会打开的文件。PR 确立的规则是:“a test that cannot run somewhere says so itself”——无法运行的测试应当用模块级 skip 自我声明,而不是住在一个缺失不可见的角落。
实现拆解
实现拆解
1. 迁移 15 个测试文件到测试树内
将 examples/experimental/openenv/tests/ 下 10 个文件、examples/experimental/hud/tests/ 下 4 个文件、examples/experimental/nemo-gym/tests/ 下 1 个文件分别迁入 tests/fast/examples/experimental/{openenv,hud,nemo_gym}/。tests/fast/ 按 ci_register.py 的 _is_implicit_fast_cpu_path 约定自动注册为 CPU CI,无需在 ci_register.py 中显式声明,也无需改动 pyproject.toml 的 testpaths。每个文件的 docstring 同步从“Not collected by the repo-level pytest run”改为“Runs on every PR”,并更新 README 中的手动 pytest 命令。
2. 用 conftest.py + init.py 收敛 sys.path 引导样板
迁移前每个测试文件自带两条 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)),并依赖 # noqa: E402 绕过“导入不在顶部”的 lint。原因在于 rollout 通过 --custom-agent-function-path 按路径加载示例模块,模块之间用裸名互相 import,测试必须模拟同一方式。改造后每个包内的 __init__.py 定义 EXAMPLE_DIR(从 parents[5] 定位到 checkout 根目录再拼接 examples/experimental/<name>),conftest.py 负责把 EXAMPLE_DIR 插入 sys.path;跨测试 fake 导入(如 from test_openenv_agent_function import _CLASSES)改为包相对导入 from .test_openenv_agent_function import ...。noqa: E402 随样板一起删除。
3. 新增 CI 守卫测试 test_ci_discovery_coverage.py(核心新增)
该文件注册为 stage-a-cpu 自身的一份 CPU 用例,包含三个测试:test_no_test_file_lives_outside_the_tests_tree 遍历“仓库追踪的树外 test 文件”(_KNOWN_ORPHANS 豁免);test_no_known_orphan_outlives_its_reason 让豁免条目在文件移走后自动报错过期;test_the_example_suites_reach_the_cpu_plan 通过真实调用 collect_tests(discover_ci_files()) 断言 tests/fast/examples/ 下每个条目都进入 CPU 的 stage-a-cpu 计划且未被禁用。_KNOWN_ORPHANS 当前仅含 miles/utils/test_wandb_utils.py(对应 #2557),并带 TODO(#2557) 注释引导删除。
4. 修正守卫自身的环境差异 bug
初版守卫用文件系统遍历(从 checkout 根目录 rglob)统计孤儿测试,但 CI 会把 sglang 和 Megatron-LM 克隆进 workspace,导致从 checkout 根遍历会继承其他仓库数千个测试文件。改为 git ls-files -z -- '*test_*.py' 询问“这个仓库追踪了什么”,从语义上把问题限定在仓库自身版本控制的范围内,并用 -z 处理含空格路径。
5. 验证与配套
PR body 给出验证矩阵:pytest tests/fast/examples/experimental/{openenv,hud} → 101 passed、7 skipped、2.1 s;tests/ci/test → 341 passed;run_suite.py --hw cpu --suite stage-a-cpu --list-only 列出全部 15 个文件;并实际栽种一个孤儿文件确认守卫测试变红。nemo-gym 套件在 stage-a-cpu 上 7 个测试全部通过,hud 4 个按设计报告为 skip。
关键文件:
tests/ci/test/test_ci_discovery_coverage.py(模块 CI 守卫;类别 test;类型 test-coverage;符号 _test_files_outside_the_tests_tree, test_no_test_file_lives_outside_the_tests_tree, test_no_known_orphan_outlives_its_reason, test_the_example_suites_reach_the_cpu_plan): 本 PR 的核心创新:三条守卫规则把“测试文件必须住在 tests/ 下”变成可执行检查,并用 git ls-files 规避 CI 克隆外部仓库导致的文件系统误判。
tests/fast/examples/experimental/openenv/conftest.py(模块 测试引导;类别 test;类型 test-coverage): 迁移后的统一 sys.path 引导点,替代 10 个测试文件各自携带的 sys.path.insert 样板,是示例测试可移植性的关键设计。
tests/fast/examples/experimental/openenv/test_openenv_sandbox_common.py(模块 沙箱测试;类别 test;类型 rename-or-move): openenv 套件中覆盖最广的公共沙箱层测试,直观展示迁移前后的导入形态变化(sys.path.insert + noqa: E402 → 包相对导入 + EXAMPLE_DIR)。
tests/fast/examples/experimental/openenv/__init__.py(模块 测试定位;类别 test;类型 test-coverage;符号 EXAMPLE_DIR): 定义 EXAMPLE_DIR 作为测试与示例目录之间的唯一定位点,conftest.py 与两个断言示例自身文件的测试都依赖它。
tests/fast/examples/experimental/nemo_gym/test_nemogym_agent_function.py(模块 适配器测试;类别 test;类型 rename-or-move): nemo-gym 套件是待定决策的验证样本:依赖 datasets 的测试迁入后首次在 stage-a-cpu 上 7 个用例全部通过,证明 CI 镜像具备运行条件。
tests/fast/examples/experimental/nemo_gym/conftest.py(模块 测试引导;类别 test;类型 test-coverage): 与 openenv/conftest.py 构成同一引导模式的另一实例,同时也是 review 讨论的焦点(重复代码与 parents[5] 深度约定)。
关键符号:_test_files_outside_the_tests_tree, test_no_test_file_lives_outside_the_tests_tree, test_no_known_orphan_outlives_its_reason, test_the_example_suites_reach_the_cpu_plan
关键源码片段
tests/ci/test/test_ci_discovery_coverage.py
本 PR 的核心创新:三条守卫规则把“测试文件必须住在 tests/ 下”变成可执行检查,并用 git ls-files 规避 CI 克隆外部仓库导致的文件系统误判。
"""一个 CI 永远不会打开的测试文件只是文档,不是测试。
pytest 的 testpaths 指向 ./tests,ci_register 在其下扫描四个根目录,所以
放在被测代码旁边的测试文件只有等人手动想起才会运行。仓库里积攒了
15 个这样的文件——openenv 示例就带了 10 个文件、100 个离线测试,它们
只需要 2 秒且不需要 SDK——而 #2545 正是这种安排的代价:针对文件描述符
泄漏(曾让一个 16 节点任务停摆)的回归测试,被加进了一个没有 CI 任务
会收集的文件。
因此这些测试立下的规则是:测试文件必须住在 tests/ 下;暂时无法运行的
测试应当用模块级 skip 自己说明(见 hud 套件的 importorskip)。skip 会
被运行器报告,并在依赖落地那天自动开始工作;而树外的文件无论怎样都
是隐形的。
_KNOWN_ORPHANS 是逃生口,而且它被设计成只会收缩:第三条测试会在
条目所指文件移动后就失败,所以过期的豁免无法残留。
"""
import subprocess
from pathlib import Path, PurePosixPath
import pytest
from tests.ci.ci_register import HWBackend, collect_tests, discover_ci_files, register_cpu_ci
# 自身也是 stage-a-cpu 的一份 CPU 用例,估时 1 秒
register_cpu_ci(est_time=1, suite="stage-a-cpu", labels=[])
REPO_ROOT = Path(__file__).resolve().parents[3]
# 当前被规则豁免的文件。条目存在就是为了被删除;新增一条本身就是一个
# 需要在 review 中捍卫的决定,而不是走过场。
_KNOWN_ORPHANS = {
# TODO(#2557): 移入 tests/ 并删除本条。
"miles/utils/test_wandb_utils.py",
}
def _test_files_outside_the_tests_tree() -> list[str]:
"""问 git 而不是问文件系统:本仓库追踪了哪些树外的测试文件。
从 checkout 根目录做目录遍历是错的:CI 会把 sglang 和 Megatron-LM
克隆进工作区,遍历会继承其他仓库的数千个测试文件(本地还会遇到
venv 或 worktree 里的东西)。
"""
listing = subprocess.run(
["git", "ls-files", "-z", "--", "*test_*.py"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
).stdout
return sorted(
path
for path in listing.split("\0")
if path and not path.startswith("tests/") and PurePosixPath(path).name.startswith("test_")
)
def test_no_test_file_lives_outside_the_tests_tree():
if not (REPO_ROOT / ".git").exists():
pytest.skip("没有 git 元数据可问;规则约束的是仓库追踪的内容")
offenders = [f for f in _test_files_outside_the_tests_tree() if f not in _KNOWN_ORPHANS]
assert offenders == [], (
"这些文件名字像测试却没有运行器收集;请把它们移入 "
"tests/fast/(CPU)或 tests/fast-gpu/,并在模块级用 skip "
f"声明缺少的依赖: {offenders}"
)
def test_no_known_orphan_outlives_its_reason():
"""已经移走的文件留下的豁免条目豁免不了任何东西。"""
stale = sorted(f for f in _KNOWN_ORPHANS if not (REPO_ROOT / f).is_file())
assert stale == [], f"这些豁免不再指向存在的文件,请删除: {stale}"
def test_the_example_suites_reach_the_cpu_plan(monkeypatch):
"""移动只有在运行器真的收集它们时才有意义。
discover_ci_files() 用仓库相对路径做 glob,读的是运行器启动时的 cwd;
这里把它钉在 checkout 根目录上,再对真实计划做断言。
"""
monkeypatch.chdir(REPO_ROOT)
plan = collect_tests(discover_ci_files())
example_entries = [r for r in plan if r.filename.startswith("tests/fast/examples/")]
assert example_entries, "tests/fast/examples/ 下没有任何测试进入 CI 计划"
for entry in example_entries:
assert entry.backend == HWBackend.CPU, entry.filename
assert entry.suite == "stage-a-cpu", entry.filename
assert entry.disabled is None, entry.filename
tests/fast/examples/experimental/openenv/test_openenv_sandbox_common.py
openenv 套件中覆盖最广的公共沙箱层测试,直观展示迁移前后的导入形态变化(sys.path.insert + noqa: E402 → 包相对导入 + EXAMPLE_DIR)。
"""共享沙箱层的离线单元测试(无网络、无 GPU)。
现在随每次 PR 运行(stage-a-cpu,遵循 tests/fast/ 约定);本地运行:
pytest tests/fast/examples/experimental/openenv -q
覆盖每个后端都继承的公共逻辑,因此只需证明一次而不是每个 provider
各证明一次:后端注册表、episode 分发、sandbox 创建限流的退避与
取消重入等。
"""
import asyncio
import logging
import sys
import threading
from contextlib import asynccontextmanager
# 示例模块由 conftest.py 统一放入 sys.path,不再需要 per-file 的
# sys.path.insert;跨测试的 fake 共享改为包相对导入,noqa: E402 随之删除
import openenv_agent_function as oaf
import openenv_sandbox_common as common
import pytest
from . import EXAMPLE_DIR
from .test_openenv_agent_function import _CLASSES, _FakeEnv, _FakePolicy, _FakeResult
logger = logging.getLogger("test-backend")
def _backend(**overrides) -> common.SandboxBackend:
"""一个 provider 特化部分为桩的 backend。
每个真实 backend 都是这个对象加上 start hook 与 throttle 分类器,
所以在这里测试它就等于测试所有 backend。退避被缩小到毫秒级,
保证重试测试瞬时完成。
"""
spec = {
"provider": "Fake",
"start_sandbox": lambda task_id, tasks_dir: ((lambda: None), "http://sandbox:8000"),
"is_throttle": lambda exc: isinstance(exc, _Throttled),
"logger": logger,
"backoff_base_s": 0.001,
"backoff_cap_s": 0.001,
}
return common.SandboxBackend(**{**spec, **overrides})
@pytest.mark.parametrize(
("name", "expected"),
[
("daytona", "daytona"),
("e2b", "e2b"),
("agentenv", "e2b"), # agentenv 是 e2b 的别名
(" E2B ", "e2b"),
("modal", "modal"),
],
)
def test_resolve_backend_normalizes_names_and_aliases(name, expected):
assert common.resolve_backend(name) == expected
@pytest.mark.parametrize("name", [None, "", " "])
def test_resolve_backend_refuses_to_pick_for_you(name):
# 注册表存在的意义就是拒绝猜测:哪个 provider 跑,决定扣谁的配额
with pytest.raises(ValueError, match="no sandbox backend named"):
common.resolve_backend(name)
tests/fast/examples/experimental/openenv/__init__.py
定义 EXAMPLE_DIR 作为测试与示例目录之间的唯一定位点,conftest.py 与两个断言示例自身文件的测试都依赖它。
"""openenv tbench2 示例的测试,示例本体住在测试树之外。
EXAMPLE_DIR 是跨越两者的唯一定位点:conftest 把它放进 sys.path 让
模块按裸名导入;断言示例自身文件的测试(面向操作员的帮助文本、
launcher 传入的 agent-function 目标)从这里读取。
"""
from pathlib import Path
# parents[5] 到达仓库根目录:本文件位于
# tests/fast/examples/experimental/openenv/,上溯 5 级即 checkout 根。
# 一旦包嵌套层级变化,这里会指向错误目录并让相关测试失败——
# 不是静默失效,但确实是一个需要留意的深度约定。
EXAMPLE_DIR = Path(__file__).resolve().parents[5] / "examples" / "experimental" / "openenv"
评论区精华
- conftest.py 重复与 parents[5] 目录深度(claude[bot] → nblintao):claude[bot] 指出 openenv 与 nemo_gym 两个新增
conftest.py 除 docstring 外字节相同,且两个 __init__.py 都用同样的 Path(__file__).resolve().parents[5] 爬到仓库根,构成“目录深度的静默不变量”,若包嵌套变化会静默指错目录,建议抽取共享 helper(非阻塞)。nblintao 回复 “Not fix. It will not break _silently”——目录深度变化会导致相关测试失败而非静默通过,因此不需要提前抽象。最终未合并 helper,Shi-Dong 以 LGTM 批准。
- 守卫测试与真实 CI 注册逻辑的一致性(claude[bot]):claude[bot] 在整体评论中说明它没有照搬 PR 描述,而是对照了
tests/ci/ci_register.py 的真实实现(discover_ci_files、collect_tests、HWBackend、tests/fast/ 隐式 CPU 约定),确认新守卫测试的假设与真实代码一致,这也是其 LGTM 的依据之一。
- conftest.py 重复引导与 parents[5] 目录深度不变量 (design): nblintao 回复 “Not fix. It will not break _silently”,认为目录深度变化会让相关测试失败而不是静默通过,无需提前抽象;最终未合并 helper,Shi-Dong 以 LGTM 批准,该意见作为 known tradeoff 保留。
- 守卫测试假设与 ci_register 真实实现的一致性核验 (testing): 核验结果与真实代码一致,成为 claude[bot] 给出 LGTM 的依据之一;无后续异议。
风险与影响
- 风险:
- git ls-files 依赖仓库元数据:
_test_files_outside_the_tests_tree 依赖 .git 存在,本地裸 checkout 会走 pytest.skip 分支(有 fallback),CI 环境克隆完整仓库不受影响;但若 CI checkout 配置了 --filter=blob:none 之类的精简模式,git ls-files 仍可用(它读取 index 而非 blob),风险可控。
- glob
*test_*.py 覆盖范围从严:该模式会捕获任何名字含 test_ 的 .py 文件,包括非 pytest 的辅助脚本(如 some_test_helper.py),可能出现误报,只能通过 _KNOWN_ORPHANS 逐条豁免;这是有意的“从严”设计,但会给后续新增工具类文件带来额外流程成本。
- parents[5] 目录深度约定:
EXAMPLE_DIR 依赖包嵌套深度固定,若未来把 tests/fast/examples/experimental/ 再加深一层,parents[5] 会指向错误目录。nblintao 认为该失效会表现为测试失败而非静默,但若错误目录恰好存在同名文件,错误信息可能误导排查。低风险。
- hud 套件 4 个文件全量 skip:hud 4 个测试在
hud SDK 未安装时模块级 importorskip,CI 中暂时只贡献 skip 记录,价值待依赖落地后兑现,符合既定规则。
- 101 个测试首次进入 CI:这些测试此前从未在 CI 环境运行过,存在环境差异导致的偶发失败可能;PR body 记录首次 stage-a-cpu 运行已全部通过(nemo-gym 7 passed、hud 4 skipped),该风险已被实证消除。
- 影响:对 CI 系统:stage-a-cpu 计划新增 15 个文件、约 100+ 用例,总耗时约 2 秒,成本可忽略;CI 从此多了一道“孤儿测试发现守卫”,所有未来 PR 新增测试文件若落在 tests/ 外会直接变红。对测试基础设施:tests/fast/examples/experimental/ 成为示例测试的标准归宿,conftest.py + __init__.py(EXAMPLE_DIR) 成为示例测试的可复用引导模板;_KNOWN_ORPHANS 提供了带自愈机制的临时豁免通道。对开发团队:新增测试的位置成为必须遵守的约定,且守卫测试本身提供了清晰的整改指引(错误信息直接提示移入 tests/fast/ 或 tests/fast-gpu/ 并用模块级 skip 声明依赖)。对 v0.1 milestone:作为测试治理的收口动作,直接服务于发布前质量保障。
- 风险标记:git ls-files 依赖仓库元数据, parents[5] 目录深度约定, hud 套件全量 skip, 101 个测试首次进 CI, glob 覆盖范围从严可能误伤
关联脉络
- PR #2545 Reuse one Daytona client per process instead of one per create attempt: 本 PR 的直接触发事件:该 PR 修复的 fd 泄漏回归测试被放进了 CI 永不收集的目录,暴露出“孤儿测试”治理缺口。
- PR #2546 CI: run the offline CPU test suites that live under examples/ (openenv's 100 tests, 1.9s, zero deps): 本 PR closes 的关联 Issue,提供了完整的迁移提案、三套件的依赖判定和成本测算。
- PR #2557 miles/utils/test_wandb_utils.py 的迁移处理: _KNOWN_ORPHANS 中唯一豁免条目的目标处理 PR,TODO(#2557) 注释依赖其落地后自动删除豁免。
- PR #2561 test(ci): file the verifiers tests under the example they test: 同一治理方向的后续 PR:把 verifiers 测试按 example 目录归档进 tests/fast,验证本 PR 确立的“测试住在 tests/ 下”规则被继续执行。
参与讨论