ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
243 lines
7.1 KiB
Python
243 lines
7.1 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for DaytonaEnvironment."""
|
|
|
|
from unittest import mock
|
|
|
|
import daytona
|
|
from daytona import CreateSandboxFromImageParams
|
|
from daytona import CreateSandboxFromSnapshotParams
|
|
from daytona import DaytonaError
|
|
from daytona import DaytonaNotFoundError
|
|
from google.adk.integrations.daytona._daytona_environment import DaytonaEnvironment
|
|
import pytest
|
|
|
|
|
|
def _make_sandbox() -> mock.MagicMock:
|
|
"""Build a mock AsyncSandbox with async method stubs."""
|
|
sandbox = mock.MagicMock(name="AsyncSandbox")
|
|
sandbox.delete = mock.AsyncMock()
|
|
sandbox.process.exec = mock.AsyncMock()
|
|
sandbox.fs.download_file = mock.AsyncMock()
|
|
sandbox.fs.upload_file = mock.AsyncMock()
|
|
sandbox.fs.create_folder = mock.AsyncMock()
|
|
sandbox.refresh_activity = mock.AsyncMock()
|
|
return sandbox
|
|
|
|
|
|
@pytest.fixture(name="sandbox")
|
|
def _sandbox() -> mock.MagicMock:
|
|
return _make_sandbox()
|
|
|
|
|
|
@pytest.fixture(name="daytona_patch")
|
|
def _daytona_patch(sandbox: mock.MagicMock):
|
|
"""Patch AsyncDaytona to return a mock client."""
|
|
mock_client = mock.MagicMock(name="AsyncDaytona")
|
|
mock_client.create = mock.AsyncMock(return_value=sandbox)
|
|
|
|
with mock.patch.object(daytona, "AsyncDaytona", autospec=True) as mock_class:
|
|
mock_class.return_value = mock_client
|
|
yield mock_class
|
|
|
|
|
|
async def test_initialize_creates_sandbox(daytona_patch, sandbox):
|
|
env = DaytonaEnvironment(image="custom-image", env_vars={"A": "1"})
|
|
assert env.is_initialized is False
|
|
await env.initialize()
|
|
assert env.is_initialized is True
|
|
|
|
daytona_patch.assert_called_once()
|
|
client = daytona_patch.return_value
|
|
client.create.assert_awaited_once()
|
|
|
|
args, _ = client.create.call_args
|
|
params = args[0]
|
|
assert isinstance(params, CreateSandboxFromImageParams)
|
|
assert params.image == "custom-image"
|
|
assert params.env_vars == {"A": "1"}
|
|
assert params.auto_stop_interval == 5
|
|
assert params.auto_delete_interval == 0
|
|
assert env._sandbox is sandbox
|
|
|
|
|
|
async def test_initialize_creates_sandbox_default(daytona_patch, sandbox):
|
|
env = DaytonaEnvironment(env_vars={"B": "2"})
|
|
await env.initialize()
|
|
|
|
daytona_patch.assert_called_once()
|
|
client = daytona_patch.return_value
|
|
client.create.assert_awaited_once()
|
|
|
|
args, _ = client.create.call_args
|
|
params = args[0]
|
|
assert isinstance(params, CreateSandboxFromSnapshotParams)
|
|
assert params.language == "python"
|
|
assert params.env_vars == {"B": "2"}
|
|
assert params.auto_stop_interval == 5
|
|
assert params.auto_delete_interval == 0
|
|
assert env._sandbox is sandbox
|
|
|
|
|
|
async def test_initialize_is_idempotent(daytona_patch, sandbox):
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
await env.initialize()
|
|
client = daytona_patch.return_value
|
|
client.create.assert_awaited_once()
|
|
|
|
|
|
async def test_close_deletes_sandbox_and_is_idempotent(daytona_patch, sandbox):
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
assert env.is_initialized is True
|
|
await env.close()
|
|
sandbox.delete.assert_awaited_once()
|
|
assert env._sandbox is None
|
|
assert env.is_initialized is False
|
|
|
|
# Second close is a no-op.
|
|
await env.close()
|
|
sandbox.delete.assert_awaited_once()
|
|
|
|
|
|
async def test_working_dir_requires_initialize():
|
|
env = DaytonaEnvironment()
|
|
with pytest.raises(RuntimeError):
|
|
_ = env.working_dir
|
|
|
|
|
|
async def test_execute_before_initialize_raises():
|
|
env = DaytonaEnvironment()
|
|
with pytest.raises(RuntimeError):
|
|
await env.execute("echo hi")
|
|
|
|
|
|
async def test_execute_success(daytona_patch, sandbox):
|
|
class MockArtifacts:
|
|
stdout = "out"
|
|
|
|
class MockResponse:
|
|
exit_code = 0
|
|
artifacts = MockArtifacts()
|
|
|
|
sandbox.process.exec.return_value = MockResponse()
|
|
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
result = await env.execute("echo out")
|
|
|
|
assert result.exit_code == 0
|
|
assert result.stdout == "out"
|
|
assert result.stderr == ""
|
|
assert result.timed_out is False
|
|
sandbox.refresh_activity.assert_awaited_once()
|
|
|
|
|
|
async def test_execute_timeout(daytona_patch, sandbox):
|
|
# Simulate a Daytona timeout error
|
|
sandbox.process.exec.side_effect = DaytonaError("timeout occurred")
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
result = await env.execute("sleep 999")
|
|
|
|
assert result.timed_out is True
|
|
|
|
|
|
async def test_read_file_returns_bytes(daytona_patch, sandbox):
|
|
sandbox.fs.download_file.return_value = b"data"
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
data = await env.read_file("notes.txt")
|
|
|
|
assert data == b"data"
|
|
sandbox.fs.download_file.assert_awaited_once_with("/workspaces/notes.txt")
|
|
sandbox.refresh_activity.assert_awaited_once()
|
|
|
|
|
|
async def test_read_file_absolute_path_passthrough(daytona_patch, sandbox):
|
|
sandbox.fs.download_file.return_value = b"x"
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
await env.read_file("/etc/hostname")
|
|
|
|
sandbox.fs.download_file.assert_awaited_once_with("/etc/hostname")
|
|
|
|
|
|
async def test_read_file_missing_raises(daytona_patch, sandbox):
|
|
sandbox.fs.download_file.return_value = None
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
await env.read_file("missing.txt")
|
|
|
|
|
|
async def test_write_file_resolves_relative_path(daytona_patch, sandbox):
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
await env.write_file("sub/out.txt", "hello")
|
|
sandbox.refresh_activity.assert_awaited_once()
|
|
|
|
sandbox.fs.upload_file.assert_awaited_once_with(
|
|
b"hello", "/workspaces/sub/out.txt"
|
|
)
|
|
|
|
|
|
async def test_initialize_propagates_api_key_and_url(daytona_patch, sandbox):
|
|
from daytona import DaytonaConfig
|
|
|
|
env = DaytonaEnvironment(api_key="my-key", api_url="my-url")
|
|
await env.initialize()
|
|
|
|
daytona_patch.assert_called_once()
|
|
_, kwargs = daytona_patch.call_args
|
|
config = kwargs.get("config")
|
|
assert isinstance(config, DaytonaConfig)
|
|
assert config.api_key == "my-key"
|
|
assert config.api_url == "my-url"
|
|
|
|
|
|
async def test_write_file_creates_parent_directory(daytona_patch, sandbox):
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
await env.write_file("sub/nested/file.txt", "content")
|
|
|
|
sandbox.fs.create_folder.assert_has_calls([
|
|
mock.call("/workspaces", mode="755"),
|
|
mock.call("/workspaces/sub", mode="755"),
|
|
mock.call("/workspaces/sub/nested", mode="755"),
|
|
])
|
|
sandbox.fs.upload_file.assert_awaited_once_with(
|
|
b"content", "/workspaces/sub/nested/file.txt"
|
|
)
|
|
|
|
|
|
async def test_read_file_raises_file_not_found_on_daytona_not_found(
|
|
daytona_patch, sandbox
|
|
):
|
|
sandbox.fs.download_file.side_effect = DaytonaNotFoundError("not found")
|
|
env = DaytonaEnvironment()
|
|
await env.initialize()
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
await env.read_file("missing.txt")
|