186 lines
5.2 KiB
Python
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)
|