mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
237 lines
11 KiB
Python
237 lines
11 KiB
Python
"""从真实命令工具入口验证统一启动目录、解释器及跨分片文本输入输出。"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shlex
|
|
import sys
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from app.agent.loader import close_materialized_terminal_sessions
|
|
from app.agent.terminal import manager as terminal_module
|
|
from app.agent.terminal.manager import _TerminalSessionManager
|
|
from app.agent.tools.impl import execute_command as command_module
|
|
from app.agent.tools.impl.execute_command import ExecuteCommandTool, _CommandOutput
|
|
|
|
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
|
|
|
|
|
def _python(code: str) -> str:
|
|
"""使用当前解释器和逐参数转义,真实程序仅接触临时目录及标准流。"""
|
|
return shlex.join([sys.executable, "-c", code])
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def command_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[tuple[ExecuteCommandTool, Path]]:
|
|
"""父进程 cwd 与项目根明确分离,所有后台进程由独立 manager 统一回收。"""
|
|
root_dir, parent_dir = tmp_path / "project", tmp_path / "parent"
|
|
root_dir.mkdir()
|
|
parent_dir.mkdir()
|
|
(root_dir / "relative").mkdir()
|
|
monkeypatch.chdir(parent_dir)
|
|
original_setting = command_module.get_runtime_setting
|
|
|
|
def setting(name: str) -> Any:
|
|
"""只替换目录配置,其他运行契约仍走测试已经隔离的宿主配置。"""
|
|
return root_dir if name == "ROOT_PATH" else original_setting(name)
|
|
|
|
manager = _TerminalSessionManager()
|
|
monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager)
|
|
monkeypatch.setattr(command_module, "get_runtime_setting", setting)
|
|
monkeypatch.setattr(terminal_module, "get_runtime_setting", setting)
|
|
monkeypatch.setattr(command_module, "_command_semaphore", asyncio.Semaphore(2))
|
|
tool = ExecuteCommandTool(session_id="command-io", user_id="command-io-owner")
|
|
try:
|
|
yield tool, root_dir
|
|
finally:
|
|
await manager.close()
|
|
|
|
|
|
async def _finish(tool: ExecuteCommandTool, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""持续按已消费字节游标读取,直到进程与读取器都收尾。"""
|
|
chunks = [payload["output"]]
|
|
async with asyncio.timeout(5):
|
|
while not (payload["output_complete"] and payload["output_until_seq"] == payload["last_seq"]
|
|
and payload["output_until_offset"] == 0):
|
|
payload = json.loads(await tool.run(
|
|
action="wait", session_id=payload["session_id"], timeout_ms=1000,
|
|
since_seq=payload["output_until_seq"], since_offset=payload["output_until_offset"],
|
|
))
|
|
chunks.append(payload["output"])
|
|
return {**payload, "output": "".join(chunks)}
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 解释器与 PTY 合同")
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["run", "pipe", "pty"])
|
|
@pytest.mark.parametrize("directory", ["default", "relative", "absolute"])
|
|
async def test_all_launch_modes_share_directory_shell_and_environment(command_environment, mode: str, directory: str) -> None:
|
|
"""相同参数不能因为是否保留会话或使用 PTY 而切换目录、解释器或环境。"""
|
|
tool, root_dir = command_environment
|
|
cwd = {"default": None, "relative": "relative", "absolute": str(root_dir / "relative")}[directory]
|
|
expected_cwd = root_dir if directory == "default" else root_dir / "relative"
|
|
code = "import os; print('DIRECTORY=' + os.getcwd()); print('MARKER=' + os.environ['MOVIEPILOT_IO_PROBE'])"
|
|
payload = json.loads(await tool.run(
|
|
action="run" if mode == "run" else "start", command=_python(code), cwd=cwd,
|
|
env={"SHELL": "/bin/sh", "MOVIEPILOT_IO_PROBE": "launch-value"},
|
|
use_pty=mode == "pty", timeout=5, since_offset=0,
|
|
))
|
|
if mode != "run":
|
|
payload = await _finish(tool, payload)
|
|
assert payload["execution_outcome"] == "succeeded"
|
|
assert payload["cwd"] == str(expected_cwd.resolve())
|
|
assert Path(payload["shell"]).resolve() == Path("/bin/sh").resolve()
|
|
assert payload["login"] is False
|
|
assert f"DIRECTORY={expected_cwd.resolve()}" in payload["output"]
|
|
assert "MARKER=launch-value" in payload["output"]
|
|
assert "MOVIEPILOT_IO_PROBE" not in os.environ
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 显式解释器合同")
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["run", "pipe", "pty"])
|
|
async def test_explicit_shell_wins_over_environment_without_implicit_login(command_environment, mode: str) -> None:
|
|
"""显式 bash 能运行其语言特性,不能被 env.SHELL 或 PTY 的旧登录路径替换。"""
|
|
tool, _ = command_environment
|
|
payload = json.loads(await tool.run(
|
|
action="run" if mode == "run" else "start", command="items=(one two); printf 'ARRAY:%s\\n' \"${items[1]}\"",
|
|
env={"SHELL": "/bin/sh"}, shell="/bin/bash", login=False, use_pty=mode == "pty", since_offset=0,
|
|
))
|
|
if mode != "run":
|
|
payload = await _finish(tool, payload)
|
|
assert payload["exit_code"] == 0
|
|
assert "ARRAY:two" in payload["output"]
|
|
assert Path(payload["shell"]).resolve() == Path("/bin/bash").resolve()
|
|
assert payload["login"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["run", "start"])
|
|
async def test_invalid_directory_is_rejected_before_process_creation(command_environment, monkeypatch, mode: str) -> None:
|
|
"""目录参数错误必须保持零进程,不允许先启动后再用回包补救。"""
|
|
tool, root_dir = command_environment
|
|
create = AsyncMock(side_effect=AssertionError("invalid cwd created a process"))
|
|
monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", create)
|
|
payload = json.loads(await tool.run(action=mode, command="echo never", cwd=str(root_dir / "absent"), use_pty=False))
|
|
assert payload["success"] is False and payload["execution_outcome"] == "failed"
|
|
create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("ending", [b"\xad", b""])
|
|
async def test_run_decodes_split_utf8_at_eof_without_replacing_complete_characters(ending: bytes) -> None:
|
|
"""Event 固定两次真实 StreamReader 读取边界,完整字符拼接和末尾残缺各有明确结果。"""
|
|
reader = asyncio.StreamReader()
|
|
first_read = asyncio.Event()
|
|
original_read = reader.read
|
|
|
|
async def read(size: int) -> bytes:
|
|
"""读取器消费首段后才允许测试送第二段,排除同批合并造成的空过。"""
|
|
data = await original_read(size)
|
|
if data == b"\xe4\xb8":
|
|
first_read.set()
|
|
return data
|
|
|
|
reader.read = read
|
|
output = _CommandOutput(preview_limit_bytes=1024)
|
|
task = asyncio.create_task(ExecuteCommandTool._read_stream(reader, "stdout", output))
|
|
try:
|
|
reader.feed_data(b"\xe4\xb8")
|
|
await asyncio.wait_for(first_read.wait(), timeout=2)
|
|
assert output.stdout == ""
|
|
if ending:
|
|
reader.feed_data(ending)
|
|
reader.feed_eof()
|
|
await asyncio.wait_for(task, timeout=2)
|
|
assert output.stdout == ("中" if ending else "\ufffd")
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
output.close()
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX shell 与 pipe EOF")
|
|
@pytest.mark.asyncio
|
|
async def test_tool_write_close_stdin_delivers_final_input_and_retains_output(command_environment) -> None:
|
|
"""模型通过工具写末段和 EOF 后仍能读取最终计算结果与退出状态。"""
|
|
tool, _ = command_environment
|
|
initial = json.loads(await tool.run(
|
|
action="start", command=_python("import sys; data=sys.stdin.read(); print('RECEIVED=' + data, flush=True)"),
|
|
env={"SHELL": "/bin/sh"}, use_pty=False, since_offset=0, yield_time_ms=0,
|
|
))
|
|
closed = json.loads(await tool.run(action="write", session_id=initial["session_id"], input_text="完整输入", close_stdin=True,
|
|
since_seq=0, since_offset=0))
|
|
assert closed["stdin_closed"] is True
|
|
assert closed["written_bytes"] == len("完整输入".encode("utf-8"))
|
|
final = await _finish(tool, closed)
|
|
assert final["execution_outcome"] == "succeeded" and final["output_complete"] is True
|
|
assert "RECEIVED=完整输入" in final["output"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_interrupt_routes_to_one_shot_control_without_kill(monkeypatch) -> None:
|
|
"""新工具动作必须调用独立中断路径,不能复用会升级强杀的 kill。"""
|
|
manager = _TerminalSessionManager()
|
|
manager.interrupt = AsyncMock(return_value={"signal_sent": True, "signal": "SIGINT"})
|
|
manager.kill = AsyncMock(side_effect=AssertionError("interrupt called kill"))
|
|
monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager)
|
|
result = json.loads(await ExecuteCommandTool(session_id="io", user_id="owner").run(action="interrupt", session_id="term-test"))
|
|
assert result["signal_sent"] is True
|
|
manager.interrupt.assert_awaited_once()
|
|
manager.kill.assert_not_awaited()
|
|
|
|
|
|
def test_run_preview_boundary_keeps_valid_unicode_and_complete_archive(tmp_path: Path, monkeypatch) -> None:
|
|
"""预览字节限额切到完整字符中间时不制造替换字,原始字符仍可在尾部和归档读取。"""
|
|
original = command_module.NamedTemporaryFile
|
|
|
|
def archive(**kwargs: Any) -> Any:
|
|
"""把完整输出归档限制在当前测试目录。"""
|
|
return original(dir=tmp_path, **kwargs)
|
|
|
|
monkeypatch.setattr(command_module, "NamedTemporaryFile", archive)
|
|
output = _CommandOutput(preview_limit_bytes=2)
|
|
try:
|
|
output.append("stdout", "中文")
|
|
output.close()
|
|
assert output.preview_truncated is True
|
|
assert output.captured_bytes <= 2
|
|
assert "\ufffd" not in output.combined_preview
|
|
assert "中文" in output.combined_preview
|
|
assert "中文" in Path(output.temp_file_path).read_text(encoding="utf-8")
|
|
finally:
|
|
output.close()
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="真实 POSIX 管道关停")
|
|
@pytest.mark.asyncio
|
|
async def test_lazy_shutdown_closes_the_materialized_terminal_package_owner(command_environment, monkeypatch) -> None:
|
|
"""模块迁移后宿主关停仍必须找到实际 manager 并回收已启动进程。"""
|
|
tool, _ = command_environment
|
|
manager = command_module.get_terminal_session_manager()
|
|
monkeypatch.setattr(terminal_module, "terminal_session_manager", manager)
|
|
payload = json.loads(await tool.run(
|
|
action="start", command=_python("print('READY', flush=True); input()"), use_pty=False,
|
|
env={"SHELL": "/bin/sh"}, since_offset=0,
|
|
))
|
|
session = manager._sessions[payload["session_id"]]
|
|
assert session.process is not None and session.process.returncode is None
|
|
await close_materialized_terminal_sessions()
|
|
assert manager._closed is True and manager._sessions == {}
|
|
assert session.process.returncode is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lazy_shutdown_does_not_import_an_unmaterialized_terminal_manager(monkeypatch) -> None:
|
|
"""未使用命令工具时,关停查表不能顺带创建新的终端管理器。"""
|
|
monkeypatch.delitem(sys.modules, "app.agent.terminal.manager", raising=False)
|
|
await close_materialized_terminal_sessions()
|
|
assert "app.agent.terminal.manager" not in sys.modules
|