cli-anything-sbox 测试体系全解析:244 项测试的分层设计、退出码契约与地图测试编排
【免费下载链接】CLI-Anything"CLI-Anything: Making ALL Software Agent-Native" -- CLI-Hub: https://clianything.cc/项目地址: https://gitcode.com/GitHub_Trending/cl/CLI-Anything
s&box(Facepunch Studios 基于 Source 2 的游戏引擎)的 CLI 自动化桥接层 cli-anything-sbox 在 sbox/agent-harness 中内置了一套 244 项测试的完整测试体系。本文以 sbox/agent-harness/cli_anything/sbox/tests/TEST.md 为主线,从测试清单、运行方式、逐类覆盖、模块映射到已知边界与平台怪癖,逐层拆解这套测试的设计意图,并配合 sbox_cli.py 与各 core 模块源码验证其底层契约。读完本文,你将掌握如何在无 s&box 安装的环境下跑通纯单元与子进程级 E2E 测试、如何配置环境变量切换测试目标、理解"一次性命令失败必须退出码 1"这一对 Agent 自动化至关重要的契约,以及地图生成测试编排管线的组合矩阵与哨兵轮询机制。
1. 测试清单总览
整个测试套件集中在 sbox/agent-harness/cli_anything/sbox/tests 目录下,由四个测试文件构成,全部可在agent-harness/目录下运行:
| 测试文件 | 覆盖范围 | 测试数 |
|---|---|---|
| test_core.py | 全部 13 个 core 模块:project、scene、prefab、codegen、input_config、collision_config、material、sound、localization、session、export、validate,外加 s&box 后端解析器 | 157 |
| test_full_e2e.py | 通过子进程调用 CLI 表层、项目工作流端到端、sbox_backend 集成 | 50 |
| test_orchestrator.py | 地图生成测试编排器(组合矩阵、哨兵轮询、RGBA→PNG 转换、配置读写) | 17 |
| test_exit_codes.py | 一次性 CLI 退出码契约、REPL 吸收 SystemExit、monkeypatch 覆盖的字典返回失败路径 | 20 |
| 合计 | 244 |
值得注意的是 test_core.py 的 157 项测试来自 36 个测试类,是纯单元测试;而 test_full_e2e.py 的 40 项TestCLISubprocess测试并不依赖 s&box 安装,它们通过子进程调用 CLI 并断言--json输出,专门验证 README.md 中记载的 79 个命令、14 个命令组的可用性。
2. 运行测试:分层命令与环境配置
2.1 按粒度运行的命令
从agent-harness/目录执行(对应 sbox/agent-harness/setup.py 的包布局):
# 纯单元测试(速度快,无需安装 s&box) python -m pytest cli_anything/sbox/tests/test_core.py -v # 编排器测试(Pillow 为硬依赖,通常可直接运行) python -m pytest cli_anything/sbox/tests/test_orchestrator.py -v # 完整 E2E 套件(TestE2EBackend 在未安装 s&box 时自动跳过) python -m pytest cli_anything/sbox/tests/test_full_e2e.py -v # 全部测试 python -m pytest cli_anything/sbox/tests/ -v2.2 环境变量契约
| 变量 | 作用 |
|---|---|
SBOX_PATH | 将 harness 指向非 Steam 目录的 s&box 安装。当 s&box 不在标准 Steam 库中时,TestE2EBackend必须依赖它。 |
CLI_ANYTHING_FORCE_INSTALLED | 设为1/true/yes时,TestCLISubprocess改为针对已安装的cli-anything-sbox控制台脚本运行,而不是python -m。 |
这两个变量的行为与 test_full_e2e.py 中的_resolve_cli辅助函数直接对应:它优先用shutil.which查找已安装命令,找不到时回退到python -m cli_anything.sbox.sbox_cli;只有当CLI_ANYTHING_FORCE_INSTALLED被设置为真值且命令不在 PATH 中时才会抛错。这套"先探测、后回退"策略保证了测试套件在只有源码、未做pip install -e .的裸环境下也能跑。
2.3 跳过(Skip)行为
TestE2EBackend(3 项测试)在主机上找不到 s&box 时整体跳过——不是失败、不是报错,而是带原因说明的干净跳过。test_converts_rgba_bytes_to_png在 Pillow 不可导入时跳过(由于 Pillow 是硬依赖,正常情况下始终可导入)。
这种"环境不满足就跳过而非报错"的设计,使套件可以在仅具备 Python 的开发机、未安装引擎的 CI 以及完整 s&box 环境中三态运行。
3. test_core.py:157 项单元测试,36 个类
test_core.py 是套件的主体,覆盖 13 个 core 模块的全部纯逻辑。所有测试自包含,文件操作统一使用 pytest 的tmp_pathfixture(见文件头部注释),不触碰真实用户目录。以下按被测模块分组说明。
3.1 项目与校验(project / validate)
| 类 | 测试数 | 被测模块与要点 |
|---|---|---|
TestProject | 7 | core/project.py:create、load/save、info、configure、find_sbproj |
TestProjectPackages | 4 | add/remove package references |
TestProjectValidate | 4 | core/validate.py:坏引用、重复 GUID、畸形输入 |
从源码签名看,core/project.py 的create_project支持name、project_type(默认"game")、org(默认"local")、max_players(默认 64)、tick_rate(默认 50)、network_type(默认"Multiplayer")、startup_scene(默认"scenes/minimal.scene")等参数;_default_sbproj与_default_minimal_scene负责生成工程骨架。validate 模块的validate_project(project_dir, check_refs, check_guids, check_inputs)是项目"体检"入口,由_build_asset_index、_is_engine_builtin、_collect_guids等内部函数配合完成引用与 GUID 检查。
3.2 场景(scene):最大的一簇,共 50 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestScene | 12 | create、list、增删 object/component、find、GUID 唯一性 |
TestSceneQuery | 9 | query_objects:按 component / tag / name / regex / bounds / enabled / 组合过滤 |
TestSceneRefs | 3 | extract_asset_refs:默认场景、去重排序、空场景 |
TestSceneBulkModify | 4 | bulk_modify_objects:位置、多字段、无匹配、需更新 |
TestSceneCloneAndGet | 6 | clone_object+get_object |
TestSceneModify | 4 | modify_object:name、position、scale、tags |
TestSceneModifyComponent | 2 | modify_component_properties |
TestSceneInstantiatePrefab | 5 | instantiate_prefab:默认名、覆盖名、prefab 来源引用、父对象、非法父对象 |
TestSceneDiff | 5 | diff_scenes:相同、增删、位置、组件、场景属性 |
从 core/scene.py 的符号清单可见其实现厚度:query_objects支持has_component、has_tag、name_match、name_regex、in_bounds、enabled六个过滤维度,内部通过_object_has_component、_object_has_tag、_parse_position_bounds、_object_in_bounds等谓词实现 AND 组合语义;diff_scenes依赖_diff_two_objects与_object_summary生成结构化差异;extract_asset_refs通过_walk_for_refs递归遍历 JSON 节点,识别.vmdl/.vmat/.vsnd/.vtex/.vpcf/.prefab等资源引用,并调用_is_asset_ref与_category_for_ref分类。
3.3 预制体(prefab):14 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestPrefab | 4 | create、带组件、info、from-scene |
TestPrefabComponents | 2 | 在 prefab 根上增删组件 |
TestPrefabRefs | 1 | extract_asset_refs |
TestPrefabModifyComponent | 5 | 按 type / 按 guid 修改组件、未找到、缺 id、缺 properties |
TestPrefabDiff | 2 | diff_prefabs:相同、根变更 |
from_scene_object(scene_path, object_guid, output_path)(core/prefab.py)是从场景实体"抽出"预制体的关键函数,与场景侧的instantiate_prefab形成双向转换闭环。
3.4 代码生成(codegen):22 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestCodegen | 10 | 组件基础、带属性、网络化、接口、生命周期方法、gameresource、编辑器菜单、代码风格(tabs / Allman / CRLF) |
TestCodegenRazor | 5 | generate_razor |
TestCodegenClass | 3 | generate_class:静态类、基类、多行方法体回归 |
TestCodegenPanelComponent | 5 | 基础、含 ScreenPanel、命名空间、属性、GUID 唯一性 |
core/codegen.py 提供generate_component(支持lifecycle_methods、interfaces、is_networked、rpc_methods)、generate_gameresource、generate_editor_menu、generate_razor、generate_panel_component、generate_class六个生成器,内部由_format_property、_format_method、_format_rpc_method等函数负责逐行格式化。网络化组件与 RPC 方法、Razor UI、PanelComponent+ScreenPanel 双件套这些"易错点"都有专门测试钉住输出格式。
3.5 输入与碰撞配置(input_config / collision_config):12 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestInputConfig | 6 | 取默认、添加、重复、删除、设置、列出(core/input_config.py) |
TestCollisionConfig | 5 | 取默认、加层、删内置层、加规则、删规则 |
TestCollisionRemoveLayer | 1 | remove_layer |
add_action(name, group="Other", keyboard_code="None", gamepad_code="None", title=None)与add_layer(name, default="Collide")、add_rule(layer_a, layer_b, result="Collide")的默认参数均在单元测试中被逐一验证。
3.6 材质 / 声音 / 本地化:19 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestMaterial | 5 | new、load/save、info、list、presets(core/material.py) |
TestMaterialUpdate | 2 | update_material:shader、颜色贴图 |
TestSound | 4 | new、info、list、presets(core/sound.py) |
TestSoundUpdate | 2 | update_sound_event |
TestLocalization | 5 | new、set、get、list、remove(core/localization.py) |
TestLocalizationBulkSet | 1 | bulk_set |
create_material的默认参数(shader="complex"、metalness=0.0、tint="1 1 1 0")、create_sound_event的默认参数(volume="1"、pitch="1"、decibels=70、selection_mode="Random"、occlusion=True)都在这一层被固定下来。
3.7 会话 / 导出 / 引用图:30 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestSession | 6 | create、set project、undo/redo、save/load、clear、损坏文件警告 + 时间戳备份(core/session.py) |
TestExport | 3 | 列资源、过滤列表、查找工程目录 |
TestAssetRefGraph | 5 | find_asset_refs+find_unused_assets |
TestAssetRenameMove | 7 | rename_asset+move_asset:均含 dry-run、目标已存在、源缺失、跨目录 |
core/export.py 的rename_asset/move_asset是"重命名/移动资源并同步更新所有场景与预制体引用"的高危操作,测试对 dry-run、目标冲突、跨目录三种边界都做了覆盖;_rewrite_string_refs与_rewrite_refs_in_project负责在项目内重写引用字符串。
3.8 组件预设(presets):3 项
| 类 | 测试数 | 要点 |
|---|---|---|
TestComponentPresets | 2 | COMPONENT_PRESETS数量与标准名称 |
TestJointPresets | 1 | 关节预设覆盖 |
sandbox的COMPONENT_PRESETS常量(位于 core/scene.py)被_resolve_component_type使用,用于把--components中的短名(如box_collider、rigidbody)解析为完整组件类型。README 中记载的 29 个预设(model、box_collider、sphere_collider、rigidbody、camera、各类灯光、joint 系列等)即由此常量驱动。
4. test_full_e2e.py:50 项端到端测试,3 个类
4.1 TestCLISubprocess(40 项)
以子进程方式启动cli-anything-sbox(未安装时回退为python -m cli_anything.sbox.sbox_cli),对 README 记载的每个命令组断言--json输出结构。它验证的是"命令真实可执行、输出可被机器解析",而非仅仅存在于 help 文本中。
4.2 TestE2EProjectWorkflow(7 项)
使用进程内 Click runner 跑完整项目工作流。test_full_project_creation(见 test_full_e2e.py)是典型代表,它断言一次create_project必须同时产出:
.sbproj文件且为合法 JSON(Title、Type字段正确);Assets/scenes/minimal.scene(含GameObjects与SceneProperties两个键);Code/Assembly.cs与Editor/Assembly.cs;ProjectSettings/Input.config与ProjectSettings/Collision.config,且均为合法 JSON。
test_scene_manipulation_workflow则验证 create → add 3 个对象(不同组件组合)→ 校验对象计数与 GUID 互异的完整链路。整个工作流覆盖 create → add objects → generate code → validate 的闭环。
4.3 TestE2EBackend(3 项)
真实 s&box 后端集成,覆盖 utils/sbox_backend.py 的三个探测函数:find_sbox_installation(定位安装)、get_sbox_version(读取版本)、find_server_executable(定位服务端可执行文件)。未安装 s&box 时这 3 项干净跳过。
5. test_orchestrator.py:17 项地图测试编排器测试
这组测试验证 core/test_orchestrator.py(地图生成测试管线)的五个核心机制:
| 类 | 测试数 | 覆盖 |
|---|---|---|
TestComboMatrix | 7 | Strategy × Size × Seed 组合矩阵 |
TestSentinelPolling | 3 | 文件系统哨兵轮询(用于检测引擎内测试是否完成) |
TestConfigIO | 3 | test_config.json往返(读、校验、写) |
TestRgbaConversion | 2 | RGBA 字节数组 → PNG(经 Pillow) |
TestDataPathResolution | 2 | FileSystem.Data路径在编辑器与独立模式下的解析 |
从测试断言可以看出组合矩阵的默认形态:build_combo_matrix()默认产生 12 个组合,即 4 种策略 × 3 种尺寸 × 1 个种子;策略集合为{"Serpentine", "Gilbert", "SpanningTree", "Backbite"},尺寸集合为{"Small", "Medium", "Large"}。seeds=[42, 99]时矩阵扩为 24 项,seed_count=3时扩为 36 项,策略/尺寸均可单独过滤——这意味着管线可以按需组合出任意规模的测试矩阵。哨兵机制中,check_sentinel读取test_complete.json,存在则返回其内容(含success/error字段),不存在返回None;poll_for_sentinel在此基础上提供 60 秒默认超时。rgba_to_png则由screenshot.rgba原始像素缓冲转换为 PNG 输出。
run_test_pipeline是整个编排的入口:遍历组合矩阵 →swap_startup_scene切换启动场景 → 以run_single_combo逐个启动 s&box →collect_screenshot收集截图 →poll_for_sentinel等待引擎内测试完成哨兵 →cleanup_data_files清理test_config.json、screenshot.rgba、metadata.json、test_complete.json等中间产物。
6. test_exit_codes.py:20 项退出码契约测试
这组测试是本套件对 Agent 自动化最关键的贡献,其背景记录在文件 docstring 中:HKUDS/CLI-Anything PR #251 的评审反馈指出,handler 捕获异常后用_output_error()打印、随后正常返回,导致脚本和 Agent 把失败误判为成功。这组测试把契约钉死:一次性模式下任何失败命令必须以退出码 1 结束,成功路径保持退出码 0,REPL 模式则吸收退出让循环继续。
6.1 五个测试类
| 类 | 测试数 | 覆盖 |
|---|---|---|
TestOneShotFailureExitsNonZero | 11 | 失败路径返回退出码 1:场景/工程文件缺失(人读 + JSON 双模式)、本地化 key 缺失、必填参数缺失、--properties传入非法 JSON、无 s&box 安装时执行 asset compile、缺失文件上的 scene list(except Exception裸路径的真门禁)、损坏 JSON 上的 asset info(json_info.error表层)。含--help退出码 0 基线。 |
TestOneShotSuccessExitsZero | 2 | 确认退出码 1 改动没有回归成功路径 |
TestProjectValidateExitsNonZero | 2 | project validate在ok=False(坏引用)时退出 1 以便 CI 把关;干净工程仍退出 0 |
TestAuditedFailurePaths | 2 | monkeypatch 覆盖字典返回型失败路径:resourcecompiler 返回success=False的asset compile、get_sbox_version()带error字段的server info |
TestReplModeAbsorbsExit | 3 | _output_error在ctx.obj["repl"] = True时不调用sys.exit;键缺失时安全默认一次性模式 |
6.2 底层实现
契约的实现在 sbox_cli.py 的_output_error:JSON 模式在 stdout 输出{"error": message},人读模式在 stderr 输出Error: ...,随后仅当ctx.obj["repl"]为假时sys.exit(1)。集中式退出逻辑让每个命令的 try/except 保持简洁,同时统一传播失败。
TestAuditedFailurePaths之所以需要 monkeypatch,是因为asset compile失败(resourcecompiler 返回success=False)和server info版本读取失败(返回error字段)这两条路径返回的是字典而非抛异常,标准的except Exception路径抓不到;审计补上了显式 success/error 检查,测试用CliRunner + monkeypatch组合把该契约钉死(断言输出含 "Resource compilation failed" 与 "Failed to read s&box version")。
6.3 子进程测试的可移植性
测试用python -m cli_anything.sbox而非安装后的cli-anything-sbox二进制,是为了在启动脚本行为可能不同的环境中保持可移植(如 Windows + Python 3.14 的原生 launcher 怪癖);_run辅助函数统一传入stdin=subprocess.DEVNULL规避平台句柄问题(详见第 8 节)。
7. 按源码模块的覆盖矩阵
TEST.md 给出了模块维度的三层覆盖(单元 / E2E / 子进程),此处是理解测试"密度"分布的关键:
| 模块 | 单元 | E2E | 子进程 |
|---|---|---|---|
| core/project.py | 7 + 4 packages | 2(创建、塔防) | 1(project new) |
| core/scene.py | 50(12+9+3+4+6+4+2+5+5) | 3(操作、塔防、工作流) | 6(query、refs、bulk-modify×2、diff×2、instantiate) |
| core/prefab.py | 14(4+2+1+5+2) | 1(from_scene) | 3(refs、modify-component、diff) |
| core/codegen.py | 22(10+5+2+5) | 1(codegen 工作流) | 3(panel-component 普通 + scene 追加) |
| core/input_config.py | 6 | 2(配置工作流、塔防) | 1(input list) |
| core/collision_config.py | 6(5+1) | 2(配置工作流、塔防) | 1(collision list) |
| core/material.py | 7(5+2) | - | - |
| core/sound.py | 6(4+2) | - | - |
| core/localization.py | 6(5+1) | - | - |
| core/session.py | 5 | - | - |
| core/export.py | 15(3+5+7) | - | 5(find-refs、find-unused、rename、rename-dry-run、move) |
| core/validate.py | 4 | - | 2(validate 干净、validate 损坏) |
| core/test_orchestrator.py | 17(整文件) | - | - |
| utils/sbox_backend.py | - | 3(安装、版本、可执行文件,无 s&box 时跳过) | - |
| sbox_cli.py(CLI) | - | - | 40(TestCLISubprocess 全部)+ 20(退出码契约 + audited 路径 monkeypatch + REPL 吸收) |
这张矩阵揭示了一个清晰的分层策略:纯文件逻辑(material/sound/localization/session/export/validate)以单元测试为主,涉及引擎进程(sbox_backend、resourcecompiler)的部分以可跳过的 E2E 或 help-smoke 为主,而 CLI 表层则由 40 项子进程测试全量把守。
8. E2E 前置条件与已知测试环境怪癖
8.1 E2E 前置条件
TestCLISubprocess(40 项)与TestE2EProjectWorkflow(7 项)在无 s&box 安装时即可运行——它们针对内存态或临时目录的项目状态执行 CLI。TestE2EBackend(3 项)要求 s&box 可被发现:标准 Steam 安装无需任何操作;非标准位置需export SBOX_PATH=/path/to/sbox;未安装则带原因说明跳过。
8.2 已知怪癖
- Windows + Python 3.14 + pytest 的句柄问题:
subprocess.Popen在子进程启动前可能抛OSError: [WinError 6] The handle is invalid,原因是父进程 stdin 句柄不可继承。test_full_e2e.py 的TestCLISubprocess._run与 test_exit_codes.py 的_run都通过stdin=subprocess.DEVNULL规避。新增子进程调用时必须照此模式。 - 显式 UTF-8:所有文件 I/O 均显式指定
open(..., encoding="utf-8")。早期版本在 Windows 上默认平台编码,提交上游前已修复——这也是所有测试断言文件内容时统一使用 UTF-8 的原因。
9. 覆盖缺口:诚实声明的边界
TEST.md 明确列出了三处未覆盖区域,理解它们有助于正确解读测试结论:
server start与launch命令仅经 help 文本冒烟测试;真正启动 s&box 服务端/编辑器进程超出范围(需要活网络套接字与运行中的游戏)。session undo/redo仅在单元层测试;没有 E2E 工作流在"编辑-撤销-再编辑"的真实序列中走一遍撤销日志。asset compile仅 help 测试;调用真实resourcecompiler.exe需要完整 s&box 安装,留给上游维护者的 CI。缺失/失败编译的非零退出码路径由test_exit_codes.py::test_asset_compile_missing_sbox_exits_one覆盖。
10. 总结:这套测试教你如何为 Agent 原生 CLI 设计质量门槛
从 s&box harness 的 244 项测试可以提炼出可复用的测试设计模式:
- 三层粒度:纯单元(fast、无外部依赖)→ 子进程级 CLI 冒烟(验证命令真实可执行且 JSON 可解析)→ 引擎集成(条件不满足时干净跳过),让不同 CI 环境各取所需;
- 退出码即契约:一次性模式下失败必须
exit 1(JSON 错误对象在 stdout、人类可读错误在 stderr),成功保持exit 0,REPL 模式吸收退出保持交互循环——这正是 Agent 判断命令成败、决定是否重试的根基; - 字典返回型失败路径单独审计:不抛异常而返回
{"success": False}或{"error": ...}的路径不会被except Exception捕获,必须显式检查并用 monkeypatch 测试钉死; - 矩阵化编排:地图测试用策略×尺寸×种子的组合矩阵 + 文件哨兵轮询,把引擎内测试的结果以
test_complete.json的方式异步回传,规避了进程内同步等待的脆弱性; - 对平台怪癖给出可操作处方:
stdin=DEVNULL、显式 UTF-8,避免"能跑但不能移植"的隐性债务。
围绕 TEST.md 记载的清单,配合 sbox_cli.py 的实现与 README.md 的命令手册,读者既可以从零跑通整套测试,也能深入理解每条契约背后的工程动机。
【免费下载链接】CLI-Anything"CLI-Anything: Making ALL Software Agent-Native" -- CLI-Hub: https://clianything.cc/项目地址: https://gitcode.com/GitHub_Trending/cl/CLI-Anything
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考