# 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()