ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
225 lines
6.2 KiB
Python
225 lines
6.2 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 E2BEnvironment."""
|
|
|
|
from unittest import mock
|
|
|
|
from e2b import CommandExitException
|
|
from e2b import CommandResult
|
|
from e2b import FileNotFoundException
|
|
from e2b import TimeoutException
|
|
from google.adk.integrations.e2b._e2b_environment import E2BEnvironment
|
|
import pytest
|
|
|
|
|
|
def _make_sandbox(*, running: bool = True) -> mock.MagicMock:
|
|
"""Build a mock AsyncSandbox with async method stubs."""
|
|
sandbox = mock.MagicMock(name='AsyncSandbox')
|
|
sandbox.is_running = mock.AsyncMock(return_value=running)
|
|
sandbox.set_timeout = mock.AsyncMock()
|
|
sandbox.kill = mock.AsyncMock(return_value=True)
|
|
sandbox.commands.run = mock.AsyncMock()
|
|
sandbox.files.read = mock.AsyncMock()
|
|
sandbox.files.write = mock.AsyncMock()
|
|
return sandbox
|
|
|
|
|
|
@pytest.fixture(name='sandbox')
|
|
def _sandbox() -> mock.MagicMock:
|
|
return _make_sandbox()
|
|
|
|
|
|
@pytest.fixture(name='create_patch')
|
|
def _create_patch(sandbox: mock.MagicMock):
|
|
"""Patch AsyncSandbox.create to return the mock sandbox."""
|
|
with mock.patch(
|
|
'e2b.AsyncSandbox.create', new=mock.AsyncMock(return_value=sandbox)
|
|
) as create:
|
|
yield create
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_creates_sandbox(create_patch, sandbox):
|
|
env = E2BEnvironment(image='custom', timeout=120, env_vars={'A': '1'})
|
|
assert env.is_initialized is False
|
|
await env.initialize()
|
|
assert env.is_initialized is True
|
|
|
|
create_patch.assert_awaited_once()
|
|
_, kwargs = create_patch.call_args
|
|
assert kwargs['template'] == 'custom'
|
|
assert kwargs['timeout'] == 120
|
|
assert kwargs['envs'] == {'A': '1'}
|
|
assert env._sandbox is sandbox
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_is_idempotent(create_patch, sandbox):
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
await env.initialize()
|
|
create_patch.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_kills_sandbox_and_is_idempotent(create_patch, sandbox):
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
assert env.is_initialized is True
|
|
await env.close()
|
|
sandbox.kill.assert_awaited_once()
|
|
assert env._sandbox is None
|
|
assert env.is_initialized is False
|
|
# Second close is a no-op.
|
|
await env.close()
|
|
sandbox.kill.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_working_dir_requires_initialize():
|
|
env = E2BEnvironment()
|
|
with pytest.raises(RuntimeError):
|
|
_ = env.working_dir
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_before_initialize_raises():
|
|
env = E2BEnvironment()
|
|
with pytest.raises(RuntimeError):
|
|
await env.execute('echo hi')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_success(create_patch, sandbox):
|
|
sandbox.commands.run.return_value = CommandResult(
|
|
stdout='out', stderr='err', exit_code=0, error=None
|
|
)
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
result = await env.execute('echo out')
|
|
|
|
assert result.exit_code == 0
|
|
assert result.stdout == 'out'
|
|
assert result.stderr == 'err'
|
|
assert result.timed_out is False
|
|
sandbox.set_timeout.assert_awaited() # keepalive
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_nonzero_exit_is_normal_result(create_patch, sandbox):
|
|
exc = CommandExitException(
|
|
stdout='partial', stderr='boom', exit_code=2, error='failed'
|
|
)
|
|
sandbox.commands.run.side_effect = exc
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
result = await env.execute('false')
|
|
|
|
assert result.exit_code == 2
|
|
assert result.stdout == 'partial'
|
|
assert result.stderr == 'boom'
|
|
assert result.timed_out is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_timeout(create_patch, sandbox):
|
|
sandbox.commands.run.side_effect = TimeoutException('too slow')
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
result = await env.execute('sleep 999')
|
|
|
|
assert result.timed_out is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_file_returns_bytes(create_patch, sandbox):
|
|
sandbox.files.read.return_value = b'data'
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
data = await env.read_file('notes.txt')
|
|
|
|
assert data == b'data'
|
|
sandbox.files.read.assert_awaited_once_with(
|
|
'/home/user/notes.txt', format='bytes'
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_file_absolute_path_passthrough(create_patch, sandbox):
|
|
sandbox.files.read.return_value = b'x'
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
await env.read_file('/etc/hostname')
|
|
|
|
sandbox.files.read.assert_awaited_once_with('/etc/hostname', format='bytes')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_file_missing_raises(create_patch, sandbox):
|
|
sandbox.files.read.side_effect = FileNotFoundException('nope')
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
await env.read_file('missing.txt')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_file_resolves_relative_path(create_patch, sandbox):
|
|
env = E2BEnvironment()
|
|
await env.initialize()
|
|
|
|
await env.write_file('sub/out.txt', 'hello')
|
|
|
|
sandbox.files.write.assert_awaited_once_with(
|
|
'/home/user/sub/out.txt', 'hello'
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keepalive_extends_timeout_when_running(create_patch, sandbox):
|
|
sandbox.files.read.return_value = b'1'
|
|
env = E2BEnvironment(timeout=200)
|
|
await env.initialize()
|
|
|
|
await env.read_file('a.txt')
|
|
|
|
sandbox.set_timeout.assert_awaited_with(200)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lazy_recreate_when_expired(sandbox):
|
|
expired = _make_sandbox(running=False)
|
|
fresh = _make_sandbox(running=True)
|
|
fresh.files.read.return_value = b'fresh'
|
|
|
|
with mock.patch(
|
|
'e2b.AsyncSandbox.create',
|
|
new=mock.AsyncMock(side_effect=[expired, fresh]),
|
|
) as create:
|
|
env = E2BEnvironment()
|
|
await env.initialize() # -> expired
|
|
data = await env.read_file('a.txt') # detects dead, recreates -> fresh
|
|
|
|
assert data == b'fresh'
|
|
assert create.await_count == 2
|
|
assert env._sandbox is fresh
|
|
expired.set_timeout.assert_not_awaited()
|