| """Unit tests for the Daytona cloud sandbox environment backend.""" |
|
|
| import threading |
| from types import SimpleNamespace |
| from unittest.mock import MagicMock, patch, PropertyMock |
|
|
| import pytest |
|
|
|
|
| |
| |
| |
|
|
| def _make_exec_response(result="", exit_code=0): |
| return SimpleNamespace(result=result, exit_code=exit_code) |
|
|
|
|
| def _make_sandbox(sandbox_id="sb-123", state="started"): |
| sb = MagicMock() |
| sb.id = sandbox_id |
| sb.state = state |
| sb.process.exec.return_value = _make_exec_response() |
| return sb |
|
|
|
|
| def _patch_daytona_imports(monkeypatch): |
| """Patch the daytona SDK so DaytonaEnvironment can be imported without it.""" |
| import types as _types |
|
|
| import enum |
|
|
| class _SandboxState(str, enum.Enum): |
| STARTED = "started" |
| STOPPED = "stopped" |
| ARCHIVED = "archived" |
| ERROR = "error" |
|
|
| daytona_mod = _types.ModuleType("daytona") |
| daytona_mod.Daytona = MagicMock |
| daytona_mod.CreateSandboxFromImageParams = MagicMock |
| daytona_mod.DaytonaError = type("DaytonaError", (Exception,), {}) |
| daytona_mod.Resources = MagicMock(name="Resources") |
| daytona_mod.SandboxState = _SandboxState |
|
|
| monkeypatch.setitem(__import__("sys").modules, "daytona", daytona_mod) |
| return daytona_mod |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture() |
| def daytona_sdk(monkeypatch): |
| """Provide a mock daytona SDK module and return it for assertions.""" |
| return _patch_daytona_imports(monkeypatch) |
|
|
|
|
| @pytest.fixture() |
| def make_env(daytona_sdk, monkeypatch): |
| """Factory that creates a DaytonaEnvironment with a mocked SDK.""" |
| |
| monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) |
|
|
| def _factory( |
| sandbox=None, |
| get_side_effect=None, |
| list_return=None, |
| home_dir="/root", |
| persistent=True, |
| **kwargs, |
| ): |
| sandbox = sandbox or _make_sandbox() |
| |
| sandbox.process.exec.return_value = _make_exec_response(result=home_dir) |
|
|
| mock_client = MagicMock() |
| mock_client.create.return_value = sandbox |
|
|
| if get_side_effect is not None: |
| mock_client.get.side_effect = get_side_effect |
| else: |
| |
| mock_client.get.side_effect = daytona_sdk.DaytonaError("not found") |
|
|
| |
| if list_return is not None: |
| mock_client.list.return_value = list_return |
| else: |
| mock_client.list.return_value = SimpleNamespace(items=[]) |
|
|
| daytona_sdk.Daytona = MagicMock(return_value=mock_client) |
|
|
| from tools.environments.daytona import DaytonaEnvironment |
|
|
| kwargs.setdefault("disk", 10240) |
| env = DaytonaEnvironment( |
| image="test-image:latest", |
| persistent_filesystem=persistent, |
| **kwargs, |
| ) |
| env._mock_client = mock_client |
| return env |
|
|
| return _factory |
|
|
|
|
| |
| |
| |
|
|
| class TestCwdResolution: |
| def test_default_cwd_resolves_home(self, make_env): |
| env = make_env(home_dir="/home/testuser") |
| assert env.cwd == "/home/testuser" |
|
|
| def test_tilde_cwd_resolves_home(self, make_env): |
| env = make_env(cwd="~", home_dir="/home/testuser") |
| assert env.cwd == "/home/testuser" |
|
|
| def test_explicit_cwd_not_overridden(self, make_env): |
| env = make_env(cwd="/workspace", home_dir="/root") |
| assert env.cwd == "/workspace" |
|
|
| def test_home_detection_failure_keeps_default_cwd(self, make_env): |
| sb = _make_sandbox() |
| sb.process.exec.side_effect = RuntimeError("exec failed") |
| env = make_env(sandbox=sb) |
| assert env.cwd == "/home/daytona" |
|
|
| def test_empty_home_keeps_default_cwd(self, make_env): |
| env = make_env(home_dir="") |
| assert env.cwd == "/home/daytona" |
|
|
|
|
| |
| |
| |
|
|
| class TestPersistence: |
| def test_persistent_resumes_via_get(self, make_env): |
| existing = _make_sandbox(sandbox_id="sb-existing") |
| existing.process.exec.return_value = _make_exec_response(result="/root") |
| env = make_env(get_side_effect=lambda name: existing, persistent=True, |
| task_id="mytask") |
| existing.start.assert_called_once() |
| env._mock_client.get.assert_called_once_with("hermes-mytask") |
| env._mock_client.create.assert_not_called() |
|
|
| def test_persistent_resumes_legacy_via_list(self, make_env, daytona_sdk): |
| legacy = _make_sandbox(sandbox_id="sb-legacy") |
| legacy.process.exec.return_value = _make_exec_response(result="/root") |
| env = make_env( |
| get_side_effect=daytona_sdk.DaytonaError("not found"), |
| list_return=SimpleNamespace(items=[legacy]), |
| persistent=True, |
| task_id="mytask", |
| ) |
| legacy.start.assert_called_once() |
| env._mock_client.list.assert_called_once_with( |
| labels={"hermes_task_id": "mytask"}, page=1, limit=1) |
| env._mock_client.create.assert_not_called() |
|
|
| def test_persistent_creates_new_when_none_found(self, make_env, daytona_sdk): |
| env = make_env( |
| get_side_effect=daytona_sdk.DaytonaError("not found"), |
| persistent=True, |
| task_id="mytask", |
| ) |
| env._mock_client.create.assert_called_once() |
| |
| |
| env._mock_client.get.assert_called_with("hermes-mytask") |
| env._mock_client.list.assert_called_with( |
| labels={"hermes_task_id": "mytask"}, page=1, limit=1) |
|
|
| def test_non_persistent_skips_lookup(self, make_env): |
| env = make_env(persistent=False) |
| env._mock_client.get.assert_not_called() |
| env._mock_client.list.assert_not_called() |
| env._mock_client.create.assert_called_once() |
|
|
|
|
| |
| |
| |
|
|
| class TestCleanup: |
| def test_persistent_cleanup_stops_sandbox(self, make_env): |
| env = make_env(persistent=True) |
| sb = env._sandbox |
| env.cleanup() |
| sb.stop.assert_called_once() |
|
|
| def test_non_persistent_cleanup_deletes_sandbox(self, make_env): |
| env = make_env(persistent=False) |
| sb = env._sandbox |
| env.cleanup() |
| env._mock_client.delete.assert_called_once_with(sb) |
|
|
| def test_cleanup_idempotent(self, make_env): |
| env = make_env(persistent=True) |
| env.cleanup() |
| env.cleanup() |
|
|
| def test_cleanup_swallows_errors(self, make_env): |
| env = make_env(persistent=True) |
| env._sandbox.stop.side_effect = RuntimeError("stop failed") |
| env.cleanup() |
| assert env._sandbox is None |
|
|
|
|
| |
| |
| |
|
|
| class TestExecute: |
| def test_basic_command(self, make_env): |
| sb = _make_sandbox() |
| |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| _make_exec_response(result="hello", exit_code=0), |
| ] |
| sb.state = "started" |
| env = make_env(sandbox=sb) |
|
|
| result = env.execute("echo hello") |
| assert result["output"] == "hello" |
| assert result["returncode"] == 0 |
|
|
| def test_command_wrapped_with_shell_timeout(self, make_env): |
| sb = _make_sandbox() |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| _make_exec_response(result="ok", exit_code=0), |
| ] |
| sb.state = "started" |
| env = make_env(sandbox=sb, timeout=42) |
|
|
| env.execute("echo hello") |
| |
| call_args = sb.process.exec.call_args_list[-1] |
| cmd = call_args[0][0] |
| assert cmd.startswith("timeout 42 sh -c ") |
| |
| assert "timeout" not in call_args[1] |
|
|
| def test_timeout_returns_exit_code_124(self, make_env): |
| """Shell timeout utility returns exit code 124.""" |
| sb = _make_sandbox() |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| _make_exec_response(result="", exit_code=124), |
| ] |
| sb.state = "started" |
| env = make_env(sandbox=sb) |
|
|
| result = env.execute("sleep 300", timeout=5) |
| assert result["returncode"] == 124 |
|
|
| def test_nonzero_exit_code(self, make_env): |
| sb = _make_sandbox() |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| _make_exec_response(result="not found", exit_code=127), |
| ] |
| sb.state = "started" |
| env = make_env(sandbox=sb) |
|
|
| result = env.execute("bad_cmd") |
| assert result["returncode"] == 127 |
|
|
| def test_stdin_data_wraps_heredoc(self, make_env): |
| sb = _make_sandbox() |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| _make_exec_response(result="ok", exit_code=0), |
| ] |
| sb.state = "started" |
| env = make_env(sandbox=sb) |
|
|
| env.execute("python3", stdin_data="print('hi')") |
| |
| |
| call_args = sb.process.exec.call_args_list[-1] |
| cmd = call_args[0][0] |
| assert "HERMES_EOF_" in cmd |
| assert "print" in cmd |
| assert "hi" in cmd |
|
|
| def test_custom_cwd_passed_through(self, make_env): |
| sb = _make_sandbox() |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| _make_exec_response(result="/tmp", exit_code=0), |
| ] |
| sb.state = "started" |
| env = make_env(sandbox=sb) |
|
|
| env.execute("pwd", cwd="/tmp") |
| call_kwargs = sb.process.exec.call_args_list[-1][1] |
| assert call_kwargs["cwd"] == "/tmp" |
|
|
| def test_daytona_error_triggers_retry(self, make_env, daytona_sdk): |
| sb = _make_sandbox() |
| sb.state = "started" |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| daytona_sdk.DaytonaError("transient"), |
| _make_exec_response(result="ok", exit_code=0), |
| ] |
| env = make_env(sandbox=sb) |
|
|
| result = env.execute("echo retry") |
| assert result["output"] == "ok" |
| assert result["returncode"] == 0 |
|
|
|
|
| |
| |
| |
|
|
| class TestResourceConversion: |
| def _get_resources_kwargs(self, daytona_sdk): |
| return daytona_sdk.Resources.call_args.kwargs |
|
|
| def test_memory_converted_to_gib(self, make_env, daytona_sdk): |
| env = make_env(memory=5120) |
| assert self._get_resources_kwargs(daytona_sdk)["memory"] == 5 |
|
|
| def test_disk_converted_to_gib(self, make_env, daytona_sdk): |
| env = make_env(disk=10240) |
| assert self._get_resources_kwargs(daytona_sdk)["disk"] == 10 |
|
|
| def test_small_values_clamped_to_1(self, make_env, daytona_sdk): |
| env = make_env(memory=100, disk=100) |
| kw = self._get_resources_kwargs(daytona_sdk) |
| assert kw["memory"] == 1 |
| assert kw["disk"] == 1 |
|
|
|
|
| |
| |
| |
|
|
| class TestInterrupt: |
| def test_interrupt_stops_sandbox_and_returns_130(self, make_env, monkeypatch): |
| sb = _make_sandbox() |
| sb.state = "started" |
| event = threading.Event() |
| calls = {"n": 0} |
|
|
| def exec_side_effect(*args, **kwargs): |
| calls["n"] += 1 |
| if calls["n"] == 1: |
| return _make_exec_response(result="/root") |
| event.wait(timeout=5) |
| return _make_exec_response(result="done", exit_code=0) |
|
|
| sb.process.exec.side_effect = exec_side_effect |
| env = make_env(sandbox=sb) |
|
|
| monkeypatch.setattr( |
| "tools.environments.daytona.is_interrupted", lambda: True |
| ) |
| try: |
| result = env.execute("sleep 10") |
| assert result["returncode"] == 130 |
| sb.stop.assert_called() |
| finally: |
| event.set() |
|
|
|
|
| |
| |
| |
|
|
| class TestRetryExhausted: |
| def test_both_attempts_fail(self, make_env, daytona_sdk): |
| sb = _make_sandbox() |
| sb.state = "started" |
| sb.process.exec.side_effect = [ |
| _make_exec_response(result="/root"), |
| daytona_sdk.DaytonaError("fail1"), |
| daytona_sdk.DaytonaError("fail2"), |
| ] |
| env = make_env(sandbox=sb) |
|
|
| result = env.execute("echo x") |
| assert result["returncode"] == 1 |
| assert "Daytona execution error" in result["output"] |
|
|
|
|
| |
| |
| |
|
|
| class TestEnsureSandboxReady: |
| def test_restarts_stopped_sandbox(self, make_env): |
| env = make_env() |
| env._sandbox.state = "stopped" |
| env._ensure_sandbox_ready() |
| env._sandbox.start.assert_called() |
|
|
| def test_no_restart_when_running(self, make_env): |
| env = make_env() |
| env._sandbox.state = "started" |
| env._ensure_sandbox_ready() |
| env._sandbox.start.assert_not_called() |
|
|