A 批(tools/basic_tools.py):迁移 7 个无外部依赖工具类(FileToolBase/ReadFileTool/WriteFileTool/ListFilesTool/SimpleWebCrawlerTool/GetCurrentTimeTool/CalculatorTool) Wave1(subsystems/):迁移 5 个叶子子系统(CommandExecutor/SubprocessCommandExecutor/PromptManager/MemoryDatabase/MemoDatabase/SkillManager),aibox.py 减少 ~1440 行 新增 tests/ 单测 113 个用例全绿,依赖方向维持 common -> subsystems -> tools -> aibox 单向
189 lines
5.7 KiB
Python
189 lines
5.7 KiB
Python
"""subsystems.executors 单元测试
|
|
|
|
覆盖:
|
|
- CommandExecutor 抽象基类契约
|
|
- SubprocessCommandExecutor 命令执行/流式/超时/取消/输出限制
|
|
"""
|
|
import queue
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from subsystems import CommandExecutor, SubprocessCommandExecutor
|
|
from common import constants
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def use_tmp_workspace(tmp_path, monkeypatch):
|
|
"""每个测试把 WORKSPACE_DIR 重定向到临时目录(并预先创建),避免默认 ~/.aibox/workspace 不存在。"""
|
|
ws = tmp_path / "ws"
|
|
ws.mkdir()
|
|
monkeypatch.setattr(type(constants), "WORKSPACE_DIR",
|
|
property(lambda self: str(ws)))
|
|
|
|
|
|
# ==================== 抽象基类契约 ====================
|
|
|
|
def test_command_executor_is_abstract():
|
|
"""CommandExecutor 是 ABC,不能直接实例化。"""
|
|
with pytest.raises(TypeError):
|
|
CommandExecutor()
|
|
|
|
|
|
def test_subprocess_executor_is_subclass():
|
|
assert issubclass(SubprocessCommandExecutor, CommandExecutor)
|
|
|
|
|
|
def test_executor_is_enabled_reflects_constants():
|
|
"""is_enabled 应反映 constants.COMMANDS_ENABLED。"""
|
|
from common import constants
|
|
ex = SubprocessCommandExecutor()
|
|
assert ex.is_enabled() == constants.COMMANDS_ENABLED
|
|
|
|
|
|
def test_executor_format_result_success():
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.format_result(True, "ok", {"k": "v"})
|
|
assert r.startswith("✅ ok")
|
|
assert "k: v" in r
|
|
|
|
|
|
def test_executor_format_result_failure():
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.format_result(False, "fail")
|
|
assert r.startswith("❌ fail")
|
|
|
|
|
|
def test_executor_format_result_multiline_value():
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.format_result(True, "ok", {"输出": "line1\nline2"})
|
|
assert "line1" in r and "line2" in r
|
|
|
|
|
|
# ==================== 实际命令执行 ====================
|
|
|
|
def test_execute_echo_command():
|
|
"""执行 echo 命令应返回成功结果。"""
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.execute("echo hello_test_123", timeout=5)
|
|
assert "hello_test_123" in r
|
|
assert "✅" in r
|
|
|
|
|
|
def test_execute_command_returns_exit_code():
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.execute("exit 0", timeout=5)
|
|
assert "返回码: 0" in r
|
|
|
|
|
|
def test_execute_failing_command_returns_nonzero_code():
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.execute("exit 7", timeout=5)
|
|
assert "返回码: 7" in r
|
|
# 非零返回码应标记为失败
|
|
assert "❌" in r
|
|
|
|
|
|
def test_execute_timeout():
|
|
"""超时应被检测并返回错误信息。"""
|
|
ex = SubprocessCommandExecutor()
|
|
# ping -t 在 Windows 上无限循环;用 ping -n 跨平台
|
|
r = ex.execute("ping -n 100 127.0.0.1", timeout=2)
|
|
assert "超时" in r
|
|
|
|
|
|
def test_execute_unknown_command_returns_error():
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.execute("nonexistent_command_xyz_123", timeout=5)
|
|
# shell=True 下会返回 127 或类似错误码,但不会抛异常
|
|
assert "返回码" in r
|
|
|
|
|
|
# ==================== 流式执行 ====================
|
|
|
|
def test_execute_streaming_emits_lines_and_complete():
|
|
ex = SubprocessCommandExecutor()
|
|
q: queue.Queue = queue.Queue()
|
|
ex.execute_streaming("echo stream_line_test", q, timeout=5)
|
|
|
|
msgs = []
|
|
while not q.empty():
|
|
msgs.append(q.get_nowait())
|
|
|
|
types = [m[0] for m in msgs]
|
|
assert "line" in types
|
|
assert "complete" in types
|
|
|
|
complete_payload = [m[1] for m in msgs if m[0] == "complete"][0]
|
|
assert "stream_line_test" in complete_payload
|
|
|
|
|
|
def test_execute_streaming_emits_command_header():
|
|
"""流式执行开头应输出命令/超时/工作目录等头部信息。"""
|
|
ex = SubprocessCommandExecutor()
|
|
q: queue.Queue = queue.Queue()
|
|
ex.execute_streaming("echo hi", q, timeout=5)
|
|
|
|
lines = []
|
|
while not q.empty():
|
|
t, p = q.get_nowait()
|
|
if t == "line":
|
|
lines.append(p)
|
|
|
|
header = "".join(lines[:6])
|
|
assert "执行命令" in header
|
|
assert "超时" in header
|
|
assert "工作目录" in header
|
|
|
|
|
|
# ==================== 取消机制 ====================
|
|
|
|
def test_cancel_terminates_running_process():
|
|
"""cancel() 在进程运行时调用应不抛异常并尝试 kill 进程。
|
|
Windows 下 kill 后管道读取可能短暂阻塞,但 cancel 本身必须安全返回。
|
|
线程设为 daemon,确保不会卡住测试进程退出。
|
|
"""
|
|
ex = SubprocessCommandExecutor()
|
|
q: queue.Queue = queue.Queue()
|
|
import threading
|
|
t = threading.Thread(
|
|
target=ex.execute_streaming,
|
|
args=("ping -n 100 127.0.0.1", q),
|
|
kwargs={"timeout": 60},
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
time.sleep(0.5) # 等进程启动
|
|
# 关键断言:cancel 不抛异常(若抛异常测试即失败)
|
|
ex.cancel()
|
|
# 等待线程退出(Windows 下可能因管道阻塞超时,daemon 确保不卡死主进程)
|
|
t.join(timeout=5)
|
|
|
|
|
|
def test_cancel_when_no_process_running_is_noop():
|
|
"""无进程运行时调用 cancel 应是空操作。"""
|
|
ex = SubprocessCommandExecutor()
|
|
ex.cancel() # 不应抛异常
|
|
|
|
|
|
# ==================== 输出大小限制 ====================
|
|
|
|
def test_output_size_limit_truncates():
|
|
"""输出超过 1MB 应被截断。"""
|
|
ex = SubprocessCommandExecutor()
|
|
# 生成约 2MB 输出
|
|
r = ex.execute("python -c \"print('x'*2048576)\"", timeout=10)
|
|
assert "截断" in r or "❌" in r
|
|
|
|
|
|
# ==================== 禁用场景 ====================
|
|
|
|
def test_execute_when_disabled_returns_disabled_message(monkeypatch):
|
|
"""当 COMMANDS_ENABLED=False 时应返回禁用提示。"""
|
|
from common import constants
|
|
monkeypatch.setattr(type(constants), "COMMANDS_ENABLED",
|
|
property(lambda self: False))
|
|
ex = SubprocessCommandExecutor()
|
|
r = ex.execute("echo hi", timeout=5)
|
|
assert "禁用" in r
|