Files
2026-07-13 12:47:58 +08:00

281 lines
7.6 KiB
Python

import asyncio
import os
import random
import string
from typing import Callable, Dict, Optional
import pytest
import pytest_asyncio
from e2b import (
AsyncCommandHandle,
AsyncSandbox,
AsyncTemplate,
AsyncVolume,
CommandExitException,
CommandHandle,
LogEntry,
Sandbox,
Template,
TemplateClass,
Volume,
)
@pytest.fixture
def test_api_key() -> str:
"""Placeholder API key with a valid format for tests that don't hit the API."""
return "e2b_" + "0" * 40
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.when == "call":
item._test_failed = rep.failed
@pytest.fixture()
def sandbox_test_id():
return f"test_{_generate_random_string()}"
@pytest.fixture()
def template():
return "base"
@pytest.fixture()
def sandbox_factory(request, template, sandbox_test_id):
def factory(*, template_name: str = template, **kwargs):
metadata = kwargs.setdefault("metadata", dict())
metadata.setdefault("sandbox_test_id", sandbox_test_id)
sandbox = Sandbox.create(template_name, **kwargs)
def finalizer():
if getattr(request.node, "_test_failed", False):
print(f"\n[TEST FAILED] Sandbox ID: {sandbox.sandbox_id}")
sandbox.kill()
request.addfinalizer(finalizer)
return sandbox
return factory
@pytest.fixture()
def sandbox(sandbox_factory):
return sandbox_factory()
@pytest_asyncio.fixture
async def async_sandbox_factory(request, template, sandbox_test_id):
sandboxes: list = []
async def factory(*, template_name: str = template, **kwargs):
metadata = kwargs.setdefault("metadata", dict())
metadata.setdefault("sandbox_test_id", sandbox_test_id)
sandbox = await AsyncSandbox.create(template_name, **kwargs)
sandboxes.append(sandbox)
return sandbox
yield factory
if getattr(request.node, "_test_failed", False):
for sandbox in sandboxes:
print(f"\n[TEST FAILED] Sandbox ID: {sandbox.sandbox_id}")
results = await asyncio.gather(
*(sandbox.kill() for sandbox in sandboxes), return_exceptions=True
)
for sandbox, result in zip(sandboxes, results):
if isinstance(result, BaseException):
print(f"\n[TEARDOWN FAILED] Sandbox ID: {sandbox.sandbox_id}: {result!r}")
@pytest_asyncio.fixture
async def async_sandbox(async_sandbox_factory):
return await async_sandbox_factory()
@pytest.fixture
def build():
def _build(
template: TemplateClass,
name: Optional[str] = None,
skip_cache: bool = False,
on_build_logs: Optional[Callable[[LogEntry], None]] = None,
):
build_name = name or f"e2b-test-{_generate_random_string()}"
build_info: Dict[str, Optional[str]] = {"template_id": None, "build_id": None}
def capture_logs(log: LogEntry):
import re
if "Template created with ID:" in log.message:
match = re.search(
r"Template created with ID: ([^,]+), Build ID: (.+)", log.message
)
if match:
build_info["template_id"] = match.group(1)
build_info["build_id"] = match.group(2)
if on_build_logs:
on_build_logs(log)
try:
return Template.build(
template,
build_name,
cpu_count=1,
memory_mb=1024,
skip_cache=skip_cache,
on_build_logs=capture_logs,
)
except Exception as e:
print(
f"\n[BUILD FAILED] name={build_name}, "
f"template_id={build_info['template_id']}, "
f"build_id={build_info['build_id']}, error={e}"
)
raise
return _build
@pytest_asyncio.fixture
def async_build():
async def _async_build(
template: TemplateClass,
name: Optional[str] = None,
skip_cache: bool = False,
on_build_logs: Optional[Callable[[LogEntry], None]] = None,
):
build_name = name or f"e2b-test-{_generate_random_string()}"
build_info: Dict[str, Optional[str]] = {"template_id": None, "build_id": None}
def capture_logs(log: LogEntry):
import re
if "Template created with ID:" in log.message:
match = re.search(
r"Template created with ID: ([^,]+), Build ID: (.+)", log.message
)
if match:
build_info["template_id"] = match.group(1)
build_info["build_id"] = match.group(2)
if on_build_logs:
on_build_logs(log)
try:
return await AsyncTemplate.build(
template,
build_name,
cpu_count=1,
memory_mb=1024,
skip_cache=skip_cache,
on_build_logs=capture_logs,
)
except Exception as e:
print(
f"\n[BUILD FAILED] name={build_name}, "
f"template_id={build_info['template_id']}, "
f"build_id={build_info['build_id']}, error={e}"
)
raise
return _async_build
@pytest.fixture
def debug():
return os.getenv("E2B_DEBUG") is not None
@pytest.fixture(autouse=True)
def skip_by_debug(request, debug):
if request.node.get_closest_marker("skip_debug"):
if debug:
pytest.skip("skipped because E2B_DEBUG is set")
class Helpers:
@staticmethod
def catch_cmd_exit_error_in_background(cmd: AsyncCommandHandle):
disabled = False
async def wait_for_exit():
try:
await cmd.wait()
except CommandExitException as e:
if not disabled:
assert False, (
f"command failed with exit code {e.exit_code}: {e.stderr}"
)
asyncio.create_task(wait_for_exit())
def disable():
nonlocal disabled
disabled = True
return disable
@staticmethod
def check_cmd_exit_error(cmd: CommandHandle):
try:
cmd.wait()
except CommandExitException as e:
assert False, f"command failed with exit code {e.exit_code}: {e.stderr}"
except Exception as e:
raise e
@pytest.fixture
def helpers():
return Helpers
def _generate_random_string(length: int = 8) -> str:
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
def _skip_unless_volume_tests_enabled():
if os.getenv("ENABLE_VOLUME_TESTS") is None:
pytest.skip("skipped because ENABLE_VOLUME_TESTS is not set")
@pytest.fixture
def volume(request):
_skip_unless_volume_tests_enabled()
vol = Volume.create(f"test-vol-{_generate_random_string()}")
def finalizer():
if getattr(request.node, "_test_failed", False):
print(f"\n[TEST FAILED] Volume ID: {vol.volume_id}")
try:
Volume.destroy(vol.volume_id)
except Exception:
pass
request.addfinalizer(finalizer)
return vol
@pytest_asyncio.fixture
async def async_volume(request):
_skip_unless_volume_tests_enabled()
vol = await AsyncVolume.create(f"test-vol-{_generate_random_string()}")
try:
yield vol
finally:
if getattr(request.node, "_test_failed", False):
print(f"\n[TEST FAILED] Volume ID: {vol.volume_id}")
try:
await AsyncVolume.destroy(vol.volume_id)
except Exception:
pass