Files
2026-07-13 12:30:04 +08:00

186 lines
5.2 KiB
Python

import subprocess
import sys
import time
from unittest.mock import patch
import pytest
from agent.tools.bash.bash import Bash
posix_only = pytest.mark.skipif(Bash._IS_WIN, reason="POSIX shell command")
windows_only = pytest.mark.skipif(not Bash._IS_WIN, reason="Windows integration test")
def _windows_pid_command(pid_file):
path = str(pid_file).replace("\\", "\\\\")
return (
f'"{sys.executable}" -c '
f'"import os,time;open(r\'{path}\',\'w\').write(str(os.getpid()));time.sleep(30)"'
)
def _windows_pid_is_running(pid):
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
errors="replace",
)
return f'"{pid}"' in result.stdout
def _windows_kill_pid_file(pid_file):
if pid_file.exists():
subprocess.run(
["taskkill", "/F", "/PID", pid_file.read_text()],
capture_output=True,
)
@posix_only
def test_fast_command_returns_output_without_progress(tmp_path):
tool = Bash({"cwd": str(tmp_path)})
progress = []
tool.progress_callback = progress.append
result = tool.execute({"command": "printf fast"})
assert result.status == "success"
assert result.result["output"] == "fast"
assert progress == []
@posix_only
def test_timeout_returns_promptly(tmp_path):
tool = Bash({"cwd": str(tmp_path)})
started = time.monotonic()
result = tool.execute({"command": "sleep 10", "timeout": 1})
assert result.status == "error"
assert "timed out after 1 seconds" in result.result
assert time.monotonic() - started < 3
@posix_only
def test_background_process_holding_pipe_does_not_hang(tmp_path):
tool = Bash({"cwd": str(tmp_path)})
started = time.monotonic()
result = tool.execute({"command": "sleep 10 & printf done", "timeout": 3})
assert result.status == "success"
assert result.result["output"] == "done"
assert time.monotonic() - started < 7
@posix_only
def test_output_without_trailing_newline_streams_progress(tmp_path):
tool = Bash({"cwd": str(tmp_path)})
progress = []
tool.progress_callback = progress.append
result = tool.execute({
"command": "for i in 1 2 3 4 5; do printf .; sleep 0.3; done",
"timeout": 5,
})
assert result.status == "success"
assert result.result["output"] == "....."
assert progress
assert progress[-1].endswith(".")
def test_windows_kill_uses_taskkill_for_process_tree():
tool = Bash()
process = type("Process", (), {"pid": 1234, "poll": lambda self: None, "kill": lambda self: None})()
with patch.object(tool, "_IS_WIN", True), patch.object(subprocess, "run") as run:
run.return_value.returncode = 0
tool._kill_process(process)
run.assert_called_once_with(
["taskkill", "/F", "/T", "/PID", "1234"],
capture_output=True,
timeout=5,
)
def test_windows_kill_falls_back_when_taskkill_fails():
tool = Bash()
process = type("Process", (), {"pid": 1234, "poll": lambda self: None, "kill": lambda self: None})()
with patch.object(tool, "_IS_WIN", True), \
patch.object(subprocess, "run", side_effect=OSError), \
patch.object(process, "kill") as kill:
tool._kill_process(process)
kill.assert_called_once()
@windows_only
def test_windows_timeout_kills_process_tree(tmp_path):
pid_file = tmp_path / "timeout-child.pid"
child_command = _windows_pid_command(pid_file)
tool = Bash({"cwd": str(tmp_path)})
started = time.monotonic()
try:
result = tool.execute({
"command": f'start "" /b {child_command} & ping -n 30 127.0.0.1 >nul',
"timeout": 1,
})
assert result.status == "error"
assert "timed out after 1 seconds" in result.result
assert time.monotonic() - started < 8
assert pid_file.exists()
time.sleep(0.5)
assert not _windows_pid_is_running(pid_file.read_text())
finally:
_windows_kill_pid_file(pid_file)
@windows_only
def test_windows_long_running_command_streams_progress(tmp_path):
tool = Bash({"cwd": str(tmp_path)})
progress = []
tool.progress_callback = progress.append
result = tool.execute({
"command": (
f'"{sys.executable}" -u -c '
'"import sys,time; '
"[(sys.stdout.write('.'),sys.stdout.flush(),time.sleep(0.3)) "
'for _ in range(5)]"'
),
"timeout": 5,
})
assert result.status == "success"
assert result.result["output"] == "....."
assert progress
assert progress[-1].endswith(".")
@windows_only
def test_windows_background_process_holding_pipe_does_not_hang(tmp_path):
pid_file = tmp_path / "background-child.pid"
child_command = _windows_pid_command(pid_file)
tool = Bash({"cwd": str(tmp_path)})
started = time.monotonic()
try:
result = tool.execute({
"command": f'start "" /b {child_command} & echo done',
"timeout": 3,
})
assert result.status == "success"
assert result.result["output"].strip() == "done"
assert time.monotonic() - started < 8
assert pid_file.exists()
finally:
_windows_kill_pid_file(pid_file)