mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""Tests for ProcessSandboxService."""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import psutil
|
|
import pytest
|
|
|
|
from openhands.app_server.sandbox.process_sandbox_service import (
|
|
ProcessInfo,
|
|
ProcessSandboxService,
|
|
ProcessSandboxServiceInjector,
|
|
)
|
|
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
|
|
|
|
|
class MockSandboxSpec:
|
|
"""Mock sandbox specification."""
|
|
|
|
def __init__(self):
|
|
self.id = 'test-spec'
|
|
self.initial_env = {'TEST_VAR': 'test_value'}
|
|
self.plugins = []
|
|
|
|
|
|
class MockSandboxSpecService:
|
|
"""Mock sandbox spec service."""
|
|
|
|
async def get_default_sandbox_spec(self):
|
|
return MockSandboxSpec()
|
|
|
|
async def get_sandbox_spec(self, spec_id: str):
|
|
if spec_id == 'test-spec':
|
|
return MockSandboxSpec()
|
|
return None
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_httpx_client():
|
|
"""Mock httpx client."""
|
|
client = AsyncMock(spec=httpx.AsyncClient)
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir():
|
|
"""Create a temporary directory for testing."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield tmpdir
|
|
|
|
|
|
@pytest.fixture
|
|
def process_sandbox_service(mock_httpx_client, temp_dir):
|
|
"""Create a ProcessSandboxService instance for testing."""
|
|
return ProcessSandboxService(
|
|
user_id='test-user-id',
|
|
sandbox_spec_service=MockSandboxSpecService(),
|
|
base_working_dir=temp_dir,
|
|
base_port=9000,
|
|
python_executable='python',
|
|
agent_server_module='openhands.agent_server',
|
|
health_check_path='/alive',
|
|
httpx_client=mock_httpx_client,
|
|
)
|
|
|
|
|
|
class TestProcessSandboxService:
|
|
"""Test cases for ProcessSandboxService."""
|
|
|
|
def test_find_unused_port(self, process_sandbox_service):
|
|
"""Test finding an unused port."""
|
|
port = process_sandbox_service._find_unused_port()
|
|
assert port >= process_sandbox_service.base_port
|
|
assert port < process_sandbox_service.base_port + 10000
|
|
|
|
@patch('os.makedirs')
|
|
def test_create_sandbox_directory(self, mock_makedirs, process_sandbox_service):
|
|
"""Test creating a sandbox directory."""
|
|
sandbox_dir = process_sandbox_service._create_sandbox_directory('test-id')
|
|
|
|
expected_dir = os.path.join(process_sandbox_service.base_working_dir, 'test-id')
|
|
assert sandbox_dir == expected_dir
|
|
mock_makedirs.assert_called_once_with(expected_dir, exist_ok=True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_server_ready_success(self, process_sandbox_service):
|
|
"""Test waiting for server to be ready - success case."""
|
|
# Mock successful response
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {'status': 'ok'}
|
|
process_sandbox_service.httpx_client.get.return_value = mock_response
|
|
|
|
result = await process_sandbox_service._wait_for_server_ready(9000, timeout=1)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_server_ready_timeout(self, process_sandbox_service):
|
|
"""Test waiting for server to be ready - timeout case."""
|
|
# Mock failed response
|
|
process_sandbox_service.httpx_client.get.side_effect = Exception(
|
|
'Connection failed'
|
|
)
|
|
|
|
result = await process_sandbox_service._wait_for_server_ready(9000, timeout=1)
|
|
assert result is False
|
|
|
|
@patch('psutil.Process')
|
|
def test_get_process_status_running(
|
|
self, mock_process_class, process_sandbox_service
|
|
):
|
|
"""Test getting process status for running process."""
|
|
mock_process = MagicMock()
|
|
mock_process.is_running.return_value = True
|
|
mock_process.status.return_value = psutil.STATUS_RUNNING
|
|
mock_process_class.return_value = mock_process
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
status = process_sandbox_service._get_process_status(process_info)
|
|
assert status == SandboxStatus.RUNNING
|
|
|
|
@patch('psutil.Process')
|
|
def test_get_process_status_missing(
|
|
self, mock_process_class, process_sandbox_service
|
|
):
|
|
"""Test getting process status for missing process."""
|
|
import psutil
|
|
|
|
mock_process_class.side_effect = psutil.NoSuchProcess(1234)
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
status = process_sandbox_service._get_process_status(process_info)
|
|
assert status == SandboxStatus.MISSING
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_sandboxes_empty(self, process_sandbox_service):
|
|
"""Test searching sandboxes when none exist."""
|
|
result = await process_sandbox_service.search_sandboxes()
|
|
|
|
assert len(result.items) == 0
|
|
assert result.next_page_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_sandbox_not_found(self, process_sandbox_service):
|
|
"""Test getting a sandbox that doesn't exist."""
|
|
result = await process_sandbox_service.get_sandbox('nonexistent')
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_sandbox_not_found(self, process_sandbox_service):
|
|
"""Test resuming a sandbox that doesn't exist."""
|
|
result = await process_sandbox_service.resume_sandbox('nonexistent')
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_sandbox_not_found(self, process_sandbox_service):
|
|
"""Test pausing a sandbox that doesn't exist."""
|
|
result = await process_sandbox_service.pause_sandbox('nonexistent')
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_sandbox_not_found(self, process_sandbox_service):
|
|
"""Test deleting a sandbox that doesn't exist."""
|
|
result = await process_sandbox_service.delete_sandbox('nonexistent')
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_sandbox_with_sandbox_id(self, process_sandbox_service):
|
|
"""Test starting a sandbox with a specified sandbox_id."""
|
|
# Mock subprocess and waiting for server
|
|
with (
|
|
patch.object(
|
|
process_sandbox_service, '_start_agent_process'
|
|
) as mock_start_process,
|
|
patch.object(
|
|
process_sandbox_service, '_wait_for_server_ready', return_value=True
|
|
),
|
|
patch.object(
|
|
process_sandbox_service,
|
|
'_get_process_status',
|
|
return_value=SandboxStatus.RUNNING,
|
|
),
|
|
):
|
|
mock_process = MagicMock()
|
|
mock_process.pid = 1234
|
|
mock_start_process.return_value = mock_process
|
|
|
|
# Mock successful health check response
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
process_sandbox_service.httpx_client.get.return_value = mock_response
|
|
|
|
# Execute with custom sandbox_id
|
|
result = await process_sandbox_service.start_sandbox(
|
|
sandbox_id='custom_sandbox_id'
|
|
)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.id == 'custom_sandbox_id'
|
|
|
|
@patch('psutil.Process')
|
|
def test_get_process_status_paused(
|
|
self, mock_process_class, process_sandbox_service
|
|
):
|
|
"""Test getting process status for paused process."""
|
|
mock_process = MagicMock()
|
|
mock_process.is_running.return_value = True
|
|
mock_process.status.return_value = psutil.STATUS_STOPPED
|
|
mock_process_class.return_value = mock_process
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
status = process_sandbox_service._get_process_status(process_info)
|
|
assert status == SandboxStatus.PAUSED
|
|
|
|
@patch('psutil.Process')
|
|
def test_get_process_status_starting(
|
|
self, mock_process_class, process_sandbox_service
|
|
):
|
|
"""Test getting process status for starting process."""
|
|
mock_process = MagicMock()
|
|
mock_process.is_running.return_value = True
|
|
mock_process.status.return_value = psutil.STATUS_SLEEPING
|
|
mock_process_class.return_value = mock_process
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
status = process_sandbox_service._get_process_status(process_info)
|
|
assert status == SandboxStatus.STARTING
|
|
|
|
@patch('psutil.Process')
|
|
def test_get_process_status_access_denied(
|
|
self, mock_process_class, process_sandbox_service
|
|
):
|
|
"""Test getting process status when access is denied."""
|
|
mock_process_class.side_effect = psutil.AccessDenied(1234)
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
status = process_sandbox_service._get_process_status(process_info)
|
|
assert status == SandboxStatus.MISSING
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_to_sandbox_info_error_status(self, process_sandbox_service):
|
|
"""Test converting process info to sandbox info when server is not responding."""
|
|
# Mock a process that's running but server is not responding
|
|
with patch.object(
|
|
process_sandbox_service,
|
|
'_get_process_status',
|
|
return_value=SandboxStatus.RUNNING,
|
|
):
|
|
# Mock httpx client to return error response
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
process_sandbox_service.httpx_client.get.return_value = mock_response
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
sandbox_info = await process_sandbox_service._process_to_sandbox_info(
|
|
'test-sandbox', process_info
|
|
)
|
|
|
|
assert sandbox_info.status == SandboxStatus.ERROR
|
|
assert sandbox_info.session_api_key is None
|
|
assert sandbox_info.exposed_urls is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_to_sandbox_info_exception(self, process_sandbox_service):
|
|
"""Test converting process info to sandbox info when httpx raises exception."""
|
|
# Mock a process that's running but httpx raises exception
|
|
with patch.object(
|
|
process_sandbox_service,
|
|
'_get_process_status',
|
|
return_value=SandboxStatus.RUNNING,
|
|
):
|
|
# Mock httpx client to raise exception
|
|
process_sandbox_service.httpx_client.get.side_effect = Exception(
|
|
'Connection failed'
|
|
)
|
|
|
|
process_info = ProcessInfo(
|
|
pid=1234,
|
|
port=9000,
|
|
user_id='test-user-id',
|
|
working_dir='/tmp/test',
|
|
session_api_key='test-key',
|
|
created_at=datetime.now(),
|
|
sandbox_spec_id='test-spec',
|
|
)
|
|
|
|
sandbox_info = await process_sandbox_service._process_to_sandbox_info(
|
|
'test-sandbox', process_info
|
|
)
|
|
|
|
assert sandbox_info.status == SandboxStatus.ERROR
|
|
assert sandbox_info.session_api_key is None
|
|
assert sandbox_info.exposed_urls is None
|
|
|
|
|
|
class TestProcessSandboxServiceInjector:
|
|
"""Test cases for ProcessSandboxServiceInjector."""
|
|
|
|
def test_default_values(self):
|
|
"""Test default configuration values."""
|
|
injector = ProcessSandboxServiceInjector()
|
|
|
|
assert injector.base_working_dir == '/tmp/openhands-sandboxes'
|
|
assert injector.base_port == 8000
|
|
assert injector.health_check_path == '/alive'
|
|
assert injector.agent_server_module == 'openhands.agent_server'
|
|
|
|
def test_custom_values(self):
|
|
"""Test custom configuration values."""
|
|
injector = ProcessSandboxServiceInjector(
|
|
base_working_dir='/custom/path',
|
|
base_port=9000,
|
|
health_check_path='/health',
|
|
agent_server_module='custom.agent.module',
|
|
)
|
|
|
|
assert injector.base_working_dir == '/custom/path'
|
|
assert injector.base_port == 9000
|
|
assert injector.health_check_path == '/health'
|
|
assert injector.agent_server_module == 'custom.agent.module'
|