diff --git a/agentrun/sandbox/__client_async_template.py b/agentrun/sandbox/__client_async_template.py index f87c34b..c6426f2 100644 --- a/agentrun/sandbox/__client_async_template.py +++ b/agentrun/sandbox/__client_async_template.py @@ -276,6 +276,7 @@ async def create_sandbox_async( self, template_name: str, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional[NASConfig] = None, oss_mount_config: Optional[OSSMountConfig] = None, polar_fs_config: Optional[PolarFsConfig] = None, @@ -286,6 +287,7 @@ async def create_sandbox_async( Args: template_name: 模板名称 / Template name sandbox_idle_timeout_seconds: 沙箱空闲超时时间(秒) / Sandbox idle timeout (seconds) + sandbox_id: 沙箱 ID(可选,用户可指定) / Sandbox ID (optional, user can specify) nas_config: NAS 配置 / NAS configuration oss_mount_config: OSS 挂载配置 / OSS mount configuration polar_fs_config: PolarFS 配置 / PolarFS configuration @@ -314,6 +316,7 @@ async def create_sandbox_async( result = await self.__sandbox_data_api.create_sandbox_async( template_name=template_name, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + sandbox_id=sandbox_id, nas_config=nas_config_dict, oss_mount_config=oss_mount_config_dict, polar_fs_config=polar_fs_config_dict, @@ -353,7 +356,7 @@ async def stop_sandbox_async( """ try: result = await self.__sandbox_data_api.stop_sandbox_async( - sandbox_id + sandbox_id, config=config ) # 判断返回结果是否成功 @@ -393,7 +396,7 @@ async def delete_sandbox_async( """ try: result = await self.__sandbox_data_api.delete_sandbox_async( - sandbox_id + sandbox_id, config=config ) # 判断返回结果是否成功 @@ -434,7 +437,9 @@ async def get_sandbox_async( ServerError: 服务器错误 """ try: - result = await self.__sandbox_data_api.get_sandbox_async(sandbox_id) + result = await self.__sandbox_data_api.get_sandbox_async( + sandbox_id, config=config + ) # 判断返回结果是否成功 if result.get("code") != "SUCCESS": diff --git a/agentrun/sandbox/__sandbox_async_template.py b/agentrun/sandbox/__sandbox_async_template.py index 3f555c0..780c65d 100644 --- a/agentrun/sandbox/__sandbox_async_template.py +++ b/agentrun/sandbox/__sandbox_async_template.py @@ -87,6 +87,7 @@ async def create_async( template_type: Literal[TemplateType.CODE_INTERPRETER], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -101,6 +102,7 @@ async def create_async( template_type: Literal[TemplateType.BROWSER], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -115,6 +117,7 @@ async def create_async( template_type: Literal[TemplateType.AIO], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -129,6 +132,7 @@ async def create_async( template_type: Literal[TemplateType.CUSTOM], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -142,6 +146,7 @@ async def create_async( template_type: TemplateType, template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -178,6 +183,7 @@ async def create_async( base_sandbox = await cls.__get_client().create_sandbox_async( template_name=template_name, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + sandbox_id=sandbox_id, nas_config=nas_config, oss_mount_config=oss_mount_config, polar_fs_config=polar_fs_config, @@ -308,7 +314,9 @@ async def connect_async( Args: sandbox_id: Sandbox ID - type: 可选的类型参数,用于类型提示和运行时验证 + template_type: 可选的类型参数,用于类型提示和运行时类型决定。 + 提供时直接使用该类型决定返回的子类,不调用 get_template(无需 AKSK)。 + 未提供时通过 get_template 获取类型(需要 AKSK)。 config: 配置对象 Returns: @@ -325,25 +333,16 @@ async def connect_async( sandbox_id, config=config ) - # 根据 template_name 获取 template 类型 - if sandbox.template_name is None: - raise ValueError(f"Sandbox {sandbox_id} has no template_name") + resolved_type = template_type + if resolved_type is None: + if sandbox.template_name is None: + raise ValueError(f"Sandbox {sandbox_id} has no template_name") - template = await cls.get_template_async( - sandbox.template_name, config=config - ) - - # 如果提供了 type 参数,验证类型是否匹配 - if ( - template_type is not None - and template.template_type != template_type - ): - raise ValueError( - f"Sandbox {sandbox_id} has template type" - f" {template.template_type}, but expected {template_type}" + template = await cls.get_template_async( + sandbox.template_name, config=config ) + resolved_type = template.template_type - # 根据 template 类型创建相应的 Sandbox 子类 from agentrun.sandbox.aio_sandbox import AioSandbox from agentrun.sandbox.browser_sandbox import BrowserSandbox from agentrun.sandbox.code_interpreter_sandbox import ( @@ -351,21 +350,21 @@ async def connect_async( ) result = None - if template.template_type == TemplateType.CODE_INTERPRETER: + if resolved_type == TemplateType.CODE_INTERPRETER: result = CodeInterpreterSandbox.model_validate( sandbox.model_dump(by_alias=False) ) - elif template.template_type == TemplateType.BROWSER: + elif resolved_type == TemplateType.BROWSER: result = BrowserSandbox.model_validate( sandbox.model_dump(by_alias=False) ) - elif template.template_type == TemplateType.AIO: + elif resolved_type == TemplateType.AIO: result = AioSandbox.model_validate( sandbox.model_dump(by_alias=False) ) else: raise ValueError( - f"Unsupported template type: {template.template_type}. " + f"Unsupported template type: {resolved_type}. " "Expected 'code-interpreter', 'browser' or 'aio'" ) diff --git a/agentrun/sandbox/api/__sandbox_data_async_template.py b/agentrun/sandbox/api/__sandbox_data_async_template.py index 1cfd165..2cf9d3c 100644 --- a/agentrun/sandbox/api/__sandbox_data_async_template.py +++ b/agentrun/sandbox/api/__sandbox_data_async_template.py @@ -70,6 +70,7 @@ async def create_sandbox_async( self, template_name: str, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional[Dict[str, Any]] = None, oss_mount_config: Optional[Dict[str, Any]] = None, polar_fs_config: Optional[Dict[str, Any]] = None, @@ -80,28 +81,30 @@ async def create_sandbox_async( "templateName": template_name, "sandboxIdleTimeoutSeconds": sandbox_idle_timeout_seconds, } + if sandbox_id is not None: + data["sandboxId"] = sandbox_id if nas_config is not None: data["nasConfig"] = nas_config if oss_mount_config is not None: data["ossMountConfig"] = oss_mount_config if polar_fs_config is not None: data["polarFsConfig"] = polar_fs_config - return await self.post_async("/", data=data) + return await self.post_async("/", data=data, config=config) async def delete_sandbox_async( self, sandbox_id: str, config: Optional[Config] = None ): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return await self.delete_async("/") + return await self.delete_async("/", config=config) async def stop_sandbox_async( self, sandbox_id: str, config: Optional[Config] = None ): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return await self.post_async("/stop") + return await self.post_async("/stop", config=config) async def get_sandbox_async( self, sandbox_id: str, config: Optional[Config] = None ): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return await self.get_async("/") + return await self.get_async("/", config=config) diff --git a/agentrun/sandbox/api/sandbox_data.py b/agentrun/sandbox/api/sandbox_data.py index e97834a..c021a72 100644 --- a/agentrun/sandbox/api/sandbox_data.py +++ b/agentrun/sandbox/api/sandbox_data.py @@ -83,6 +83,7 @@ async def create_sandbox_async( self, template_name: str, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional[Dict[str, Any]] = None, oss_mount_config: Optional[Dict[str, Any]] = None, polar_fs_config: Optional[Dict[str, Any]] = None, @@ -93,18 +94,21 @@ async def create_sandbox_async( "templateName": template_name, "sandboxIdleTimeoutSeconds": sandbox_idle_timeout_seconds, } + if sandbox_id is not None: + data["sandboxId"] = sandbox_id if nas_config is not None: data["nasConfig"] = nas_config if oss_mount_config is not None: data["ossMountConfig"] = oss_mount_config if polar_fs_config is not None: data["polarFsConfig"] = polar_fs_config - return await self.post_async("/", data=data) + return await self.post_async("/", data=data, config=config) def create_sandbox( self, template_name: str, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional[Dict[str, Any]] = None, oss_mount_config: Optional[Dict[str, Any]] = None, polar_fs_config: Optional[Dict[str, Any]] = None, @@ -115,40 +119,42 @@ def create_sandbox( "templateName": template_name, "sandboxIdleTimeoutSeconds": sandbox_idle_timeout_seconds, } + if sandbox_id is not None: + data["sandboxId"] = sandbox_id if nas_config is not None: data["nasConfig"] = nas_config if oss_mount_config is not None: data["ossMountConfig"] = oss_mount_config if polar_fs_config is not None: data["polarFsConfig"] = polar_fs_config - return self.post("/", data=data) + return self.post("/", data=data, config=config) async def delete_sandbox_async( self, sandbox_id: str, config: Optional[Config] = None ): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return await self.delete_async("/") + return await self.delete_async("/", config=config) def delete_sandbox(self, sandbox_id: str, config: Optional[Config] = None): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return self.delete("/") + return self.delete("/", config=config) async def stop_sandbox_async( self, sandbox_id: str, config: Optional[Config] = None ): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return await self.post_async("/stop") + return await self.post_async("/stop", config=config) def stop_sandbox(self, sandbox_id: str, config: Optional[Config] = None): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return self.post("/stop") + return self.post("/stop", config=config) async def get_sandbox_async( self, sandbox_id: str, config: Optional[Config] = None ): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return await self.get_async("/") + return await self.get_async("/", config=config) def get_sandbox(self, sandbox_id: str, config: Optional[Config] = None): self.__refresh_access_token(sandbox_id=sandbox_id, config=config) - return self.get("/") + return self.get("/", config=config) diff --git a/agentrun/sandbox/client.py b/agentrun/sandbox/client.py index e5ea9b7..b315b45 100644 --- a/agentrun/sandbox/client.py +++ b/agentrun/sandbox/client.py @@ -498,6 +498,7 @@ async def create_sandbox_async( self, template_name: str, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional[NASConfig] = None, oss_mount_config: Optional[OSSMountConfig] = None, polar_fs_config: Optional[PolarFsConfig] = None, @@ -508,6 +509,7 @@ async def create_sandbox_async( Args: template_name: 模板名称 / Template name sandbox_idle_timeout_seconds: 沙箱空闲超时时间(秒) / Sandbox idle timeout (seconds) + sandbox_id: 沙箱 ID(可选,用户可指定) / Sandbox ID (optional, user can specify) nas_config: NAS 配置 / NAS configuration oss_mount_config: OSS 挂载配置 / OSS mount configuration polar_fs_config: PolarFS 配置 / PolarFS configuration @@ -536,6 +538,7 @@ async def create_sandbox_async( result = await self.__sandbox_data_api.create_sandbox_async( template_name=template_name, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + sandbox_id=sandbox_id, nas_config=nas_config_dict, oss_mount_config=oss_mount_config_dict, polar_fs_config=polar_fs_config_dict, @@ -560,6 +563,7 @@ def create_sandbox( self, template_name: str, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional[NASConfig] = None, oss_mount_config: Optional[OSSMountConfig] = None, polar_fs_config: Optional[PolarFsConfig] = None, @@ -570,6 +574,7 @@ def create_sandbox( Args: template_name: 模板名称 / Template name sandbox_idle_timeout_seconds: 沙箱空闲超时时间(秒) / Sandbox idle timeout (seconds) + sandbox_id: 沙箱 ID(可选,用户可指定) / Sandbox ID (optional, user can specify) nas_config: NAS 配置 / NAS configuration oss_mount_config: OSS 挂载配置 / OSS mount configuration polar_fs_config: PolarFS 配置 / PolarFS configuration @@ -598,6 +603,7 @@ def create_sandbox( result = self.__sandbox_data_api.create_sandbox( template_name=template_name, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + sandbox_id=sandbox_id, nas_config=nas_config_dict, oss_mount_config=oss_mount_config_dict, polar_fs_config=polar_fs_config_dict, @@ -637,7 +643,7 @@ async def stop_sandbox_async( """ try: result = await self.__sandbox_data_api.stop_sandbox_async( - sandbox_id + sandbox_id, config=config ) # 判断返回结果是否成功 @@ -676,7 +682,9 @@ def stop_sandbox( ServerError: 服务器错误 """ try: - result = self.__sandbox_data_api.stop_sandbox(sandbox_id) + result = self.__sandbox_data_api.stop_sandbox( + sandbox_id, config=config + ) # 判断返回结果是否成功 if result.get("code") != "SUCCESS": @@ -715,7 +723,7 @@ async def delete_sandbox_async( """ try: result = await self.__sandbox_data_api.delete_sandbox_async( - sandbox_id + sandbox_id, config=config ) # 判断返回结果是否成功 @@ -754,7 +762,9 @@ def delete_sandbox( ServerError: 服务器错误 """ try: - result = self.__sandbox_data_api.delete_sandbox(sandbox_id) + result = self.__sandbox_data_api.delete_sandbox( + sandbox_id, config=config + ) # 判断返回结果是否成功 if result.get("code") != "SUCCESS": @@ -794,7 +804,9 @@ async def get_sandbox_async( ServerError: 服务器错误 """ try: - result = await self.__sandbox_data_api.get_sandbox_async(sandbox_id) + result = await self.__sandbox_data_api.get_sandbox_async( + sandbox_id, config=config + ) # 判断返回结果是否成功 if result.get("code") != "SUCCESS": @@ -834,7 +846,9 @@ def get_sandbox( ServerError: 服务器错误 """ try: - result = self.__sandbox_data_api.get_sandbox(sandbox_id) + result = self.__sandbox_data_api.get_sandbox( + sandbox_id, config=config + ) # 判断返回结果是否成功 if result.get("code") != "SUCCESS": diff --git a/agentrun/sandbox/sandbox.py b/agentrun/sandbox/sandbox.py index 0b6dafd..a504531 100644 --- a/agentrun/sandbox/sandbox.py +++ b/agentrun/sandbox/sandbox.py @@ -97,6 +97,7 @@ async def create_async( template_type: Literal[TemplateType.CODE_INTERPRETER], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -111,6 +112,7 @@ def create( template_type: Literal[TemplateType.CODE_INTERPRETER], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -125,6 +127,7 @@ async def create_async( template_type: Literal[TemplateType.BROWSER], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -139,6 +142,7 @@ def create( template_type: Literal[TemplateType.BROWSER], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -153,6 +157,7 @@ async def create_async( template_type: Literal[TemplateType.AIO], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -167,6 +172,7 @@ def create( template_type: Literal[TemplateType.AIO], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -181,6 +187,7 @@ async def create_async( template_type: Literal[TemplateType.CUSTOM], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -195,6 +202,7 @@ def create( template_type: Literal[TemplateType.CUSTOM], template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -208,6 +216,7 @@ async def create_async( template_type: TemplateType, template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -244,6 +253,7 @@ async def create_async( base_sandbox = await cls.__get_client().create_sandbox_async( template_name=template_name, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + sandbox_id=sandbox_id, nas_config=nas_config, oss_mount_config=oss_mount_config, polar_fs_config=polar_fs_config, @@ -281,6 +291,7 @@ def create( template_type: TemplateType, template_name: Optional[str] = None, sandbox_idle_timeout_seconds: Optional[int] = 600, + sandbox_id: Optional[str] = None, nas_config: Optional["NASConfig"] = None, oss_mount_config: Optional["OSSMountConfig"] = None, polar_fs_config: Optional["PolarFsConfig"] = None, @@ -317,6 +328,7 @@ def create( base_sandbox = cls.__get_client().create_sandbox( template_name=template_name, sandbox_idle_timeout_seconds=sandbox_idle_timeout_seconds, + sandbox_id=sandbox_id, nas_config=nas_config, oss_mount_config=oss_mount_config, polar_fs_config=polar_fs_config, @@ -535,7 +547,9 @@ async def connect_async( Args: sandbox_id: Sandbox ID - type: 可选的类型参数,用于类型提示和运行时验证 + template_type: 可选的类型参数,用于类型提示和运行时类型决定。 + 提供时直接使用该类型决定返回的子类,不调用 get_template(无需 AKSK)。 + 未提供时通过 get_template 获取类型(需要 AKSK)。 config: 配置对象 Returns: @@ -552,25 +566,16 @@ async def connect_async( sandbox_id, config=config ) - # 根据 template_name 获取 template 类型 - if sandbox.template_name is None: - raise ValueError(f"Sandbox {sandbox_id} has no template_name") + resolved_type = template_type + if resolved_type is None: + if sandbox.template_name is None: + raise ValueError(f"Sandbox {sandbox_id} has no template_name") - template = await cls.get_template_async( - sandbox.template_name, config=config - ) - - # 如果提供了 type 参数,验证类型是否匹配 - if ( - template_type is not None - and template.template_type != template_type - ): - raise ValueError( - f"Sandbox {sandbox_id} has template type" - f" {template.template_type}, but expected {template_type}" + template = await cls.get_template_async( + sandbox.template_name, config=config ) + resolved_type = template.template_type - # 根据 template 类型创建相应的 Sandbox 子类 from agentrun.sandbox.aio_sandbox import AioSandbox from agentrun.sandbox.browser_sandbox import BrowserSandbox from agentrun.sandbox.code_interpreter_sandbox import ( @@ -578,21 +583,21 @@ async def connect_async( ) result = None - if template.template_type == TemplateType.CODE_INTERPRETER: + if resolved_type == TemplateType.CODE_INTERPRETER: result = CodeInterpreterSandbox.model_validate( sandbox.model_dump(by_alias=False) ) - elif template.template_type == TemplateType.BROWSER: + elif resolved_type == TemplateType.BROWSER: result = BrowserSandbox.model_validate( sandbox.model_dump(by_alias=False) ) - elif template.template_type == TemplateType.AIO: + elif resolved_type == TemplateType.AIO: result = AioSandbox.model_validate( sandbox.model_dump(by_alias=False) ) else: raise ValueError( - f"Unsupported template type: {template.template_type}. " + f"Unsupported template type: {resolved_type}. " "Expected 'code-interpreter', 'browser' or 'aio'" ) @@ -612,7 +617,9 @@ def connect( Args: sandbox_id: Sandbox ID - type: 可选的类型参数,用于类型提示和运行时验证 + template_type: 可选的类型参数,用于类型提示和运行时类型决定。 + 提供时直接使用该类型决定返回的子类,不调用 get_template(无需 AKSK)。 + 未提供时通过 get_template 获取类型(需要 AKSK)。 config: 配置对象 Returns: @@ -627,23 +634,14 @@ def connect( # 先获取 sandbox 信息 sandbox = cls.__get_client().get_sandbox(sandbox_id, config=config) - # 根据 template_name 获取 template 类型 - if sandbox.template_name is None: - raise ValueError(f"Sandbox {sandbox_id} has no template_name") + resolved_type = template_type + if resolved_type is None: + if sandbox.template_name is None: + raise ValueError(f"Sandbox {sandbox_id} has no template_name") - template = cls.get_template(sandbox.template_name, config=config) + template = cls.get_template(sandbox.template_name, config=config) + resolved_type = template.template_type - # 如果提供了 type 参数,验证类型是否匹配 - if ( - template_type is not None - and template.template_type != template_type - ): - raise ValueError( - f"Sandbox {sandbox_id} has template type" - f" {template.template_type}, but expected {template_type}" - ) - - # 根据 template 类型创建相应的 Sandbox 子类 from agentrun.sandbox.aio_sandbox import AioSandbox from agentrun.sandbox.browser_sandbox import BrowserSandbox from agentrun.sandbox.code_interpreter_sandbox import ( @@ -651,21 +649,21 @@ def connect( ) result = None - if template.template_type == TemplateType.CODE_INTERPRETER: + if resolved_type == TemplateType.CODE_INTERPRETER: result = CodeInterpreterSandbox.model_validate( sandbox.model_dump(by_alias=False) ) - elif template.template_type == TemplateType.BROWSER: + elif resolved_type == TemplateType.BROWSER: result = BrowserSandbox.model_validate( sandbox.model_dump(by_alias=False) ) - elif template.template_type == TemplateType.AIO: + elif resolved_type == TemplateType.AIO: result = AioSandbox.model_validate( sandbox.model_dump(by_alias=False) ) else: raise ValueError( - f"Unsupported template type: {template.template_type}. " + f"Unsupported template type: {resolved_type}. " "Expected 'code-interpreter', 'browser' or 'aio'" ) diff --git a/pyproject.toml b/pyproject.toml index 2406f22..1c21038 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,9 +153,8 @@ omit = [ "*.egg-info/*", # 缓存目录 "*__pycache__*", - # server 和 sandbox 模块 + # server 模块 "agentrun/server/*", - "agentrun/sandbox/*", # integration 模块(第三方集成,单独测试) "agentrun/integration/*", # MCP 客户端(需要外部 MCP 服务器) diff --git a/tests/unittests/sandbox/__init__.py b/tests/unittests/sandbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unittests/sandbox/api/__init__.py b/tests/unittests/sandbox/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unittests/sandbox/api/test_aio_data.py b/tests/unittests/sandbox/api/test_aio_data.py new file mode 100644 index 0000000..1922134 --- /dev/null +++ b/tests/unittests/sandbox/api/test_aio_data.py @@ -0,0 +1,461 @@ +"""Tests for agentrun.sandbox.api.aio_data module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.api.aio_data import AioDataAPI +from agentrun.sandbox.model import CodeLanguage + + +@pytest.fixture +def api(): + with patch.object(AioDataAPI, "__init__", lambda self, **kw: None): + obj = AioDataAPI.__new__(AioDataAPI) + obj.sandbox_id = "sb-aio-1" + obj.config = MagicMock() + obj.config.get_account_id.return_value = "account123" + obj.access_token = "tok" + obj.access_token_map = {} + obj.resource_name = "sb-aio-1" + obj.with_path = MagicMock( + side_effect=lambda p: f"http://host.com/ns{p}?sig=abc" + ) + obj.auth = MagicMock( + return_value=("tok", {"Authorization": "Bearer tok"}, None) + ) + obj.get = MagicMock(return_value={"ok": True}) + obj.get_async = AsyncMock(return_value={"ok": True}) + obj.post = MagicMock(return_value={"ok": True}) + obj.post_async = AsyncMock(return_value={"ok": True}) + obj.delete = MagicMock(return_value={"ok": True}) + obj.delete_async = AsyncMock(return_value={"ok": True}) + obj.post_file = MagicMock(return_value={"ok": True}) + obj.post_file_async = AsyncMock(return_value={"ok": True}) + obj.get_file = MagicMock(return_value={"saved_path": "/x", "size": 10}) + obj.get_file_async = AsyncMock( + return_value={"saved_path": "/x", "size": 10} + ) + obj.get_video = MagicMock( + return_value={"saved_path": "/x.mkv", "size": 1024} + ) + obj.get_video_async = AsyncMock( + return_value={"saved_path": "/x.mkv", "size": 1024} + ) + return obj + + +class TestAioDataAPIInit: + + @patch("agentrun.sandbox.api.aio_data.SandboxDataAPI.__init__") + def test_init(self, mock_super_init): + mock_super_init.return_value = None + api = AioDataAPI(sandbox_id="sb-1") + assert api.sandbox_id == "sb-1" + mock_super_init.assert_called_once_with(sandbox_id="sb-1", config=None) + + +# ==================== Browser API ==================== + + +class TestAioCdpUrl: + + def test_get_cdp_url_no_record(self, api): + url = api.get_cdp_url() + api.with_path.assert_called_once_with("/ws/automation") + assert "ws://" in url + assert "tenantId=account123" in url + assert "recording" not in url + + def test_get_cdp_url_with_record(self, api): + url = api.get_cdp_url(record=True) + assert "recording=true" in url + + +class TestAioVncUrl: + + def test_get_vnc_url_no_record(self, api): + url = api.get_vnc_url() + api.with_path.assert_called_once_with("/ws/liveview") + assert "ws://" in url + assert "tenantId=account123" in url + + def test_get_vnc_url_with_record(self, api): + url = api.get_vnc_url(record=True) + assert "recording=true" in url + + +class TestAioPlaywright: + + def test_sync_playwright(self, api): + with patch( + "agentrun.sandbox.api.playwright_sync.BrowserPlaywrightSync" + ) as mock_cls: + mock_cls.return_value = MagicMock() + result = api.sync_playwright(record=True) + assert result is not None + mock_cls.assert_called_once() + + def test_async_playwright(self, api): + with patch( + "agentrun.sandbox.api.playwright_async.BrowserPlaywrightAsync" + ) as mock_cls: + mock_cls.return_value = MagicMock() + result = api.async_playwright(record=True) + assert result is not None + mock_cls.assert_called_once() + + +class TestAioRecordings: + + def test_list_recordings(self, api): + api.list_recordings() + api.get.assert_called_once_with("/recordings") + + @pytest.mark.asyncio + async def test_list_recordings_async(self, api): + await api.list_recordings_async() + api.get_async.assert_called_once_with("/recordings") + + def test_delete_recording(self, api): + api.delete_recording("file.mkv") + api.delete.assert_called_once_with("/recordings/file.mkv") + + @pytest.mark.asyncio + async def test_delete_recording_async(self, api): + await api.delete_recording_async("file.mkv") + api.delete_async.assert_called_once_with("/recordings/file.mkv") + + def test_download_recording(self, api): + result = api.download_recording("file.mkv", "/local/file.mkv") + api.get_video.assert_called_once_with( + "/recordings/file.mkv", save_path="/local/file.mkv" + ) + assert result["size"] == 1024 + + @pytest.mark.asyncio + async def test_download_recording_async(self, api): + result = await api.download_recording_async( + "file.mkv", "/local/file.mkv" + ) + api.get_video_async.assert_called_once_with( + "/recordings/file.mkv", save_path="/local/file.mkv" + ) + assert result["size"] == 1024 + + +# ==================== Code Interpreter API ==================== + + +class TestAioListDirectory: + + def test_list_directory_no_params(self, api): + api.list_directory() + api.get.assert_called_once_with("/filesystem", query={}) + + def test_list_directory_with_path(self, api): + api.list_directory(path="/home") + api.get.assert_called_once_with("/filesystem", query={"path": "/home"}) + + def test_list_directory_with_depth(self, api): + api.list_directory(depth=2) + api.get.assert_called_once_with("/filesystem", query={"depth": 2}) + + def test_list_directory_with_path_and_depth(self, api): + api.list_directory(path="/home", depth=3) + api.get.assert_called_once_with( + "/filesystem", query={"path": "/home", "depth": 3} + ) + + @pytest.mark.asyncio + async def test_list_directory_async(self, api): + await api.list_directory_async(path="/tmp", depth=1) + api.get_async.assert_called_once_with( + "/filesystem", query={"path": "/tmp", "depth": 1} + ) + + +class TestAioStat: + + def test_stat(self, api): + api.stat("/tmp/f.txt") + api.get.assert_called_once_with( + "/filesystem/stat", query={"path": "/tmp/f.txt"} + ) + + @pytest.mark.asyncio + async def test_stat_async(self, api): + await api.stat_async("/tmp/f.txt") + api.get_async.assert_called_once_with( + "/filesystem/stat", query={"path": "/tmp/f.txt"} + ) + + +class TestAioMkdir: + + def test_mkdir_defaults(self, api): + api.mkdir("/tmp/dir") + api.post.assert_called_once_with( + "/filesystem/mkdir", + data={"path": "/tmp/dir", "parents": True, "mode": "0755"}, + ) + + @pytest.mark.asyncio + async def test_mkdir_async(self, api): + await api.mkdir_async("/tmp/dir") + api.post_async.assert_called_once_with( + "/filesystem/mkdir", + data={"path": "/tmp/dir", "parents": True, "mode": "0755"}, + ) + + +class TestAioMoveFile: + + def test_move_file(self, api): + api.move_file("/a", "/b") + api.post.assert_called_once_with( + "/filesystem/move", data={"source": "/a", "destination": "/b"} + ) + + @pytest.mark.asyncio + async def test_move_file_async(self, api): + await api.move_file_async("/a", "/b") + api.post_async.assert_called_once_with( + "/filesystem/move", data={"source": "/a", "destination": "/b"} + ) + + +class TestAioRemoveFile: + + def test_remove_file(self, api): + api.remove_file("/tmp/x") + api.post.assert_called_once_with( + "/filesystem/remove", data={"path": "/tmp/x"} + ) + + @pytest.mark.asyncio + async def test_remove_file_async(self, api): + await api.remove_file_async("/tmp/x") + api.post_async.assert_called_once_with( + "/filesystem/remove", data={"path": "/tmp/x"} + ) + + +class TestAioContexts: + + def test_list_contexts(self, api): + api.list_contexts() + api.get.assert_called_once_with("/contexts") + + @pytest.mark.asyncio + async def test_list_contexts_async(self, api): + await api.list_contexts_async() + api.get_async.assert_called_once_with("/contexts") + + def test_create_context_default(self, api): + api.create_context() + api.post.assert_called_once_with( + "/contexts", + data={"cwd": "/home/user", "language": CodeLanguage.PYTHON}, + ) + + def test_create_context_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + api.create_context(language="ruby") + + @pytest.mark.asyncio + async def test_create_context_async_default(self, api): + await api.create_context_async() + api.post_async.assert_called_once_with( + "/contexts", + data={"cwd": "/home/user", "language": CodeLanguage.PYTHON}, + ) + + @pytest.mark.asyncio + async def test_create_context_async_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + await api.create_context_async(language="ruby") + + def test_get_context(self, api): + api.get_context("ctx-1") + api.get.assert_called_once_with("/contexts/ctx-1") + + @pytest.mark.asyncio + async def test_get_context_async(self, api): + await api.get_context_async("ctx-1") + api.get_async.assert_called_once_with("/contexts/ctx-1") + + def test_delete_context(self, api): + api.delete_context("ctx-1") + api.delete.assert_called_once_with("/contexts/ctx-1") + + @pytest.mark.asyncio + async def test_delete_context_async(self, api): + await api.delete_context_async("ctx-1") + api.delete_async.assert_called_once_with("/contexts/ctx-1") + + +class TestAioExecuteCode: + + def test_execute_code_minimal(self, api): + api.execute_code("print(1)", context_id=None) + api.post.assert_called_once_with( + "/contexts/execute", data={"code": "print(1)", "timeout": 30} + ) + + def test_execute_code_with_all_params(self, api): + api.execute_code( + "print(1)", + context_id="c1", + language=CodeLanguage.PYTHON, + timeout=60, + ) + call_data = api.post.call_args[1]["data"] + assert call_data["contextId"] == "c1" + assert call_data["language"] == CodeLanguage.PYTHON + assert call_data["timeout"] == 60 + + def test_execute_code_no_timeout(self, api): + api.execute_code("print(1)", context_id=None, timeout=None) + call_data = api.post.call_args[1]["data"] + assert "timeout" not in call_data + + def test_execute_code_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + api.execute_code("code", context_id=None, language="ruby") + + @pytest.mark.asyncio + async def test_execute_code_async_minimal(self, api): + await api.execute_code_async("print(1)", context_id=None) + api.post_async.assert_called_once_with( + "/contexts/execute", data={"code": "print(1)", "timeout": 30} + ) + + @pytest.mark.asyncio + async def test_execute_code_async_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + await api.execute_code_async( + "code", context_id=None, language="ruby" + ) + + +class TestAioFiles: + + def test_read_file(self, api): + api.read_file("/tmp/f.txt") + api.get.assert_called_once_with("/files", query={"path": "/tmp/f.txt"}) + + @pytest.mark.asyncio + async def test_read_file_async(self, api): + await api.read_file_async("/tmp/f.txt") + api.get_async.assert_called_once_with( + "/files", query={"path": "/tmp/f.txt"} + ) + + def test_write_file_defaults(self, api): + api.write_file("/tmp/f.txt", "content") + api.post.assert_called_once_with( + "/files", + data={ + "path": "/tmp/f.txt", + "content": "content", + "mode": "644", + "encoding": "utf-8", + "createDir": True, + }, + ) + + @pytest.mark.asyncio + async def test_write_file_async(self, api): + await api.write_file_async("/tmp/f.txt", "content") + api.post_async.assert_called_once() + + def test_upload_file(self, api): + api.upload_file("/local/f", "/remote/f") + api.post_file.assert_called_once_with( + path="/filesystem/upload", + local_file_path="/local/f", + target_file_path="/remote/f", + ) + + @pytest.mark.asyncio + async def test_upload_file_async(self, api): + await api.upload_file_async("/local/f", "/remote/f") + api.post_file_async.assert_called_once_with( + path="/filesystem/upload", + local_file_path="/local/f", + target_file_path="/remote/f", + ) + + def test_download_file(self, api): + api.download_file("/remote/f", "/local/f") + api.get_file.assert_called_once_with( + path="/filesystem/download", + save_path="/local/f", + query={"path": "/remote/f"}, + ) + + @pytest.mark.asyncio + async def test_download_file_async(self, api): + await api.download_file_async("/remote/f", "/local/f") + api.get_file_async.assert_called_once_with( + path="/filesystem/download", + save_path="/local/f", + query={"path": "/remote/f"}, + ) + + +class TestAioProcesses: + + def test_cmd(self, api): + api.cmd("ls", "/home") + api.post.assert_called_once_with( + "/processes/cmd", + data={"command": "ls", "cwd": "/home", "timeout": 30}, + ) + + def test_cmd_no_timeout(self, api): + api.cmd("ls", "/home", timeout=None) + call_data = api.post.call_args[1]["data"] + assert "timeout" not in call_data + + @pytest.mark.asyncio + async def test_cmd_async(self, api): + await api.cmd_async("ls", "/home") + api.post_async.assert_called_once_with( + "/processes/cmd", + data={"command": "ls", "cwd": "/home", "timeout": 30}, + ) + + @pytest.mark.asyncio + async def test_cmd_async_no_timeout(self, api): + await api.cmd_async("ls", "/home", timeout=None) + call_data = api.post_async.call_args[1]["data"] + assert "timeout" not in call_data + + def test_list_processes(self, api): + api.list_processes() + api.get.assert_called_once_with("/processes") + + @pytest.mark.asyncio + async def test_list_processes_async(self, api): + await api.list_processes_async() + api.get_async.assert_called_once_with("/processes") + + def test_get_process(self, api): + api.get_process("123") + api.get.assert_called_once_with("/processes/123") + + @pytest.mark.asyncio + async def test_get_process_async(self, api): + await api.get_process_async("123") + api.get_async.assert_called_once_with("/processes/123") + + def test_kill_process(self, api): + api.kill_process("123") + api.delete.assert_called_once_with("/processes/123") + + @pytest.mark.asyncio + async def test_kill_process_async(self, api): + await api.kill_process_async("123") + api.delete_async.assert_called_once_with("/processes/123") diff --git a/tests/unittests/sandbox/api/test_browser_data.py b/tests/unittests/sandbox/api/test_browser_data.py new file mode 100644 index 0000000..354ffd1 --- /dev/null +++ b/tests/unittests/sandbox/api/test_browser_data.py @@ -0,0 +1,133 @@ +"""Tests for agentrun.sandbox.api.browser_data module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.api.browser_data import BrowserDataAPI + + +@pytest.fixture +def api(): + with patch.object(BrowserDataAPI, "__init__", lambda self, **kw: None): + obj = BrowserDataAPI.__new__(BrowserDataAPI) + obj.sandbox_id = "sb-1" + obj.config = MagicMock() + obj.config.get_account_id.return_value = "account123" + obj.access_token = "tok" + obj.access_token_map = {} + obj.resource_name = "sb-1" + obj.with_path = MagicMock( + side_effect=lambda p: f"http://host.com/ns{p}?sig=abc" + ) + obj.auth = MagicMock( + return_value=("tok", {"Authorization": "Bearer tok"}, None) + ) + obj.get = MagicMock(return_value=[]) + obj.get_async = AsyncMock(return_value=[]) + obj.delete = MagicMock(return_value={"ok": True}) + obj.delete_async = AsyncMock(return_value={"ok": True}) + obj.get_video = MagicMock( + return_value={"saved_path": "/x.mkv", "size": 1024} + ) + obj.get_video_async = AsyncMock( + return_value={"saved_path": "/x.mkv", "size": 1024} + ) + return obj + + +class TestBrowserDataAPIInit: + + @patch("agentrun.sandbox.api.browser_data.SandboxDataAPI.__init__") + def test_init(self, mock_super_init): + mock_super_init.return_value = None + api = BrowserDataAPI(sandbox_id="sb-1") + assert api.sandbox_id == "sb-1" + mock_super_init.assert_called_once_with(sandbox_id="sb-1", config=None) + + +class TestCdpUrl: + + def test_get_cdp_url_no_record(self, api): + url = api.get_cdp_url() + api.with_path.assert_called_once_with("/ws/automation") + assert "ws://" in url + assert "tenantId=account123" in url + assert "recording" not in url + + def test_get_cdp_url_with_record(self, api): + url = api.get_cdp_url(record=True) + assert "recording=true" in url + assert "tenantId=account123" in url + + +class TestVncUrl: + + def test_get_vnc_url_no_record(self, api): + url = api.get_vnc_url() + api.with_path.assert_called_once_with("/ws/liveview") + assert "ws://" in url + assert "tenantId=account123" in url + + def test_get_vnc_url_with_record(self, api): + url = api.get_vnc_url(record=True) + assert "recording=true" in url + + +class TestPlaywright: + + def test_sync_playwright(self, api): + with patch( + "agentrun.sandbox.api.playwright_sync.BrowserPlaywrightSync" + ) as mock_cls: + mock_cls.return_value = MagicMock() + result = api.sync_playwright(record=True) + assert result is not None + mock_cls.assert_called_once() + + def test_async_playwright(self, api): + with patch( + "agentrun.sandbox.api.playwright_async.BrowserPlaywrightAsync" + ) as mock_cls: + mock_cls.return_value = MagicMock() + result = api.async_playwright(record=True) + assert result is not None + mock_cls.assert_called_once() + + +class TestRecordings: + + def test_list_recordings(self, api): + api.list_recordings() + api.get.assert_called_once_with("/recordings") + + @pytest.mark.asyncio + async def test_list_recordings_async(self, api): + await api.list_recordings_async() + api.get_async.assert_called_once_with("/recordings") + + def test_delete_recording(self, api): + api.delete_recording("file.mkv") + api.delete.assert_called_once_with("/recordings/file.mkv") + + @pytest.mark.asyncio + async def test_delete_recording_async(self, api): + await api.delete_recording_async("file.mkv") + api.delete_async.assert_called_once_with("/recordings/file.mkv") + + def test_download_recording(self, api): + result = api.download_recording("file.mkv", "/local/file.mkv") + api.get_video.assert_called_once_with( + "/recordings/file.mkv", save_path="/local/file.mkv" + ) + assert result["size"] == 1024 + + @pytest.mark.asyncio + async def test_download_recording_async(self, api): + result = await api.download_recording_async( + "file.mkv", "/local/file.mkv" + ) + api.get_video_async.assert_called_once_with( + "/recordings/file.mkv", save_path="/local/file.mkv" + ) + assert result["size"] == 1024 diff --git a/tests/unittests/sandbox/api/test_code_interpreter_data.py b/tests/unittests/sandbox/api/test_code_interpreter_data.py new file mode 100644 index 0000000..94ca8d6 --- /dev/null +++ b/tests/unittests/sandbox/api/test_code_interpreter_data.py @@ -0,0 +1,392 @@ +"""Tests for agentrun.sandbox.api.code_interpreter_data module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.api.code_interpreter_data import CodeInterpreterDataAPI +from agentrun.sandbox.model import CodeLanguage + + +@pytest.fixture +def api(): + with patch.object( + CodeInterpreterDataAPI, "__init__", lambda self, **kw: None + ): + obj = CodeInterpreterDataAPI.__new__(CodeInterpreterDataAPI) + obj.config = MagicMock() + obj.access_token = None + obj.access_token_map = {} + obj.resource_name = "sb-1" + obj.resource_type = None + obj.namespace = "sandboxes/sb-1" + obj.get = MagicMock(return_value={"ok": True}) + obj.get_async = AsyncMock(return_value={"ok": True}) + obj.post = MagicMock(return_value={"ok": True}) + obj.post_async = AsyncMock(return_value={"ok": True}) + obj.delete = MagicMock(return_value={"ok": True}) + obj.delete_async = AsyncMock(return_value={"ok": True}) + obj.post_file = MagicMock(return_value={"ok": True}) + obj.post_file_async = AsyncMock(return_value={"ok": True}) + obj.get_file = MagicMock(return_value={"saved_path": "/x", "size": 10}) + obj.get_file_async = AsyncMock( + return_value={"saved_path": "/x", "size": 10} + ) + return obj + + +class TestCodeInterpreterDataAPIInit: + + @patch("agentrun.sandbox.api.code_interpreter_data.SandboxDataAPI.__init__") + def test_init(self, mock_super_init): + mock_super_init.return_value = None + api = CodeInterpreterDataAPI(sandbox_id="sb-1") + mock_super_init.assert_called_once_with(sandbox_id="sb-1", config=None) + + +class TestListDirectory: + + def test_list_directory_no_params(self, api): + api.list_directory() + api.get.assert_called_once_with("/filesystem", query={}) + + def test_list_directory_with_path(self, api): + api.list_directory(path="/home") + api.get.assert_called_once_with("/filesystem", query={"path": "/home"}) + + def test_list_directory_with_depth(self, api): + api.list_directory(depth=2) + api.get.assert_called_once_with("/filesystem", query={"depth": 2}) + + def test_list_directory_with_path_and_depth(self, api): + api.list_directory(path="/home", depth=3) + api.get.assert_called_once_with( + "/filesystem", query={"path": "/home", "depth": 3} + ) + + @pytest.mark.asyncio + async def test_list_directory_async_no_params(self, api): + await api.list_directory_async() + api.get_async.assert_called_once_with("/filesystem", query={}) + + @pytest.mark.asyncio + async def test_list_directory_async_with_path_and_depth(self, api): + await api.list_directory_async(path="/tmp", depth=1) + api.get_async.assert_called_once_with( + "/filesystem", query={"path": "/tmp", "depth": 1} + ) + + +class TestStat: + + def test_stat(self, api): + api.stat("/tmp/f.txt") + api.get.assert_called_once_with( + "/filesystem/stat", query={"path": "/tmp/f.txt"} + ) + + @pytest.mark.asyncio + async def test_stat_async(self, api): + await api.stat_async("/tmp/f.txt") + api.get_async.assert_called_once_with( + "/filesystem/stat", query={"path": "/tmp/f.txt"} + ) + + +class TestMkdir: + + def test_mkdir_defaults(self, api): + api.mkdir("/tmp/dir") + api.post.assert_called_once_with( + "/filesystem/mkdir", + data={"path": "/tmp/dir", "parents": True, "mode": "0755"}, + ) + + def test_mkdir_custom(self, api): + api.mkdir("/tmp/dir", parents=False, mode="0700") + api.post.assert_called_once_with( + "/filesystem/mkdir", + data={"path": "/tmp/dir", "parents": False, "mode": "0700"}, + ) + + @pytest.mark.asyncio + async def test_mkdir_async(self, api): + await api.mkdir_async("/tmp/dir") + api.post_async.assert_called_once_with( + "/filesystem/mkdir", + data={"path": "/tmp/dir", "parents": True, "mode": "0755"}, + ) + + +class TestMoveFile: + + def test_move_file(self, api): + api.move_file("/a", "/b") + api.post.assert_called_once_with( + "/filesystem/move", data={"source": "/a", "destination": "/b"} + ) + + @pytest.mark.asyncio + async def test_move_file_async(self, api): + await api.move_file_async("/a", "/b") + api.post_async.assert_called_once_with( + "/filesystem/move", data={"source": "/a", "destination": "/b"} + ) + + +class TestRemoveFile: + + def test_remove_file(self, api): + api.remove_file("/tmp/x") + api.post.assert_called_once_with( + "/filesystem/remove", data={"path": "/tmp/x"} + ) + + @pytest.mark.asyncio + async def test_remove_file_async(self, api): + await api.remove_file_async("/tmp/x") + api.post_async.assert_called_once_with( + "/filesystem/remove", data={"path": "/tmp/x"} + ) + + +class TestContexts: + + def test_list_contexts(self, api): + api.list_contexts() + api.get.assert_called_once_with("/contexts") + + @pytest.mark.asyncio + async def test_list_contexts_async(self, api): + await api.list_contexts_async() + api.get_async.assert_called_once_with("/contexts") + + def test_create_context_default(self, api): + api.create_context() + api.post.assert_called_once_with( + "/contexts", + data={"cwd": "/home/user", "language": CodeLanguage.PYTHON}, + ) + + def test_create_context_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + api.create_context(language="ruby") + + @pytest.mark.asyncio + async def test_create_context_async_default(self, api): + await api.create_context_async() + api.post_async.assert_called_once_with( + "/contexts", + data={"cwd": "/home/user", "language": CodeLanguage.PYTHON}, + ) + + @pytest.mark.asyncio + async def test_create_context_async_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + await api.create_context_async(language="ruby") + + def test_get_context(self, api): + api.get_context("ctx-1") + api.get.assert_called_once_with("/contexts/ctx-1") + + @pytest.mark.asyncio + async def test_get_context_async(self, api): + await api.get_context_async("ctx-1") + api.get_async.assert_called_once_with("/contexts/ctx-1") + + def test_delete_context(self, api): + api.delete_context("ctx-1") + api.delete.assert_called_once_with("/contexts/ctx-1") + + @pytest.mark.asyncio + async def test_delete_context_async(self, api): + await api.delete_context_async("ctx-1") + api.delete_async.assert_called_once_with("/contexts/ctx-1") + + +class TestExecuteCode: + + def test_execute_code_minimal(self, api): + api.execute_code("print(1)", context_id=None) + api.post.assert_called_once_with( + "/contexts/execute", data={"code": "print(1)", "timeout": 30} + ) + + def test_execute_code_with_all_params(self, api): + api.execute_code( + "print(1)", + context_id="c1", + language=CodeLanguage.PYTHON, + timeout=60, + ) + call_data = api.post.call_args[1]["data"] + assert call_data["code"] == "print(1)" + assert call_data["contextId"] == "c1" + assert call_data["language"] == CodeLanguage.PYTHON + assert call_data["timeout"] == 60 + + def test_execute_code_no_timeout(self, api): + api.execute_code("print(1)", context_id=None, timeout=None) + call_data = api.post.call_args[1]["data"] + assert "timeout" not in call_data + + def test_execute_code_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + api.execute_code("code", context_id=None, language="ruby") + + @pytest.mark.asyncio + async def test_execute_code_async_minimal(self, api): + await api.execute_code_async("print(1)", context_id=None) + api.post_async.assert_called_once_with( + "/contexts/execute", data={"code": "print(1)", "timeout": 30} + ) + + @pytest.mark.asyncio + async def test_execute_code_async_with_all_params(self, api): + await api.execute_code_async( + "print(1)", + context_id="c1", + language=CodeLanguage.PYTHON, + timeout=60, + ) + call_data = api.post_async.call_args[1]["data"] + assert call_data["contextId"] == "c1" + assert call_data["language"] == CodeLanguage.PYTHON + + @pytest.mark.asyncio + async def test_execute_code_async_invalid_language(self, api): + with pytest.raises(ValueError, match="language must be"): + await api.execute_code_async( + "code", context_id=None, language="ruby" + ) + + +class TestFiles: + + def test_read_file(self, api): + api.read_file("/tmp/f.txt") + api.get.assert_called_once_with("/files", query={"path": "/tmp/f.txt"}) + + @pytest.mark.asyncio + async def test_read_file_async(self, api): + await api.read_file_async("/tmp/f.txt") + api.get_async.assert_called_once_with( + "/files", query={"path": "/tmp/f.txt"} + ) + + def test_write_file_defaults(self, api): + api.write_file("/tmp/f.txt", "content") + api.post.assert_called_once_with( + "/files", + data={ + "path": "/tmp/f.txt", + "content": "content", + "mode": "644", + "encoding": "utf-8", + "createDir": True, + }, + ) + + def test_write_file_custom(self, api): + api.write_file( + "/tmp/f.txt", "data", mode="755", encoding="ascii", create_dir=False + ) + call_data = api.post.call_args[1]["data"] + assert call_data["mode"] == "755" + assert call_data["encoding"] == "ascii" + assert call_data["createDir"] is False + + @pytest.mark.asyncio + async def test_write_file_async(self, api): + await api.write_file_async("/tmp/f.txt", "content") + api.post_async.assert_called_once() + + def test_upload_file(self, api): + api.upload_file("/local/f", "/remote/f") + api.post_file.assert_called_once_with( + path="/filesystem/upload", + local_file_path="/local/f", + target_file_path="/remote/f", + ) + + @pytest.mark.asyncio + async def test_upload_file_async(self, api): + await api.upload_file_async("/local/f", "/remote/f") + api.post_file_async.assert_called_once_with( + path="/filesystem/upload", + local_file_path="/local/f", + target_file_path="/remote/f", + ) + + def test_download_file(self, api): + api.download_file("/remote/f", "/local/f") + api.get_file.assert_called_once_with( + path="/filesystem/download", + save_path="/local/f", + query={"path": "/remote/f"}, + ) + + @pytest.mark.asyncio + async def test_download_file_async(self, api): + await api.download_file_async("/remote/f", "/local/f") + api.get_file_async.assert_called_once_with( + path="/filesystem/download", + save_path="/local/f", + query={"path": "/remote/f"}, + ) + + +class TestProcesses: + + def test_cmd(self, api): + api.cmd("ls", "/home") + api.post.assert_called_once_with( + "/processes/cmd", + data={"command": "ls", "cwd": "/home", "timeout": 30}, + ) + + def test_cmd_no_timeout(self, api): + api.cmd("ls", "/home", timeout=None) + call_data = api.post.call_args[1]["data"] + assert "timeout" not in call_data + + @pytest.mark.asyncio + async def test_cmd_async(self, api): + await api.cmd_async("ls", "/home") + api.post_async.assert_called_once_with( + "/processes/cmd", + data={"command": "ls", "cwd": "/home", "timeout": 30}, + ) + + @pytest.mark.asyncio + async def test_cmd_async_no_timeout(self, api): + await api.cmd_async("ls", "/home", timeout=None) + call_data = api.post_async.call_args[1]["data"] + assert "timeout" not in call_data + + def test_list_processes(self, api): + api.list_processes() + api.get.assert_called_once_with("/processes") + + @pytest.mark.asyncio + async def test_list_processes_async(self, api): + await api.list_processes_async() + api.get_async.assert_called_once_with("/processes") + + def test_get_process(self, api): + api.get_process("123") + api.get.assert_called_once_with("/processes/123") + + @pytest.mark.asyncio + async def test_get_process_async(self, api): + await api.get_process_async("123") + api.get_async.assert_called_once_with("/processes/123") + + def test_kill_process(self, api): + api.kill_process("123") + api.delete.assert_called_once_with("/processes/123") + + @pytest.mark.asyncio + async def test_kill_process_async(self, api): + await api.kill_process_async("123") + api.delete_async.assert_called_once_with("/processes/123") diff --git a/tests/unittests/sandbox/api/test_playwright_async.py b/tests/unittests/sandbox/api/test_playwright_async.py new file mode 100644 index 0000000..144c5ee --- /dev/null +++ b/tests/unittests/sandbox/api/test_playwright_async.py @@ -0,0 +1,349 @@ +"""Tests for agentrun.sandbox.api.playwright_async module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_playwright(): + with patch( + "agentrun.sandbox.api.playwright_async.async_playwright" + ) as mock_ap: + mock_pw_instance = MagicMock() + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + + mock_page.bring_to_front = AsyncMock() + mock_page.context = mock_context + mock_page.goto = AsyncMock(return_value=None) + mock_page.click = AsyncMock() + mock_page.dblclick = AsyncMock() + mock_page.drag_and_drop = AsyncMock() + mock_page.fill = AsyncMock() + mock_page.hover = AsyncMock() + mock_page.type = AsyncMock() + mock_page.go_forward = AsyncMock(return_value=None) + mock_page.go_back = AsyncMock(return_value=None) + mock_page.evaluate = AsyncMock(return_value="result") + mock_page.wait_for_timeout = AsyncMock() + mock_page.content = AsyncMock(return_value="") + mock_page.screenshot = AsyncMock(return_value=b"image") + mock_page.title = AsyncMock(return_value="Title") + mock_page.close = AsyncMock() + + mock_context.pages = [mock_page] + mock_context.new_page = AsyncMock(return_value=mock_page) + + mock_browser.contexts = [mock_context] + mock_browser.new_context = AsyncMock(return_value=mock_context) + mock_browser.close = AsyncMock() + + mock_pw_instance.chromium = MagicMock() + mock_pw_instance.chromium.connect_over_cdp = AsyncMock( + return_value=mock_browser + ) + mock_pw_instance.stop = AsyncMock() + + mock_cm = MagicMock() + mock_cm.start = AsyncMock(return_value=mock_pw_instance) + mock_ap.return_value = mock_cm + + yield { + "ap": mock_ap, + "pw_instance": mock_pw_instance, + "browser": mock_browser, + "context": mock_context, + "page": mock_page, + } + + +@pytest.fixture +def pw(mock_playwright): + from agentrun.sandbox.api.playwright_async import BrowserPlaywrightAsync + + return BrowserPlaywrightAsync( + url="ws://example.com/ws/automation", + browser_type="chrome", + headers={"Authorization": "Bearer tok"}, + ) + + +class TestInit: + + def test_constructor(self, mock_playwright): + from agentrun.sandbox.api.playwright_async import BrowserPlaywrightAsync + + obj = BrowserPlaywrightAsync( + "ws://test", browser_type="firefox", headers={"x": "1"} + ) + assert obj.url == "ws://test" + assert obj.browser_type == "firefox" + assert obj.auto_close_browser is False + assert obj.auto_close_page is False + assert obj._browser is None + assert obj._page is None + + +class TestOpenClose: + + @pytest.mark.asyncio + async def test_open(self, pw, mock_playwright): + result = await pw.open() + assert result is pw + assert pw._browser is mock_playwright["browser"] + + @pytest.mark.asyncio + async def test_open_already_connected(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = await pw.open() + assert result is pw + mock_playwright[ + "pw_instance" + ].chromium.connect_over_cdp.assert_not_called() + + @pytest.mark.asyncio + async def test_close_with_auto_close(self, pw, mock_playwright): + pw.auto_close_page = True + pw.auto_close_browser = True + pw._page = mock_playwright["page"] + pw._browser = mock_playwright["browser"] + pw._playwright_instance = mock_playwright["pw_instance"] + + await pw.close() + mock_playwright["page"].close.assert_awaited_once() + mock_playwright["browser"].close.assert_awaited_once() + mock_playwright["pw_instance"].stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_close_without_auto_close(self, pw, mock_playwright): + pw._playwright_instance = mock_playwright["pw_instance"] + await pw.close() + mock_playwright["pw_instance"].stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_close_no_playwright_instance(self, pw): + await pw.close() + + +class TestContextManager: + + @pytest.mark.asyncio + async def test_aenter(self, pw, mock_playwright): + result = await pw.__aenter__() + assert result is pw + + @pytest.mark.asyncio + async def test_aexit(self, pw, mock_playwright): + pw._playwright_instance = mock_playwright["pw_instance"] + await pw.__aexit__(None, None, None) + mock_playwright["pw_instance"].stop.assert_awaited_once() + + +class TestEnsure: + + @pytest.mark.asyncio + async def test_ensure_browser_cached(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = await pw.ensure_browser() + assert result is mock_playwright["browser"] + + @pytest.mark.asyncio + async def test_ensure_browser_opens(self, pw, mock_playwright): + result = await pw.ensure_browser() + assert result is mock_playwright["browser"] + + @pytest.mark.asyncio + async def test_ensure_context_cached(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + result = await pw.ensure_context() + assert result is mock_playwright["context"] + + @pytest.mark.asyncio + async def test_ensure_context_from_browser(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = await pw.ensure_context() + assert result is mock_playwright["context"] + + @pytest.mark.asyncio + async def test_ensure_context_no_contexts(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + mock_playwright["browser"].contexts = [] + result = await pw.ensure_context() + mock_playwright["browser"].new_context.assert_awaited_once() + + @pytest.mark.asyncio + async def test_ensure_page_cached(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + result = await pw.ensure_page() + assert result is mock_playwright["page"] + mock_playwright["page"].bring_to_front.assert_awaited() + + @pytest.mark.asyncio + async def test_ensure_page_from_context(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + result = await pw.ensure_page() + assert result is mock_playwright["page"] + + @pytest.mark.asyncio + async def test_ensure_page_no_pages(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + mock_playwright["context"].pages = [] + result = await pw.ensure_page() + mock_playwright["context"].new_page.assert_awaited_once() + + +class TestPageOps: + + @pytest.mark.asyncio + async def test_use_page(self, pw, mock_playwright): + page = mock_playwright["page"] + result = await pw._use_page(page) + assert result is page + assert pw._page is page + + @pytest.mark.asyncio + async def test_list_pages(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pages = await pw.list_pages() + assert len(pages) == 1 + + @pytest.mark.asyncio + async def test_new_page(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + result = await pw.new_page() + assert result is mock_playwright["page"] + + @pytest.mark.asyncio + async def test_select_tab_valid(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = await pw.select_tab(0) + assert result is mock_playwright["page"] + + @pytest.mark.asyncio + async def test_select_tab_invalid(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + with pytest.raises(IndexError, match="Tab index out of range"): + await pw.select_tab(99) + + +class TestNavigationAndActions: + + @pytest.mark.asyncio + async def test_goto(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.goto("https://example.com") + mock_playwright["page"].goto.assert_awaited_once() + + @pytest.mark.asyncio + async def test_click(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.click("#btn") + mock_playwright["page"].click.assert_awaited_once() + + @pytest.mark.asyncio + async def test_drag_and_drop(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.drag_and_drop("#src", "#dst") + mock_playwright["page"].drag_and_drop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_dblclick(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.dblclick("#btn") + mock_playwright["page"].dblclick.assert_awaited_once() + + @pytest.mark.asyncio + async def test_fill(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.fill("#input", "text") + mock_playwright["page"].fill.assert_awaited_once() + + @pytest.mark.asyncio + async def test_hover(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.hover("#elem") + mock_playwright["page"].hover.assert_awaited_once() + + @pytest.mark.asyncio + async def test_type(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.type("#input", "text") + mock_playwright["page"].type.assert_awaited_once() + + @pytest.mark.asyncio + async def test_go_forward(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.go_forward() + mock_playwright["page"].go_forward.assert_awaited_once() + + @pytest.mark.asyncio + async def test_go_back(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.go_back() + mock_playwright["page"].go_back.assert_awaited_once() + + @pytest.mark.asyncio + async def test_evaluate(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + result = await pw.evaluate("1+1") + assert result == "result" + + @pytest.mark.asyncio + async def test_wait(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + await pw.wait(1000) + mock_playwright["page"].wait_for_timeout.assert_awaited_once() + + @pytest.mark.asyncio + async def test_html_content(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + result = await pw.html_content() + assert result == "" + + @pytest.mark.asyncio + async def test_screenshot(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + result = await pw.screenshot() + assert result == b"image" + + @pytest.mark.asyncio + async def test_title(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + result = await pw.title() + assert result == "Title" diff --git a/tests/unittests/sandbox/api/test_playwright_sync.py b/tests/unittests/sandbox/api/test_playwright_sync.py new file mode 100644 index 0000000..7eca5ba --- /dev/null +++ b/tests/unittests/sandbox/api/test_playwright_sync.py @@ -0,0 +1,277 @@ +"""Tests for agentrun.sandbox.api.playwright_sync module.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_playwright(): + with patch( + "agentrun.sandbox.api.playwright_sync.sync_playwright" + ) as mock_sp: + mock_pw_instance = MagicMock() + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + + mock_page.context = mock_context + mock_page.goto.return_value = None + mock_page.content.return_value = "" + mock_page.screenshot.return_value = b"image" + mock_page.title.return_value = "Title" + mock_page.evaluate.return_value = "result" + + mock_context.pages = [mock_page] + mock_context.new_page.return_value = mock_page + + mock_browser.contexts = [mock_context] + mock_browser.new_context.return_value = mock_context + + mock_pw_instance.chromium = MagicMock() + mock_pw_instance.chromium.connect_over_cdp.return_value = mock_browser + + mock_cm = MagicMock() + mock_cm.start.return_value = mock_pw_instance + mock_sp.return_value = mock_cm + + yield { + "sp": mock_sp, + "pw_instance": mock_pw_instance, + "browser": mock_browser, + "context": mock_context, + "page": mock_page, + } + + +@pytest.fixture +def pw(mock_playwright): + from agentrun.sandbox.api.playwright_sync import BrowserPlaywrightSync + + return BrowserPlaywrightSync( + url="ws://example.com/ws/automation", + browser_type="chrome", + headers={"Authorization": "Bearer tok"}, + ) + + +class TestInit: + + def test_constructor(self, mock_playwright): + from agentrun.sandbox.api.playwright_sync import BrowserPlaywrightSync + + obj = BrowserPlaywrightSync( + "ws://test", browser_type="firefox", headers={"x": "1"} + ) + assert obj.url == "ws://test" + assert obj.browser_type == "firefox" + assert obj.auto_close_browser is False + assert obj.auto_close_page is False + assert obj._browser is None + assert obj._page is None + + +class TestOpenClose: + + def test_open(self, pw, mock_playwright): + result = pw.open() + assert result is pw + assert pw._browser is mock_playwright["browser"] + + def test_open_already_connected(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = pw.open() + assert result is pw + mock_playwright[ + "pw_instance" + ].chromium.connect_over_cdp.assert_not_called() + + def test_close_with_auto_close(self, pw, mock_playwright): + pw.auto_close_page = True + pw.auto_close_browser = True + pw._page = mock_playwright["page"] + pw._browser = mock_playwright["browser"] + pw._playwright_instance = mock_playwright["pw_instance"] + + pw.close() + mock_playwright["page"].close.assert_called_once() + mock_playwright["browser"].close.assert_called_once() + mock_playwright["pw_instance"].stop.assert_called_once() + + def test_close_without_auto_close(self, pw, mock_playwright): + pw._playwright_instance = mock_playwright["pw_instance"] + pw.close() + mock_playwright["pw_instance"].stop.assert_called_once() + + def test_close_no_playwright_instance(self, pw): + pw.close() + + +class TestContextManager: + + def test_enter(self, pw, mock_playwright): + result = pw.__enter__() + assert result is pw + + def test_exit(self, pw, mock_playwright): + pw._playwright_instance = mock_playwright["pw_instance"] + pw.__exit__(None, None, None) + mock_playwright["pw_instance"].stop.assert_called_once() + + +class TestEnsure: + + def test_ensure_browser_cached(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = pw.ensure_browser() + assert result is mock_playwright["browser"] + + def test_ensure_browser_opens(self, pw, mock_playwright): + result = pw.ensure_browser() + assert result is mock_playwright["browser"] + + def test_ensure_context_cached(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + result = pw.ensure_context() + assert result is mock_playwright["context"] + + def test_ensure_context_from_browser(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = pw.ensure_context() + assert result is mock_playwright["context"] + + def test_ensure_context_no_contexts(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + mock_playwright["browser"].contexts = [] + result = pw.ensure_context() + mock_playwright["browser"].new_context.assert_called_once() + + def test_ensure_page_cached(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + result = pw.ensure_page() + assert result is mock_playwright["page"] + mock_playwright["page"].bring_to_front.assert_called() + + def test_ensure_page_from_context(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + result = pw.ensure_page() + assert result is mock_playwright["page"] + + def test_ensure_page_no_pages(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + mock_playwright["context"].pages = [] + result = pw.ensure_page() + mock_playwright["context"].new_page.assert_called_once() + + +class TestPageOps: + + def test_use_page(self, pw, mock_playwright): + page = mock_playwright["page"] + result = pw._use_page(page) + assert result is page + assert pw._page is page + + def test_list_pages(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pages = pw.list_pages() + assert len(pages) == 1 + + def test_new_page(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + result = pw.new_page() + assert result is mock_playwright["page"] + + def test_select_tab_valid(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + result = pw.select_tab(0) + assert result is mock_playwright["page"] + + def test_select_tab_invalid(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + with pytest.raises(IndexError, match="Tab index out of range"): + pw.select_tab(99) + + +class TestNavigationAndActions: + + def _setup(self, pw, mock_playwright): + pw._browser = mock_playwright["browser"] + pw._context = mock_playwright["context"] + pw._page = mock_playwright["page"] + + def test_goto(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.goto("https://example.com") + mock_playwright["page"].goto.assert_called_once() + + def test_click(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.click("#btn") + mock_playwright["page"].click.assert_called_once() + + def test_drag_and_drop(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.drag_and_drop("#src", "#dst") + mock_playwright["page"].drag_and_drop.assert_called_once() + + def test_dblclick(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.dblclick("#btn") + mock_playwright["page"].dblclick.assert_called_once() + + def test_fill(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.fill("#input", "text") + mock_playwright["page"].fill.assert_called_once() + + def test_hover(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.hover("#elem") + mock_playwright["page"].hover.assert_called_once() + + def test_type(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.type("#input", "text") + mock_playwright["page"].type.assert_called_once() + + def test_go_forward(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.go_forward() + mock_playwright["page"].go_forward.assert_called_once() + + def test_go_back(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.go_back() + mock_playwright["page"].go_back.assert_called_once() + + def test_evaluate(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + result = pw.evaluate("1+1") + assert result == "result" + + def test_wait(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + pw.wait(1000) + mock_playwright["page"].wait_for_timeout.assert_called_once() + + def test_html_content(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + result = pw.html_content() + assert result == "" + + def test_screenshot(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + result = pw.screenshot() + assert result == b"image" + + def test_title(self, pw, mock_playwright): + self._setup(pw, mock_playwright) + result = pw.title() + assert result == "Title" diff --git a/tests/unittests/sandbox/api/test_sandbox_data.py b/tests/unittests/sandbox/api/test_sandbox_data.py new file mode 100644 index 0000000..b68f422 --- /dev/null +++ b/tests/unittests/sandbox/api/test_sandbox_data.py @@ -0,0 +1,181 @@ +"""Tests for agentrun.sandbox.api.sandbox_data module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.api.sandbox_data import SandboxDataAPI + + +@pytest.fixture +def api(): + with patch.object(SandboxDataAPI, "__init__", lambda self, **kw: None): + obj = SandboxDataAPI.__new__(SandboxDataAPI) + obj.access_token_map = {} + obj.access_token = None + obj.resource_name = "" + obj.resource_type = None + obj.namespace = "sandboxes" + obj.config = MagicMock() + obj.get = MagicMock(return_value={"status": "ok"}) + obj.get_async = AsyncMock(return_value={"status": "ok"}) + obj.post = MagicMock(return_value={"code": "SUCCESS"}) + obj.post_async = AsyncMock(return_value={"code": "SUCCESS"}) + obj.delete = MagicMock(return_value={"code": "SUCCESS"}) + obj.delete_async = AsyncMock(return_value={"code": "SUCCESS"}) + obj.auth = MagicMock(return_value=("token", {}, None)) + return obj + + +class TestSandboxDataAPIInit: + + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.__init__") + def test_init_without_sandbox_id(self, mock_init): + mock_init.return_value = None + api = SandboxDataAPI() + assert api.access_token_map == {} + + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.__init__") + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.auth") + def test_init_with_sandbox_id(self, mock_auth, mock_init): + mock_init.return_value = None + mock_auth.return_value = None + api = SandboxDataAPI.__new__(SandboxDataAPI) + api.config = None + api.access_token = None + SandboxDataAPI.__init__(api, sandbox_id="sb-1") + assert api.resource_name == "sb-1" + + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.__init__") + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.auth") + def test_init_with_template_name(self, mock_auth, mock_init): + mock_init.return_value = None + mock_auth.return_value = None + api = SandboxDataAPI.__new__(SandboxDataAPI) + api.config = None + api.access_token = None + SandboxDataAPI.__init__(api, template_name="tpl-1") + assert api.resource_name == "tpl-1" + + +class TestSandboxDataAPIRefreshToken: + + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.__init__") + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.auth") + def test_refresh_with_cached_token(self, mock_auth, mock_init): + mock_init.return_value = None + api = SandboxDataAPI() + api.access_token_map = {"sb-1": "cached-token"} + api.config = MagicMock() + api._SandboxDataAPI__refresh_access_token(sandbox_id="sb-1") + assert api.access_token == "cached-token" + + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.__init__") + @patch("agentrun.sandbox.api.sandbox_data.DataAPI.auth") + def test_refresh_template_name(self, mock_auth, mock_init): + mock_init.return_value = None + mock_auth.return_value = None + api = SandboxDataAPI() + api.access_token_map = {} + api.access_token = None + api.config = MagicMock() + api._SandboxDataAPI__refresh_access_token(template_name="tpl-1") + assert api.resource_name == "tpl-1" + assert api.namespace == "sandboxes" + + +class TestSandboxDataAPIHealthCheck: + + def test_check_health(self, api): + result = api.check_health() + api.get.assert_called_once_with("/health") + assert result == {"status": "ok"} + + @pytest.mark.asyncio + async def test_check_health_async(self, api): + result = await api.check_health_async() + api.get_async.assert_called_once_with("/health") + assert result == {"status": "ok"} + + +class TestSandboxDataAPICreateSandbox: + + def test_create_sandbox_minimal(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + result = api.create_sandbox("tpl-1") + api.post.assert_called_once() + + @pytest.mark.asyncio + async def test_create_sandbox_async_minimal(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + result = await api.create_sandbox_async("tpl-1") + api.post_async.assert_called_once() + + def test_create_sandbox_with_all_options(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + api.create_sandbox( + "tpl-1", + sandbox_idle_timeout_seconds=1200, + sandbox_id="sb-custom", + nas_config={"groupId": 1000}, + oss_mount_config={"buckets": []}, + polar_fs_config={"userId": 1000}, + ) + call_data = api.post.call_args + data = ( + call_data[1].get("data") or call_data[0][1] + if len(call_data[0]) > 1 + else call_data[1]["data"] + ) + assert "sandboxId" in data + assert "nasConfig" in data + assert "ossMountConfig" in data + assert "polarFsConfig" in data + + @pytest.mark.asyncio + async def test_create_sandbox_async_with_all_options(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + await api.create_sandbox_async( + "tpl-1", + sandbox_id="sb-custom", + nas_config={"groupId": 1000}, + oss_mount_config={"buckets": []}, + polar_fs_config={"userId": 1000}, + ) + api.post_async.assert_called_once() + + +class TestSandboxDataAPICRUD: + + def test_delete_sandbox(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + api.delete_sandbox("sb-1") + api.delete.assert_called_once_with("/", config=None) + + @pytest.mark.asyncio + async def test_delete_sandbox_async(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + await api.delete_sandbox_async("sb-1") + api.delete_async.assert_called_once_with("/", config=None) + + def test_stop_sandbox(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + api.stop_sandbox("sb-1") + api.post.assert_called_once_with("/stop", config=None) + + @pytest.mark.asyncio + async def test_stop_sandbox_async(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + await api.stop_sandbox_async("sb-1") + api.post_async.assert_called_once_with("/stop", config=None) + + def test_get_sandbox(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + api.get_sandbox("sb-1") + api.get.assert_called_once_with("/", config=None) + + @pytest.mark.asyncio + async def test_get_sandbox_async(self, api): + api._SandboxDataAPI__refresh_access_token = MagicMock() + await api.get_sandbox_async("sb-1") + api.get_async.assert_called_once_with("/", config=None) diff --git a/tests/unittests/sandbox/test_aio_sandbox.py b/tests/unittests/sandbox/test_aio_sandbox.py new file mode 100644 index 0000000..7d4c33e --- /dev/null +++ b/tests/unittests/sandbox/test_aio_sandbox.py @@ -0,0 +1,663 @@ +"""Tests for agentrun.sandbox.aio_sandbox module.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.aio_sandbox import ( + AioSandbox, + ContextOperations, + FileOperations, + FileSystemOperations, + ProcessOperations, +) +from agentrun.sandbox.model import CodeLanguage, TemplateType +from agentrun.utils.exception import ServerError + + +def _make_sandbox(sandbox_id="sb-aio-1"): + sb = AioSandbox.model_construct(sandbox_id=sandbox_id) + sb._data_api = MagicMock() + return sb + + +# ==================== FileOperations ==================== + + +class TestAioFileOperations: + + def test_read(self): + sb = _make_sandbox() + sb.data_api.read_file.return_value = "data" + ops = FileOperations(sb) + assert ops.read("/f") == "data" + + @pytest.mark.asyncio + async def test_read_async(self): + sb = _make_sandbox() + sb.data_api.read_file_async = AsyncMock(return_value="data") + ops = FileOperations(sb) + assert await ops.read_async("/f") == "data" + + def test_write(self): + sb = _make_sandbox() + sb.data_api.write_file.return_value = {"ok": True} + ops = FileOperations(sb) + assert ops.write( + "/f", "content", mode="755", encoding="ascii", create_dir=False + ) == {"ok": True} + + @pytest.mark.asyncio + async def test_write_async(self): + sb = _make_sandbox() + sb.data_api.write_file_async = AsyncMock(return_value={"ok": True}) + ops = FileOperations(sb) + assert await ops.write_async("/f", "content") == {"ok": True} + + +# ==================== FileSystemOperations ==================== + + +class TestAioFileSystemOperations: + + def test_list(self): + sb = _make_sandbox() + sb.data_api.list_directory.return_value = [] + ops = FileSystemOperations(sb) + assert ops.list(path="/home", depth=2) == [] + + @pytest.mark.asyncio + async def test_list_async(self): + sb = _make_sandbox() + sb.data_api.list_directory_async = AsyncMock(return_value=[]) + assert await FileSystemOperations(sb).list_async() == [] + + def test_move(self): + sb = _make_sandbox() + sb.data_api.move_file.return_value = {"ok": True} + assert FileSystemOperations(sb).move("/a", "/b") == {"ok": True} + + @pytest.mark.asyncio + async def test_move_async(self): + sb = _make_sandbox() + sb.data_api.move_file_async = AsyncMock(return_value={"ok": True}) + assert await FileSystemOperations(sb).move_async("/a", "/b") == { + "ok": True + } + + def test_remove(self): + sb = _make_sandbox() + sb.data_api.remove_file.return_value = {"ok": True} + assert FileSystemOperations(sb).remove("/x") == {"ok": True} + + @pytest.mark.asyncio + async def test_remove_async(self): + sb = _make_sandbox() + sb.data_api.remove_file_async = AsyncMock(return_value={"ok": True}) + assert await FileSystemOperations(sb).remove_async("/x") == {"ok": True} + + def test_stat(self): + sb = _make_sandbox() + sb.data_api.stat.return_value = {"size": 10} + assert FileSystemOperations(sb).stat("/x") == {"size": 10} + + @pytest.mark.asyncio + async def test_stat_async(self): + sb = _make_sandbox() + sb.data_api.stat_async = AsyncMock(return_value={"size": 10}) + assert await FileSystemOperations(sb).stat_async("/x") == {"size": 10} + + def test_mkdir(self): + sb = _make_sandbox() + sb.data_api.mkdir.return_value = {"ok": True} + assert FileSystemOperations(sb).mkdir( + "/d", parents=False, mode="0700" + ) == {"ok": True} + + @pytest.mark.asyncio + async def test_mkdir_async(self): + sb = _make_sandbox() + sb.data_api.mkdir_async = AsyncMock(return_value={"ok": True}) + assert await FileSystemOperations(sb).mkdir_async("/d") == {"ok": True} + + def test_upload(self): + sb = _make_sandbox() + sb.data_api.upload_file.return_value = {"ok": True} + assert FileSystemOperations(sb).upload("/l", "/r") == {"ok": True} + + @pytest.mark.asyncio + async def test_upload_async(self): + sb = _make_sandbox() + sb.data_api.upload_file_async = AsyncMock(return_value={"ok": True}) + assert await FileSystemOperations(sb).upload_async("/l", "/r") == { + "ok": True + } + + def test_download(self): + sb = _make_sandbox() + sb.data_api.download_file.return_value = {"saved_path": "/x"} + assert FileSystemOperations(sb).download("/r", "/l") == { + "saved_path": "/x" + } + + @pytest.mark.asyncio + async def test_download_async(self): + sb = _make_sandbox() + sb.data_api.download_file_async = AsyncMock( + return_value={"saved_path": "/x"} + ) + assert await FileSystemOperations(sb).download_async("/r", "/l") == { + "saved_path": "/x" + } + + +# ==================== ProcessOperations ==================== + + +class TestAioProcessOperations: + + def test_cmd(self): + sb = _make_sandbox() + sb.data_api.cmd.return_value = {"exit_code": 0} + assert ProcessOperations(sb).cmd("ls", "/home") == {"exit_code": 0} + + @pytest.mark.asyncio + async def test_cmd_async(self): + sb = _make_sandbox() + sb.data_api.cmd_async = AsyncMock(return_value={"exit_code": 0}) + assert await ProcessOperations(sb).cmd_async("ls", "/home") == { + "exit_code": 0 + } + + def test_list(self): + sb = _make_sandbox() + sb.data_api.list_processes.return_value = [] + assert ProcessOperations(sb).list() == [] + + @pytest.mark.asyncio + async def test_list_async(self): + sb = _make_sandbox() + sb.data_api.list_processes_async = AsyncMock(return_value=[]) + assert await ProcessOperations(sb).list_async() == [] + + def test_get(self): + sb = _make_sandbox() + sb.data_api.get_process.return_value = {"pid": "1"} + assert ProcessOperations(sb).get("1") == {"pid": "1"} + + @pytest.mark.asyncio + async def test_get_async(self): + sb = _make_sandbox() + sb.data_api.get_process_async = AsyncMock(return_value={"pid": "1"}) + assert await ProcessOperations(sb).get_async("1") == {"pid": "1"} + + def test_kill(self): + sb = _make_sandbox() + sb.data_api.kill_process.return_value = {"ok": True} + assert ProcessOperations(sb).kill("1") == {"ok": True} + + @pytest.mark.asyncio + async def test_kill_async(self): + sb = _make_sandbox() + sb.data_api.kill_process_async = AsyncMock(return_value={"ok": True}) + assert await ProcessOperations(sb).kill_async("1") == {"ok": True} + + +# ==================== ContextOperations ==================== + + +class TestAioContextOperations: + + def _make_ctx_ops(self): + sb = _make_sandbox() + return ContextOperations(sb), sb + + def test_context_id_default_none(self): + ops, _ = self._make_ctx_ops() + assert ops.context_id is None + + def test_list(self): + ops, sb = self._make_ctx_ops() + sb.data_api.list_contexts.return_value = [] + assert ops.list() == [] + + @pytest.mark.asyncio + async def test_list_async(self): + ops, sb = self._make_ctx_ops() + sb.data_api.list_contexts_async = AsyncMock(return_value=[]) + assert await ops.list_async() == [] + + def test_create_success(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context.return_value = { + "id": "c1", + "cwd": "/home", + "language": "python", + } + result = ops.create() + assert result is ops + assert ops.context_id == "c1" + + def test_create_failure(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context.return_value = {} + with pytest.raises(ServerError): + ops.create() + + @pytest.mark.asyncio + async def test_create_async_success(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context_async = AsyncMock( + return_value={"id": "c1", "cwd": "/home", "language": "python"} + ) + result = await ops.create_async() + assert result is ops + + @pytest.mark.asyncio + async def test_create_async_failure(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context_async = AsyncMock(return_value={}) + with pytest.raises(ServerError): + await ops.create_async() + + def test_get_with_id(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context.return_value = { + "id": "c1", + "cwd": "/x", + "language": "python", + } + ops.get(context_id="c1") + assert ops.context_id == "c1" + + def test_get_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context id is not set"): + ops.get() + + def test_get_failure(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context.return_value = {} + with pytest.raises(ServerError): + ops.get(context_id="c1") + + @pytest.mark.asyncio + async def test_get_async_with_id(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context_async = AsyncMock( + return_value={"id": "c1", "cwd": "/x", "language": "python"} + ) + await ops.get_async(context_id="c1") + assert ops.context_id == "c1" + + @pytest.mark.asyncio + async def test_get_async_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context id is not set"): + await ops.get_async() + + @pytest.mark.asyncio + async def test_get_async_failure(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context_async = AsyncMock(return_value={}) + with pytest.raises(ServerError): + await ops.get_async(context_id="c1") + + def test_execute_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.execute_code.return_value = {"result": "ok"} + ops.execute("code") + sb.data_api.execute_code.assert_called_once_with( + context_id="c1", language=None, code="code", timeout=30 + ) + + def test_execute_defaults_python(self): + ops, sb = self._make_ctx_ops() + sb.data_api.execute_code.return_value = {"result": "ok"} + ops.execute("code") + sb.data_api.execute_code.assert_called_once_with( + context_id=None, + language=CodeLanguage.PYTHON, + code="code", + timeout=30, + ) + + @pytest.mark.asyncio + async def test_execute_async_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.execute_code_async = AsyncMock( + return_value={"result": "ok"} + ) + await ops.execute_async("code") + sb.data_api.execute_code_async.assert_called_once_with( + context_id="c1", language=None, code="code", timeout=30 + ) + + @pytest.mark.asyncio + async def test_execute_async_defaults_python(self): + ops, sb = self._make_ctx_ops() + sb.data_api.execute_code_async = AsyncMock( + return_value={"result": "ok"} + ) + await ops.execute_async("code") + sb.data_api.execute_code_async.assert_called_once_with( + context_id=None, + language=CodeLanguage.PYTHON, + code="code", + timeout=30, + ) + + def test_delete_with_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context.return_value = {"ok": True} + ops.delete() + assert ops._context_id is None + + def test_delete_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context_id is required"): + ops.delete() + + @pytest.mark.asyncio + async def test_delete_async_with_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context_async = AsyncMock(return_value={"ok": True}) + await ops.delete_async() + assert ops._context_id is None + + @pytest.mark.asyncio + async def test_delete_async_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context_id is required"): + await ops.delete_async() + + def test_enter_with_context(self): + ops, _ = self._make_ctx_ops() + ops._context_id = "c1" + assert ops.__enter__() is ops + + def test_enter_no_context_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="No context has been created"): + ops.__enter__() + + @pytest.mark.asyncio + async def test_aenter_with_context(self): + ops, _ = self._make_ctx_ops() + ops._context_id = "c1" + assert await ops.__aenter__() is ops + + @pytest.mark.asyncio + async def test_aenter_no_context_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="No context has been created"): + await ops.__aenter__() + + def test_exit_with_context(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context.return_value = {"ok": True} + assert ops.__exit__(None, None, None) is False + + def test_exit_no_context(self): + ops, _ = self._make_ctx_ops() + assert ops.__exit__(None, None, None) is False + + def test_exit_delete_fails(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context.side_effect = Exception("fail") + assert ops.__exit__(None, None, None) is False + + @pytest.mark.asyncio + async def test_aexit_with_context(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context_async = AsyncMock(return_value={"ok": True}) + assert await ops.__aexit__(None, None, None) is False + + @pytest.mark.asyncio + async def test_aexit_no_context(self): + ops, _ = self._make_ctx_ops() + assert await ops.__aexit__(None, None, None) is False + + @pytest.mark.asyncio + async def test_aexit_delete_fails(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context_async = AsyncMock( + side_effect=Exception("fail") + ) + assert await ops.__aexit__(None, None, None) is False + + +# ==================== AioSandbox ==================== + + +class TestAioSandbox: + + def test_template_type(self): + assert ( + AioSandbox.__private_attributes__["_template_type"].default + == TemplateType.AIO + ) + + def test_data_api_lazy_init(self): + sb = AioSandbox.model_construct(sandbox_id="sb-1") + with patch("agentrun.sandbox.aio_sandbox.AioDataAPI") as mock_cls: + mock_cls.return_value = MagicMock() + api = sb.data_api + assert api is not None + assert sb.data_api is api + + def test_data_api_no_sandbox_id_raises(self): + sb = AioSandbox.model_construct(sandbox_id=None) + sb._data_api = None + with pytest.raises(ValueError, match="Sandbox ID is not set"): + _ = sb.data_api + + def test_check_health(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "ok"} + assert sb.check_health() == {"status": "ok"} + + @pytest.mark.asyncio + async def test_check_health_async(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "ok"} + ) + assert await sb.check_health_async() == {"status": "ok"} + + def test_get_cdp_url(self): + sb = _make_sandbox() + sb.data_api.get_cdp_url.return_value = "ws://url" + assert sb.get_cdp_url() == "ws://url" + + def test_get_vnc_url(self): + sb = _make_sandbox() + sb.data_api.get_vnc_url.return_value = "ws://vnc" + assert sb.get_vnc_url() == "ws://vnc" + + def test_sync_playwright(self): + sb = _make_sandbox() + sb.data_api.sync_playwright.return_value = MagicMock() + assert sb.sync_playwright() is not None + + def test_async_playwright(self): + sb = _make_sandbox() + sb.data_api.async_playwright.return_value = MagicMock() + assert sb.async_playwright() is not None + + @pytest.mark.asyncio + async def test_list_recordings_async(self): + sb = _make_sandbox() + sb.data_api.list_recordings_async = AsyncMock(return_value=[]) + assert await sb.list_recordings_async() == [] + + def test_list_recordings(self): + sb = _make_sandbox() + sb.data_api.list_recordings.return_value = [] + assert sb.list_recordings() == [] + + @pytest.mark.asyncio + async def test_download_recording_async(self): + sb = _make_sandbox() + sb.data_api.download_recording_async = AsyncMock( + return_value={"saved_path": "/x"} + ) + assert await sb.download_recording_async("f.mkv", "/x") == { + "saved_path": "/x" + } + + def test_download_recording(self): + sb = _make_sandbox() + sb.data_api.download_recording.return_value = {"saved_path": "/x"} + assert sb.download_recording("f.mkv", "/x") == {"saved_path": "/x"} + + @pytest.mark.asyncio + async def test_delete_recording_async(self): + sb = _make_sandbox() + sb.data_api.delete_recording_async = AsyncMock( + return_value={"ok": True} + ) + assert await sb.delete_recording_async("f.mkv") == {"ok": True} + + def test_delete_recording(self): + sb = _make_sandbox() + sb.data_api.delete_recording.return_value = {"ok": True} + assert sb.delete_recording("f.mkv") == {"ok": True} + + def test_file_property(self): + sb = _make_sandbox() + f = sb.file + assert isinstance(f, FileOperations) + assert sb.file is f + + def test_file_system_property(self): + sb = _make_sandbox() + fs = sb.file_system + assert isinstance(fs, FileSystemOperations) + assert sb.file_system is fs + + def test_context_property(self): + sb = _make_sandbox() + ctx = sb.context + assert isinstance(ctx, ContextOperations) + assert sb.context is ctx + + def test_process_property(self): + sb = _make_sandbox() + p = sb.process + assert isinstance(p, ProcessOperations) + assert sb.process is p + + +class TestAioSandboxContextManager: + + def test_enter_health_ok(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "ok"} + result = sb.__enter__() + assert result is sb + + def test_enter_retries_then_ok(self): + sb = _make_sandbox() + sb.data_api.check_health.side_effect = [ + {"status": "not-ready"}, + {"status": "ok"}, + ] + with patch("agentrun.sandbox.aio_sandbox.time.sleep"): + result = sb.__enter__() + assert result is sb + + def test_enter_exception_retries(self): + sb = _make_sandbox() + sb.data_api.check_health.side_effect = [ + Exception("err"), + {"status": "ok"}, + ] + with patch("agentrun.sandbox.aio_sandbox.time.sleep"): + result = sb.__enter__() + assert result is sb + + def test_enter_timeout(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "not-ready"} + with patch("agentrun.sandbox.aio_sandbox.time.sleep"): + with pytest.raises(RuntimeError, match="Health check timeout"): + sb.__enter__() + + @pytest.mark.asyncio + async def test_aenter_health_ok(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "ok"} + ) + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_retries_then_ok(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + side_effect=[{"status": "not-ready"}, {"status": "ok"}] + ) + with patch( + "agentrun.sandbox.aio_sandbox.asyncio.sleep", new_callable=AsyncMock + ): + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_exception_retries(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + side_effect=[Exception("err"), {"status": "ok"}] + ) + with patch( + "agentrun.sandbox.aio_sandbox.asyncio.sleep", new_callable=AsyncMock + ): + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_timeout(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "not-ready"} + ) + with patch( + "agentrun.sandbox.aio_sandbox.asyncio.sleep", new_callable=AsyncMock + ): + with pytest.raises(RuntimeError, match="Health check timeout"): + await sb.__aenter__() + + def test_exit_calls_delete(self): + sb = _make_sandbox() + sb.delete = MagicMock() + sb.__exit__(None, None, None) + sb.delete.assert_called_once() + + def test_exit_no_sandbox_id_raises(self): + sb = AioSandbox.model_construct(sandbox_id=None) + with pytest.raises(ValueError, match="Sandbox ID is not set"): + sb.__exit__(None, None, None) + + @pytest.mark.asyncio + async def test_aexit_calls_delete(self): + sb = _make_sandbox() + sb.delete_async = AsyncMock() + await sb.__aexit__(None, None, None) + sb.delete_async.assert_called_once() + + @pytest.mark.asyncio + async def test_aexit_no_sandbox_id_raises(self): + sb = AioSandbox.model_construct(sandbox_id=None) + with pytest.raises(ValueError, match="Sandbox ID is not set"): + await sb.__aexit__(None, None, None) diff --git a/tests/unittests/sandbox/test_browser_sandbox.py b/tests/unittests/sandbox/test_browser_sandbox.py new file mode 100644 index 0000000..55efa8b --- /dev/null +++ b/tests/unittests/sandbox/test_browser_sandbox.py @@ -0,0 +1,228 @@ +"""Tests for agentrun.sandbox.browser_sandbox module.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.browser_sandbox import BrowserSandbox +from agentrun.sandbox.model import TemplateType + + +def _make_sandbox(sandbox_id="sb-br-1"): + sb = BrowserSandbox.model_construct(sandbox_id=sandbox_id) + sb._data_api = MagicMock() + return sb + + +class TestBrowserSandbox: + + def test_template_type(self): + assert ( + BrowserSandbox.__private_attributes__["_template_type"].default + == TemplateType.BROWSER + ) + + def test_data_api_lazy_init(self): + sb = BrowserSandbox.model_construct(sandbox_id="sb-1") + with patch( + "agentrun.sandbox.browser_sandbox.BrowserDataAPI" + ) as mock_cls: + mock_cls.return_value = MagicMock() + api = sb.data_api + assert api is not None + assert sb.data_api is api + + def test_data_api_no_sandbox_id_raises(self): + sb = BrowserSandbox.model_construct(sandbox_id=None) + sb._data_api = None + with pytest.raises(ValueError, match="Sandbox ID is not set"): + _ = sb.data_api + + def test_check_health(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "ok"} + assert sb.check_health() == {"status": "ok"} + + @pytest.mark.asyncio + async def test_check_health_async(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "ok"} + ) + assert await sb.check_health_async() == {"status": "ok"} + + def test_get_cdp_url(self): + sb = _make_sandbox() + sb.data_api.get_cdp_url.return_value = "ws://example.com/ws/automation" + assert sb.get_cdp_url(record=True) == "ws://example.com/ws/automation" + sb.data_api.get_cdp_url.assert_called_once_with(record=True) + + def test_get_vnc_url(self): + sb = _make_sandbox() + sb.data_api.get_vnc_url.return_value = "ws://example.com/ws/liveview" + assert sb.get_vnc_url() == "ws://example.com/ws/liveview" + + def test_sync_playwright(self): + sb = _make_sandbox() + sb.data_api.sync_playwright.return_value = MagicMock() + result = sb.sync_playwright(record=True) + assert result is not None + + def test_async_playwright(self): + sb = _make_sandbox() + sb.data_api.async_playwright.return_value = MagicMock() + result = sb.async_playwright() + assert result is not None + + @pytest.mark.asyncio + async def test_list_recordings_async(self): + sb = _make_sandbox() + sb.data_api.list_recordings_async = AsyncMock( + return_value=[{"name": "r1"}] + ) + assert await sb.list_recordings_async() == [{"name": "r1"}] + + def test_list_recordings(self): + sb = _make_sandbox() + sb.data_api.list_recordings.return_value = [{"name": "r1"}] + assert sb.list_recordings() == [{"name": "r1"}] + + @pytest.mark.asyncio + async def test_download_recording_async(self): + sb = _make_sandbox() + sb.data_api.download_recording_async = AsyncMock( + return_value={"saved_path": "/x.mkv", "size": 1024} + ) + result = await sb.download_recording_async("r1.mkv", "/x.mkv") + assert result["size"] == 1024 + + def test_download_recording(self): + sb = _make_sandbox() + sb.data_api.download_recording.return_value = { + "saved_path": "/x.mkv", + "size": 1024, + } + result = sb.download_recording("r1.mkv", "/x.mkv") + assert result["size"] == 1024 + + @pytest.mark.asyncio + async def test_delete_recording_async(self): + sb = _make_sandbox() + sb.data_api.delete_recording_async = AsyncMock( + return_value={"ok": True} + ) + assert await sb.delete_recording_async("r1.mkv") == {"ok": True} + + def test_delete_recording(self): + sb = _make_sandbox() + sb.data_api.delete_recording.return_value = {"ok": True} + assert sb.delete_recording("r1.mkv") == {"ok": True} + + +class TestBrowserSandboxContextManager: + + def test_enter_health_ok(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "ok"} + result = sb.__enter__() + assert result is sb + + def test_enter_retries_then_ok(self): + sb = _make_sandbox() + sb.data_api.check_health.side_effect = [ + {"status": "not-ready"}, + {"status": "ok"}, + ] + with patch("agentrun.sandbox.browser_sandbox.time.sleep"): + result = sb.__enter__() + assert result is sb + + def test_enter_exception_retries(self): + sb = _make_sandbox() + sb.data_api.check_health.side_effect = [ + Exception("network"), + {"status": "ok"}, + ] + with patch("agentrun.sandbox.browser_sandbox.time.sleep"): + result = sb.__enter__() + assert result is sb + + def test_enter_timeout(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "not-ready"} + with patch("agentrun.sandbox.browser_sandbox.time.sleep"): + with pytest.raises(RuntimeError, match="Health check timeout"): + sb.__enter__() + + @pytest.mark.asyncio + async def test_aenter_health_ok(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "ok"} + ) + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_retries_then_ok(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + side_effect=[{"status": "not-ready"}, {"status": "ok"}] + ) + with patch( + "agentrun.sandbox.browser_sandbox.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_exception_retries(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + side_effect=[Exception("err"), {"status": "ok"}] + ) + with patch( + "agentrun.sandbox.browser_sandbox.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_timeout(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "not-ready"} + ) + with patch( + "agentrun.sandbox.browser_sandbox.asyncio.sleep", + new_callable=AsyncMock, + ): + with pytest.raises(RuntimeError, match="Health check timeout"): + await sb.__aenter__() + + def test_exit_calls_delete(self): + sb = _make_sandbox() + sb.delete = MagicMock() + sb.__exit__(None, None, None) + sb.delete.assert_called_once() + + def test_exit_no_sandbox_id_raises(self): + sb = BrowserSandbox.model_construct(sandbox_id=None) + with pytest.raises(ValueError, match="Sandbox ID is not set"): + sb.__exit__(None, None, None) + + @pytest.mark.asyncio + async def test_aexit_calls_delete(self): + sb = _make_sandbox() + sb.delete_async = AsyncMock() + await sb.__aexit__(None, None, None) + sb.delete_async.assert_called_once() + + @pytest.mark.asyncio + async def test_aexit_no_sandbox_id_raises(self): + sb = BrowserSandbox.model_construct(sandbox_id=None) + with pytest.raises(ValueError, match="Sandbox ID is not set"): + await sb.__aexit__(None, None, None) diff --git a/tests/unittests/sandbox/test_client.py b/tests/unittests/sandbox/test_client.py new file mode 100644 index 0000000..41fe3c0 --- /dev/null +++ b/tests/unittests/sandbox/test_client.py @@ -0,0 +1,1271 @@ +"""测试 agentrun.sandbox.client 模块 / Test agentrun.sandbox.client module""" + +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.client import SandboxClient +from agentrun.sandbox.model import ( + ListSandboxesInput, + NASConfig, + OSSMountConfig, + PageableInput, + PolarFsConfig, + TemplateInput, + TemplateType, +) +from agentrun.utils.config import Config +from agentrun.utils.exception import ( + AgentRunError, + ClientError, + ResourceNotExistError, +) + + +class MockTemplateData: + """模拟 Template 数据""" + + def to_map(self): + return { + "templateId": "tmpl-123", + "templateName": "test-template", + "templateType": "CodeInterpreter", + "cpu": 2.0, + "memory": 4096, + "diskSize": 512, + "status": "READY", + "createdAt": "2024-01-01T00:00:00Z", + "lastUpdatedAt": "2024-01-01T00:00:00Z", + } + + +class MockTemplateCreatingData: + """模拟正在创建中的 Template 数据""" + + def to_map(self): + return { + "templateId": "tmpl-123", + "templateName": "test-template", + "templateType": "CodeInterpreter", + "status": "CREATING", + } + + +class MockTemplateFailedData: + """模拟创建失败的 Template 数据""" + + def to_map(self): + return { + "templateId": "tmpl-123", + "templateName": "test-template", + "templateType": "CodeInterpreter", + "status": "CREATE_FAILED", + } + + +class MockListTemplatesResult: + """模拟 Template 列表结果""" + + def __init__(self, items): + self.items = items + + +class MockListSandboxesResult: + """模拟 Sandbox 列表结果""" + + def __init__(self, items, next_token=None): + self.items = items + self.next_token = next_token + + +class MockSandboxData: + """模拟底层 SDK Sandbox 对象""" + + def to_map(self): + return { + "sandboxId": "sandbox-123", + "templateName": "test-template", + "templateId": "tmpl-123", + "status": "RUNNING", + "createdAt": "2024-01-01T00:00:00Z", + } + + +# ==================== 初始化测试 ==================== + + +class TestSandboxClientInit: + + def test_init_without_config(self): + client = SandboxClient() + assert client is not None + + def test_init_with_config(self): + config = Config(access_key_id="test-ak") + client = SandboxClient(config=config) + assert client is not None + + +# ==================== Template CRUD 测试 ==================== + + +class TestSandboxClientCreateTemplate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_template_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.create_template.return_value = MockTemplateData() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = client.create_template(input_obj) + assert result.template_name == "test-template" + assert result.status == "READY" + assert mock_control_api.create_template.called + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.create_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = await client.create_template_async(input_obj) + assert result.template_name == "test-template" + + +class TestSandboxClientDeleteTemplate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_template_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.delete_template("test-template") + assert result is not None + assert mock_control_api.delete_template.called + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = await client.delete_template_async("test-template") + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_template_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template.side_effect = ClientError( + status_code=404, message="Not found" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.delete_template("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_template_async_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template_async = AsyncMock( + side_effect=ClientError(status_code=404, message="Not found") + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + await client.delete_template_async("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_template_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template.side_effect = ClientError( + status_code=500, message="Internal error" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ClientError): + client.delete_template("test") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_template_async_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template_async = AsyncMock( + side_effect=ClientError(status_code=500, message="Internal error") + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ClientError): + await client.delete_template_async("test") + + +class TestSandboxClientUpdateTemplate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_update_template_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = client.update_template("test-template", input_obj) + assert result is not None + assert mock_control_api.update_template.called + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_update_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = await client.update_template_async("test-template", input_obj) + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_update_template_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template.side_effect = ClientError( + status_code=404, message="Not found" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + with pytest.raises(ResourceNotExistError): + client.update_template("nonexistent", input_obj) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_update_template_async_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template_async = AsyncMock( + side_effect=ClientError(status_code=404, message="Not found") + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + with pytest.raises(ResourceNotExistError): + await client.update_template_async("nonexistent", input_obj) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_update_template_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template.side_effect = ClientError( + status_code=500, message="Internal error" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + with pytest.raises(ClientError): + client.update_template("test", input_obj) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_update_template_async_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template_async = AsyncMock( + side_effect=ClientError(status_code=500, message="Internal error") + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + with pytest.raises(ClientError): + await client.update_template_async("test", input_obj) + + +class TestSandboxClientGetTemplate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_template_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.get_template("test-template") + assert result.template_name == "test-template" + assert mock_control_api.get_template.called + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = await client.get_template_async("test-template") + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_template_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.side_effect = ClientError( + status_code=404, message="Not found" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.get_template("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_template_async_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + side_effect=ClientError(status_code=404, message="Not found") + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + await client.get_template_async("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_template_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.side_effect = ClientError( + status_code=500, message="Internal error" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(ClientError): + client.get_template("test") + + +class TestSandboxClientListTemplates: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + [MockTemplateData()] + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.list_templates() + assert len(result) == 1 + assert mock_control_api.list_templates.called + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_list_templates_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates_async = AsyncMock( + return_value=MockListTemplatesResult([MockTemplateData()]) + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = await client.list_templates_async() + assert len(result) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates_empty( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + None + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.list_templates() + assert len(result) == 0 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_list_templates_async_empty( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates_async = AsyncMock( + return_value=MockListTemplatesResult(None) + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = await client.list_templates_async() + assert len(result) == 0 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates_with_input( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + [MockTemplateData()] + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = PageableInput(page_number=1, page_size=5) + result = client.list_templates(input=input_obj) + assert len(result) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates_none_input( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + [MockTemplateData()] + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.list_templates(input=None) + assert len(result) == 1 + + +# ==================== Sandbox CRUD 测试 ==================== + + +class TestSandboxClientCreateSandbox: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_sandbox_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-template", + "status": "RUNNING", + }, + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = client.create_sandbox(template_name="test-template") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_sandbox_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-template", + "status": "RUNNING", + }, + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = await client.create_sandbox_async( + template_name="test-template" + ) + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_sandbox_with_storage_configs( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = client.create_sandbox( + template_name="test-template", + sandbox_id="custom-id", + nas_config=NASConfig(group_id=1000), + oss_mount_config=OSSMountConfig(), + polar_fs_config=PolarFsConfig(user_id=1000), + ) + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_sandbox_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "FAILED", + "message": "Something went wrong", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to create sandbox"): + client.create_sandbox(template_name="test-template") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_sandbox_async_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "FAILED", + "message": "Something went wrong", + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to create sandbox"): + await client.create_sandbox_async(template_name="test-template") + + +class TestSandboxClientStopSandbox: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_stop_sandbox_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123", "status": "STOPPED"}, + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = client.stop_sandbox("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_stop_sandbox_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123", "status": "STOPPED"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = await client.stop_sandbox_async("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_stop_sandbox_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox.return_value = { + "code": "FAILED", + "message": "Stop failed", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + client.stop_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_stop_sandbox_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox.side_effect = ClientError( + status_code=404, message="Not found" + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.stop_sandbox("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_stop_sandbox_async_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox_async = AsyncMock( + side_effect=ClientError(status_code=404, message="Not found") + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + await client.stop_sandbox_async("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_stop_sandbox_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox.side_effect = ClientError( + status_code=500, message="Internal error" + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError): + client.stop_sandbox("sandbox-123") + + +class TestSandboxClientDeleteSandbox: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = client.delete_sandbox("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_sandbox_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = await client.delete_sandbox_async("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "FAILED", + "message": "Delete failed", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + client.delete_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.side_effect = ClientError( + status_code=404, message="Not found" + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.delete_sandbox("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_sandbox_async_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + side_effect=ClientError(status_code=404, message="Not found") + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + await client.delete_sandbox_async("nonexistent") + + +class TestSandboxClientGetSandbox: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_sandbox_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123", "templateName": "tmpl"}, + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = client.get_sandbox("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_sandbox_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = await client.get_sandbox_async("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_sandbox_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "FAILED", + "message": "Get failed", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to get sandbox"): + client.get_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_sandbox_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.side_effect = ClientError( + status_code=404, message="Not found" + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.get_sandbox("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_sandbox_async_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + side_effect=ClientError(status_code=404, message="Not found") + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + await client.get_sandbox_async("nonexistent") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_sandbox_async_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={"code": "FAILED", "message": "err"} + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to get sandbox"): + await client.get_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_sandbox_other_client_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.side_effect = ClientError( + status_code=500, message="Internal" + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError): + client.get_sandbox("sandbox-123") + + +class TestSandboxClientListSandboxes: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_sandboxes_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_sandboxes.return_value = MockListSandboxesResult( + [MockSandboxData()] + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.list_sandboxes() + assert len(result.sandboxes) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_list_sandboxes_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_sandboxes_async = AsyncMock( + return_value=MockListSandboxesResult([MockSandboxData()]) + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = await client.list_sandboxes_async() + assert len(result.sandboxes) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_sandboxes_with_input( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_sandboxes.return_value = MockListSandboxesResult( + [], next_token="token" + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + input_obj = ListSandboxesInput(max_results=5) + result = client.list_sandboxes(input=input_obj) + assert len(result.sandboxes) == 0 + assert result.next_token == "token" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_sandboxes_none_input( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_sandboxes.return_value = MockListSandboxesResult( + [] + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client.list_sandboxes(input=None) + assert len(result.sandboxes) == 0 + + +# ==================== _wait_template_ready 测试 ==================== + + +class TestWaitTemplateReady: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_wait_ready_immediately( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = client._wait_template_ready("test-template") + assert result.status == "READY" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_wait_ready_fails( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateFailedData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(AgentRunError, match="creation failed"): + client._wait_template_ready("test-template") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_wait_ready_timeout( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateCreatingData() + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(TimeoutError, match="Timeout"): + client._wait_template_ready( + "test-template", + interval_seconds=0.01, + timeout_seconds=0.05, + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_wait_ready_async_immediately( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + result = await client._wait_template_ready_async("test-template") + assert result.status == "READY" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_wait_ready_async_fails( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateFailedData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(AgentRunError, match="creation failed"): + await client._wait_template_ready_async("test-template") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_wait_ready_async_timeout( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateCreatingData() + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(TimeoutError, match="Timeout"): + await client._wait_template_ready_async( + "test-template", + interval_seconds=0.01, + timeout_seconds=0.05, + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_stop_sandbox_async_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox_async = AsyncMock( + return_value={"code": "FAILED", "message": "err"} + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + await client.stop_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_sandbox_async_failure( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + return_value={"code": "FAILED", "message": "err"} + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + await client.delete_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_other_client_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.side_effect = ClientError( + status_code=500, message="Internal" + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError): + client.delete_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_sandbox_async_other_client_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + side_effect=ClientError(status_code=500, message="Internal") + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError): + await client.delete_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_stop_sandbox_async_other_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox_async = AsyncMock( + side_effect=ClientError(status_code=500, message="Internal") + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError): + await client.stop_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_sandbox_async_other_client_error( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + side_effect=ClientError(status_code=500, message="Internal") + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError): + await client.get_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_sandbox_async_with_storage_configs( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-456"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + result = await client.create_sandbox_async( + template_name="test-template", + sandbox_id="custom-id", + nas_config=NASConfig(group_id=1000), + oss_mount_config=OSSMountConfig(), + polar_fs_config=PolarFsConfig(user_id=1000), + ) + assert result.sandbox_id == "sandbox-456" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_wait_update_failed( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + failed_data = MagicMock() + failed_data.to_map.return_value = { + "templateId": "tmpl-123", + "templateName": "test-template", + "status": "UPDATE_FAILED", + } + mock_control_api.get_template.return_value = failed_data + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(AgentRunError, match="creation failed"): + client._wait_template_ready("test-template") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_wait_async_update_failed( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + failed_data = MagicMock() + failed_data.to_map.return_value = { + "templateId": "tmpl-123", + "templateName": "test-template", + "status": "UPDATE_FAILED", + } + mock_control_api.get_template_async = AsyncMock( + return_value=failed_data + ) + mock_control_api_class.return_value = mock_control_api + + client = SandboxClient() + with pytest.raises(AgentRunError, match="creation failed"): + await client._wait_template_ready_async("test-template") diff --git a/tests/unittests/sandbox/test_code_interpreter_sandbox.py b/tests/unittests/sandbox/test_code_interpreter_sandbox.py new file mode 100644 index 0000000..4834f89 --- /dev/null +++ b/tests/unittests/sandbox/test_code_interpreter_sandbox.py @@ -0,0 +1,681 @@ +"""Tests for agentrun.sandbox.code_interpreter_sandbox module.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.code_interpreter_sandbox import ( + CodeInterpreterSandbox, + ContextOperations, + FileOperations, + FileSystemOperations, + ProcessOperations, +) +from agentrun.sandbox.model import CodeLanguage, TemplateType +from agentrun.utils.exception import ServerError + + +def _make_sandbox(sandbox_id="sb-123"): + sb = CodeInterpreterSandbox.model_construct(sandbox_id=sandbox_id) + sb._data_api = MagicMock() + return sb + + +# ==================== FileOperations ==================== + + +class TestFileOperations: + + def test_read(self): + sb = _make_sandbox() + sb.data_api.read_file.return_value = "content" + ops = FileOperations(sb) + assert ops.read("/tmp/f.txt") == "content" + sb.data_api.read_file.assert_called_once_with(path="/tmp/f.txt") + + @pytest.mark.asyncio + async def test_read_async(self): + sb = _make_sandbox() + sb.data_api.read_file_async = AsyncMock(return_value="async-content") + ops = FileOperations(sb) + result = await ops.read_async("/tmp/f.txt") + assert result == "async-content" + + def test_write(self): + sb = _make_sandbox() + sb.data_api.write_file.return_value = {"ok": True} + ops = FileOperations(sb) + result = ops.write( + "/tmp/f.txt", "data", mode="755", encoding="ascii", create_dir=False + ) + assert result == {"ok": True} + sb.data_api.write_file.assert_called_once_with( + path="/tmp/f.txt", + content="data", + mode="755", + encoding="ascii", + create_dir=False, + ) + + @pytest.mark.asyncio + async def test_write_async(self): + sb = _make_sandbox() + sb.data_api.write_file_async = AsyncMock(return_value={"ok": True}) + ops = FileOperations(sb) + result = await ops.write_async("/tmp/f.txt", "data") + assert result == {"ok": True} + + +# ==================== FileSystemOperations ==================== + + +class TestFileSystemOperations: + + def test_list(self): + sb = _make_sandbox() + sb.data_api.list_directory.return_value = [{"name": "a"}] + ops = FileSystemOperations(sb) + assert ops.list(path="/home", depth=2) == [{"name": "a"}] + + @pytest.mark.asyncio + async def test_list_async(self): + sb = _make_sandbox() + sb.data_api.list_directory_async = AsyncMock(return_value=[]) + ops = FileSystemOperations(sb) + assert await ops.list_async() == [] + + def test_move(self): + sb = _make_sandbox() + sb.data_api.move_file.return_value = {"ok": True} + ops = FileSystemOperations(sb) + assert ops.move("/a", "/b") == {"ok": True} + + @pytest.mark.asyncio + async def test_move_async(self): + sb = _make_sandbox() + sb.data_api.move_file_async = AsyncMock(return_value={"ok": True}) + ops = FileSystemOperations(sb) + assert await ops.move_async("/a", "/b") == {"ok": True} + + def test_remove(self): + sb = _make_sandbox() + sb.data_api.remove_file.return_value = {"ok": True} + ops = FileSystemOperations(sb) + assert ops.remove("/tmp/x") == {"ok": True} + + @pytest.mark.asyncio + async def test_remove_async(self): + sb = _make_sandbox() + sb.data_api.remove_file_async = AsyncMock(return_value={"ok": True}) + ops = FileSystemOperations(sb) + assert await ops.remove_async("/tmp/x") == {"ok": True} + + def test_stat(self): + sb = _make_sandbox() + sb.data_api.stat.return_value = {"size": 100} + ops = FileSystemOperations(sb) + assert ops.stat("/tmp/x") == {"size": 100} + + @pytest.mark.asyncio + async def test_stat_async(self): + sb = _make_sandbox() + sb.data_api.stat_async = AsyncMock(return_value={"size": 100}) + ops = FileSystemOperations(sb) + assert await ops.stat_async("/tmp/x") == {"size": 100} + + def test_mkdir(self): + sb = _make_sandbox() + sb.data_api.mkdir.return_value = {"ok": True} + ops = FileSystemOperations(sb) + assert ops.mkdir("/tmp/dir", parents=False, mode="0700") == {"ok": True} + + @pytest.mark.asyncio + async def test_mkdir_async(self): + sb = _make_sandbox() + sb.data_api.mkdir_async = AsyncMock(return_value={"ok": True}) + ops = FileSystemOperations(sb) + assert await ops.mkdir_async("/tmp/dir") == {"ok": True} + + def test_upload(self): + sb = _make_sandbox() + sb.data_api.upload_file.return_value = {"ok": True} + ops = FileSystemOperations(sb) + assert ops.upload("/local/f", "/remote/f") == {"ok": True} + + @pytest.mark.asyncio + async def test_upload_async(self): + sb = _make_sandbox() + sb.data_api.upload_file_async = AsyncMock(return_value={"ok": True}) + ops = FileSystemOperations(sb) + assert await ops.upload_async("/local/f", "/remote/f") == {"ok": True} + + def test_download(self): + sb = _make_sandbox() + sb.data_api.download_file.return_value = { + "saved_path": "/x", + "size": 10, + } + ops = FileSystemOperations(sb) + assert ops.download("/remote/f", "/local/f") == { + "saved_path": "/x", + "size": 10, + } + + @pytest.mark.asyncio + async def test_download_async(self): + sb = _make_sandbox() + sb.data_api.download_file_async = AsyncMock( + return_value={"saved_path": "/x", "size": 10} + ) + ops = FileSystemOperations(sb) + assert await ops.download_async("/remote/f", "/local/f") == { + "saved_path": "/x", + "size": 10, + } + + +# ==================== ProcessOperations ==================== + + +class TestProcessOperations: + + def test_cmd(self): + sb = _make_sandbox() + sb.data_api.cmd.return_value = {"exit_code": 0} + ops = ProcessOperations(sb) + assert ops.cmd("ls", "/home", timeout=10) == {"exit_code": 0} + + @pytest.mark.asyncio + async def test_cmd_async(self): + sb = _make_sandbox() + sb.data_api.cmd_async = AsyncMock(return_value={"exit_code": 0}) + ops = ProcessOperations(sb) + assert await ops.cmd_async("ls", "/home") == {"exit_code": 0} + + def test_list(self): + sb = _make_sandbox() + sb.data_api.list_processes.return_value = [{"pid": "1"}] + ops = ProcessOperations(sb) + assert ops.list() == [{"pid": "1"}] + + @pytest.mark.asyncio + async def test_list_async(self): + sb = _make_sandbox() + sb.data_api.list_processes_async = AsyncMock(return_value=[]) + ops = ProcessOperations(sb) + assert await ops.list_async() == [] + + def test_get(self): + sb = _make_sandbox() + sb.data_api.get_process.return_value = {"pid": "1"} + ops = ProcessOperations(sb) + assert ops.get("1") == {"pid": "1"} + + @pytest.mark.asyncio + async def test_get_async(self): + sb = _make_sandbox() + sb.data_api.get_process_async = AsyncMock(return_value={"pid": "1"}) + ops = ProcessOperations(sb) + assert await ops.get_async("1") == {"pid": "1"} + + def test_kill(self): + sb = _make_sandbox() + sb.data_api.kill_process.return_value = {"ok": True} + ops = ProcessOperations(sb) + assert ops.kill("1") == {"ok": True} + + @pytest.mark.asyncio + async def test_kill_async(self): + sb = _make_sandbox() + sb.data_api.kill_process_async = AsyncMock(return_value={"ok": True}) + ops = ProcessOperations(sb) + assert await ops.kill_async("1") == {"ok": True} + + +# ==================== ContextOperations ==================== + + +class TestContextOperations: + + def _make_ctx_ops(self): + sb = _make_sandbox() + return ContextOperations(sb), sb + + def test_context_id_default_none(self): + ops, _ = self._make_ctx_ops() + assert ops.context_id is None + + def test_list(self): + ops, sb = self._make_ctx_ops() + sb.data_api.list_contexts.return_value = [{"id": "c1"}] + assert ops.list() == [{"id": "c1"}] + + @pytest.mark.asyncio + async def test_list_async(self): + ops, sb = self._make_ctx_ops() + sb.data_api.list_contexts_async = AsyncMock(return_value=[]) + assert await ops.list_async() == [] + + def test_create_success(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context.return_value = { + "id": "c1", + "cwd": "/home", + "language": "python", + } + result = ops.create() + assert result is ops + assert ops.context_id == "c1" + assert ops._language == "python" + assert ops._cwd == "/home" + + def test_create_failure(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context.return_value = { + "id": None, + "cwd": "/home", + "language": "python", + } + with pytest.raises(ServerError): + ops.create() + + @pytest.mark.asyncio + async def test_create_async_success(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context_async = AsyncMock( + return_value={"id": "c2", "cwd": "/home", "language": "javascript"} + ) + result = await ops.create_async( + language=CodeLanguage.PYTHON, cwd="/work" + ) + assert result is ops + assert ops.context_id == "c2" + + @pytest.mark.asyncio + async def test_create_async_failure(self): + ops, sb = self._make_ctx_ops() + sb.data_api.create_context_async = AsyncMock( + return_value={"incomplete": True} + ) + with pytest.raises(ServerError): + await ops.create_async() + + def test_get_with_explicit_id(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context.return_value = { + "id": "c3", + "cwd": "/x", + "language": "python", + } + result = ops.get(context_id="c3") + assert result is ops + assert ops.context_id == "c3" + + def test_get_with_saved_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "saved-id" + sb.data_api.get_context.return_value = { + "id": "saved-id", + "cwd": "/x", + "language": "python", + } + ops.get() + assert ops.context_id == "saved-id" + + def test_get_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context id is not set"): + ops.get() + + def test_get_failure_raises(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context.return_value = {"id": None} + with pytest.raises(ServerError): + ops.get(context_id="c1") + + @pytest.mark.asyncio + async def test_get_async_with_id(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context_async = AsyncMock( + return_value={"id": "c3", "cwd": "/x", "language": "python"} + ) + result = await ops.get_async(context_id="c3") + assert result is ops + + @pytest.mark.asyncio + async def test_get_async_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context id is not set"): + await ops.get_async() + + @pytest.mark.asyncio + async def test_get_async_failure_raises(self): + ops, sb = self._make_ctx_ops() + sb.data_api.get_context_async = AsyncMock(return_value={"id": None}) + with pytest.raises(ServerError): + await ops.get_async(context_id="c1") + + def test_execute_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.execute_code.return_value = {"result": "ok"} + result = ops.execute("print(1)") + assert result == {"result": "ok"} + sb.data_api.execute_code.assert_called_once_with( + context_id="c1", language=None, code="print(1)", timeout=30 + ) + + def test_execute_no_context_no_language_defaults_python(self): + ops, sb = self._make_ctx_ops() + sb.data_api.execute_code.return_value = {"result": "ok"} + ops.execute("print(1)") + sb.data_api.execute_code.assert_called_once_with( + context_id=None, + language=CodeLanguage.PYTHON, + code="print(1)", + timeout=30, + ) + + def test_execute_with_explicit_params(self): + ops, sb = self._make_ctx_ops() + sb.data_api.execute_code.return_value = {"result": "ok"} + ops.execute( + "code", language=CodeLanguage.PYTHON, context_id="x", timeout=60 + ) + sb.data_api.execute_code.assert_called_once_with( + context_id="x", + language=CodeLanguage.PYTHON, + code="code", + timeout=60, + ) + + @pytest.mark.asyncio + async def test_execute_async_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.execute_code_async = AsyncMock( + return_value={"result": "ok"} + ) + result = await ops.execute_async("code") + assert result == {"result": "ok"} + + @pytest.mark.asyncio + async def test_execute_async_defaults_python(self): + ops, sb = self._make_ctx_ops() + sb.data_api.execute_code_async = AsyncMock( + return_value={"result": "ok"} + ) + await ops.execute_async("code") + sb.data_api.execute_code_async.assert_called_once_with( + context_id=None, + language=CodeLanguage.PYTHON, + code="code", + timeout=30, + ) + + def test_delete_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context.return_value = {"ok": True} + result = ops.delete() + assert result == {"ok": True} + assert ops._context_id is None + + def test_delete_with_explicit_id(self): + ops, sb = self._make_ctx_ops() + sb.data_api.delete_context.return_value = {"ok": True} + result = ops.delete(context_id="c2") + assert result == {"ok": True} + assert ops._context_id is None + + def test_delete_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context_id is required"): + ops.delete() + + @pytest.mark.asyncio + async def test_delete_async_with_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context_async = AsyncMock(return_value={"ok": True}) + result = await ops.delete_async() + assert result == {"ok": True} + assert ops._context_id is None + + @pytest.mark.asyncio + async def test_delete_async_no_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="context_id is required"): + await ops.delete_async() + + def test_enter_with_context_id(self): + ops, _ = self._make_ctx_ops() + ops._context_id = "c1" + assert ops.__enter__() is ops + + def test_enter_no_context_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="No context has been created"): + ops.__enter__() + + @pytest.mark.asyncio + async def test_aenter_with_context_id(self): + ops, _ = self._make_ctx_ops() + ops._context_id = "c1" + assert await ops.__aenter__() is ops + + @pytest.mark.asyncio + async def test_aenter_no_context_id_raises(self): + ops, _ = self._make_ctx_ops() + with pytest.raises(ValueError, match="No context has been created"): + await ops.__aenter__() + + def test_exit_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context.return_value = {"ok": True} + result = ops.__exit__(None, None, None) + assert result is False + + def test_exit_no_context_id(self): + ops, _ = self._make_ctx_ops() + result = ops.__exit__(None, None, None) + assert result is False + + def test_exit_delete_fails_logs_error(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context.side_effect = Exception("fail") + result = ops.__exit__(None, None, None) + assert result is False + + @pytest.mark.asyncio + async def test_aexit_with_context_id(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context_async = AsyncMock(return_value={"ok": True}) + result = await ops.__aexit__(None, None, None) + assert result is False + + @pytest.mark.asyncio + async def test_aexit_no_context_id(self): + ops, _ = self._make_ctx_ops() + result = await ops.__aexit__(None, None, None) + assert result is False + + @pytest.mark.asyncio + async def test_aexit_delete_fails_logs_error(self): + ops, sb = self._make_ctx_ops() + ops._context_id = "c1" + sb.data_api.delete_context_async = AsyncMock( + side_effect=Exception("fail") + ) + result = await ops.__aexit__(None, None, None) + assert result is False + + +# ==================== CodeInterpreterSandbox ==================== + + +class TestCodeInterpreterSandbox: + + def test_template_type(self): + assert ( + CodeInterpreterSandbox.__private_attributes__[ + "_template_type" + ].default + == TemplateType.CODE_INTERPRETER + ) + + def test_data_api_lazy_init(self): + sb = CodeInterpreterSandbox.model_construct(sandbox_id="sb-123") + with patch( + "agentrun.sandbox.code_interpreter_sandbox.CodeInterpreterDataAPI" + ) as mock_cls: + mock_cls.return_value = MagicMock() + api = sb.data_api + assert api is not None + assert sb.data_api is api # cached + + def test_file_property(self): + sb = _make_sandbox() + f = sb.file + assert isinstance(f, FileOperations) + assert sb.file is f # cached + + def test_file_system_property(self): + sb = _make_sandbox() + fs = sb.file_system + assert isinstance(fs, FileSystemOperations) + assert sb.file_system is fs + + def test_context_property(self): + sb = _make_sandbox() + ctx = sb.context + assert isinstance(ctx, ContextOperations) + assert sb.context is ctx + + def test_process_property(self): + sb = _make_sandbox() + p = sb.process + assert isinstance(p, ProcessOperations) + assert sb.process is p + + def test_check_health(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "ok"} + assert sb.check_health() == {"status": "ok"} + + @pytest.mark.asyncio + async def test_check_health_async(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "ok"} + ) + assert await sb.check_health_async() == {"status": "ok"} + + def test_enter_health_ok(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "ok"} + result = sb.__enter__() + assert result is sb + + def test_enter_health_retries_then_ok(self): + sb = _make_sandbox() + sb.data_api.check_health.side_effect = [ + {"status": "not-ready"}, + {"status": "ok"}, + ] + with patch("agentrun.sandbox.code_interpreter_sandbox.time.sleep"): + result = sb.__enter__() + assert result is sb + + def test_enter_health_exception_retries(self): + sb = _make_sandbox() + sb.data_api.check_health.side_effect = [ + Exception("network error"), + {"status": "ok"}, + ] + with patch("agentrun.sandbox.code_interpreter_sandbox.time.sleep"): + result = sb.__enter__() + assert result is sb + + def test_enter_timeout(self): + sb = _make_sandbox() + sb.data_api.check_health.return_value = {"status": "not-ready"} + with patch("agentrun.sandbox.code_interpreter_sandbox.time.sleep"): + with pytest.raises(RuntimeError, match="Health check timeout"): + sb.__enter__() + + @pytest.mark.asyncio + async def test_aenter_health_ok(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "ok"} + ) + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_retries_then_ok(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + side_effect=[{"status": "not-ready"}, {"status": "ok"}] + ) + with patch( + "agentrun.sandbox.code_interpreter_sandbox.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_exception_retries(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + side_effect=[Exception("err"), {"status": "ok"}] + ) + with patch( + "agentrun.sandbox.code_interpreter_sandbox.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await sb.__aenter__() + assert result is sb + + @pytest.mark.asyncio + async def test_aenter_timeout(self): + sb = _make_sandbox() + sb.data_api.check_health_async = AsyncMock( + return_value={"status": "not-ready"} + ) + with patch( + "agentrun.sandbox.code_interpreter_sandbox.asyncio.sleep", + new_callable=AsyncMock, + ): + with pytest.raises(RuntimeError, match="Health check timeout"): + await sb.__aenter__() + + def test_exit_calls_delete(self): + sb = _make_sandbox() + sb.delete = MagicMock() + sb.__exit__(None, None, None) + sb.delete.assert_called_once() + + def test_exit_no_sandbox_id_raises(self): + sb = CodeInterpreterSandbox.model_construct(sandbox_id=None) + with pytest.raises(ValueError, match="Sandbox ID is not set"): + sb.__exit__(None, None, None) + + @pytest.mark.asyncio + async def test_aexit_calls_delete(self): + sb = _make_sandbox() + sb.delete_async = AsyncMock() + await sb.__aexit__(None, None, None) + sb.delete_async.assert_called_once() + + @pytest.mark.asyncio + async def test_aexit_no_sandbox_id_raises(self): + sb = CodeInterpreterSandbox.model_construct(sandbox_id=None) + with pytest.raises(ValueError, match="Sandbox ID is not set"): + await sb.__aexit__(None, None, None) diff --git a/tests/unittests/sandbox/test_custom_sandbox.py b/tests/unittests/sandbox/test_custom_sandbox.py new file mode 100644 index 0000000..b966c12 --- /dev/null +++ b/tests/unittests/sandbox/test_custom_sandbox.py @@ -0,0 +1,39 @@ +"""Tests for agentrun.sandbox.custom_sandbox module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from agentrun.sandbox.custom_sandbox import CustomSandbox +from agentrun.sandbox.model import TemplateType + + +class TestCustomSandbox: + + def test_template_type(self): + assert ( + CustomSandbox.__private_attributes__["_template_type"].default + == TemplateType.CUSTOM + ) + + @patch("agentrun.sandbox.custom_sandbox.DataAPI") + def test_get_base_url(self, mock_data_api_cls): + mock_api = MagicMock() + mock_api.with_path.return_value = "https://example.com/sandboxes" + mock_data_api_cls.return_value = mock_api + + sb = CustomSandbox.model_construct(sandbox_id="sb-1") + result = sb.get_base_url() + assert result == "https://example.com/sandboxes" + mock_api.with_path.assert_called_once_with("") + + @patch("agentrun.sandbox.custom_sandbox.DataAPI") + def test_get_base_url_with_config(self, mock_data_api_cls): + mock_api = MagicMock() + mock_api.with_path.return_value = "https://custom.com" + mock_data_api_cls.return_value = mock_api + + sb = CustomSandbox.model_construct(sandbox_id="sb-1") + config = MagicMock() + result = sb.get_base_url(config=config) + assert result == "https://custom.com" diff --git a/tests/unittests/sandbox/test_model.py b/tests/unittests/sandbox/test_model.py new file mode 100644 index 0000000..56dae0a --- /dev/null +++ b/tests/unittests/sandbox/test_model.py @@ -0,0 +1,667 @@ +"""测试 agentrun.sandbox.model 模块 / Test agentrun.sandbox.model module""" + +import pytest + +from agentrun.sandbox.model import ( + CodeLanguage, + ListSandboxesInput, + ListSandboxesOutput, + NASConfig, + NASMountConfig, + OSSMountConfig, + OSSMountPoint, + PageableInput, + PolarFsConfig, + PolarFsMountConfig, + SandboxInput, + TemplateArmsConfiguration, + TemplateContainerConfiguration, + TemplateCredentialConfiguration, + TemplateInput, + TemplateLogConfiguration, + TemplateMcpOptions, + TemplateMcpState, + TemplateNetworkConfiguration, + TemplateNetworkMode, + TemplateOssConfiguration, + TemplateOSSPermission, + TemplateType, +) + +# ==================== 枚举测试 ==================== + + +class TestTemplateOSSPermission: + + def test_read_write_value(self): + assert TemplateOSSPermission.READ_WRITE.value == "READ_WRITE" + + def test_read_only_value(self): + assert TemplateOSSPermission.READ_ONLY.value == "READ_ONLY" + + def test_is_string_enum(self): + assert isinstance(TemplateOSSPermission.READ_WRITE, str) + assert TemplateOSSPermission.READ_WRITE == "READ_WRITE" + + +class TestTemplateType: + + def test_code_interpreter_value(self): + assert TemplateType.CODE_INTERPRETER.value == "CodeInterpreter" + + def test_browser_value(self): + assert TemplateType.BROWSER.value == "Browser" + + def test_aio_value(self): + assert TemplateType.AIO.value == "AllInOne" + + def test_custom_value(self): + assert TemplateType.CUSTOM.value == "CustomImage" + + def test_is_string_enum(self): + assert isinstance(TemplateType.CODE_INTERPRETER, str) + + +class TestTemplateNetworkMode: + + def test_public_value(self): + assert TemplateNetworkMode.PUBLIC.value == "PUBLIC" + + def test_private_value(self): + assert TemplateNetworkMode.PRIVATE.value == "PRIVATE" + + def test_public_and_private_value(self): + assert ( + TemplateNetworkMode.PUBLIC_AND_PRIVATE.value == "PUBLIC_AND_PRIVATE" + ) + + +class TestCodeLanguage: + + def test_python_value(self): + assert CodeLanguage.PYTHON.value == "python" + + def test_is_string_enum(self): + assert isinstance(CodeLanguage.PYTHON, str) + assert CodeLanguage.PYTHON == "python" + + +# ==================== NAS 配置测试 ==================== + + +class TestNASMountConfig: + + def test_create_full(self): + config = NASMountConfig( + enable_tls=True, + mount_dir="/mnt/nas", + server_addr="nas-server.example.com", + ) + assert config.enable_tls is True + assert config.mount_dir == "/mnt/nas" + assert config.server_addr == "nas-server.example.com" + + def test_optional_fields(self): + config = NASMountConfig() + assert config.enable_tls is None + assert config.mount_dir is None + assert config.server_addr is None + + def test_partial_fields(self): + config = NASMountConfig(mount_dir="/mnt/data") + assert config.mount_dir == "/mnt/data" + assert config.enable_tls is None + + def test_model_dump(self): + config = NASMountConfig( + enable_tls=True, mount_dir="/mnt/nas", server_addr="addr" + ) + data = config.model_dump(by_alias=True) + assert "enableTls" in data + assert "mountDir" in data + assert "serverAddr" in data + + +class TestNASConfig: + + def test_create_full(self): + mount = NASMountConfig(mount_dir="/mnt/nas", server_addr="addr") + config = NASConfig(group_id=1000, mount_points=[mount], user_id=1000) + assert config.group_id == 1000 + assert config.user_id == 1000 + assert len(config.mount_points) == 1 + + def test_optional_fields(self): + config = NASConfig() + assert config.group_id is None + assert config.mount_points is None + assert config.user_id is None + + +# ==================== OSS 配置测试 ==================== + + +class TestOSSMountPoint: + + def test_create_full(self): + point = OSSMountPoint( + bucket_name="my-bucket", + bucket_path="/data", + endpoint="oss-cn-hangzhou.aliyuncs.com", + mount_dir="/mnt/oss", + read_only=True, + ) + assert point.bucket_name == "my-bucket" + assert point.bucket_path == "/data" + assert point.endpoint == "oss-cn-hangzhou.aliyuncs.com" + assert point.mount_dir == "/mnt/oss" + assert point.read_only is True + + def test_optional_fields(self): + point = OSSMountPoint() + assert point.bucket_name is None + assert point.bucket_path is None + assert point.endpoint is None + assert point.mount_dir is None + assert point.read_only is None + + def test_model_dump(self): + point = OSSMountPoint(bucket_name="b", mount_dir="/mnt") + data = point.model_dump(by_alias=True) + assert "bucketName" in data + assert "mountDir" in data + + +class TestOSSMountConfig: + + def test_create_with_mount_points(self): + point = OSSMountPoint(bucket_name="my-bucket", mount_dir="/mnt/oss") + config = OSSMountConfig(mount_points=[point]) + assert len(config.mount_points) == 1 + assert config.mount_points[0].bucket_name == "my-bucket" + + def test_optional_fields(self): + config = OSSMountConfig() + assert config.mount_points is None + + def test_multiple_mount_points(self): + points = [ + OSSMountPoint(bucket_name="bucket-1"), + OSSMountPoint(bucket_name="bucket-2"), + ] + config = OSSMountConfig(mount_points=points) + assert len(config.mount_points) == 2 + + +# ==================== PolarFS 配置测试 ==================== + + +class TestPolarFsMountConfig: + + def test_create_full(self): + config = PolarFsMountConfig( + instance_id="inst-123", + mount_dir="/mnt/polar", + remote_dir="/remote/data", + ) + assert config.instance_id == "inst-123" + assert config.mount_dir == "/mnt/polar" + assert config.remote_dir == "/remote/data" + + def test_optional_fields(self): + config = PolarFsMountConfig() + assert config.instance_id is None + assert config.mount_dir is None + assert config.remote_dir is None + + +class TestPolarFsConfig: + + def test_create_full(self): + mount = PolarFsMountConfig(instance_id="inst-123", mount_dir="/mnt") + config = PolarFsConfig( + group_id=1000, mount_points=[mount], user_id=1000 + ) + assert config.group_id == 1000 + assert config.user_id == 1000 + assert len(config.mount_points) == 1 + + def test_optional_fields(self): + config = PolarFsConfig() + assert config.group_id is None + assert config.mount_points is None + assert config.user_id is None + + +# ==================== 模板配置测试 ==================== + + +class TestTemplateNetworkConfiguration: + + def test_default_values(self): + config = TemplateNetworkConfiguration() + assert config.network_mode == TemplateNetworkMode.PUBLIC + + def test_create_full(self): + config = TemplateNetworkConfiguration( + network_mode=TemplateNetworkMode.PRIVATE, + security_group_id="sg-123", + vpc_id="vpc-456", + vswitch_ids=["vsw-789"], + ) + assert config.network_mode == TemplateNetworkMode.PRIVATE + assert config.security_group_id == "sg-123" + assert config.vpc_id == "vpc-456" + assert config.vswitch_ids == ["vsw-789"] + + def test_optional_fields(self): + config = TemplateNetworkConfiguration() + assert config.security_group_id is None + assert config.vpc_id is None + assert config.vswitch_ids is None + + +class TestTemplateOssConfiguration: + + def test_create(self): + config = TemplateOssConfiguration( + bucket_name="my-bucket", + mount_point="/mnt/oss", + prefix="data/", + region="cn-hangzhou", + ) + assert config.bucket_name == "my-bucket" + assert config.mount_point == "/mnt/oss" + assert config.prefix == "data/" + assert config.region == "cn-hangzhou" + assert config.permission == TemplateOSSPermission.READ_WRITE + + def test_create_with_read_only(self): + config = TemplateOssConfiguration( + bucket_name="b", + mount_point="/mnt", + prefix="/", + region="cn-shanghai", + permission=TemplateOSSPermission.READ_ONLY, + ) + assert config.permission == TemplateOSSPermission.READ_ONLY + + +class TestTemplateLogConfiguration: + + def test_create_full(self): + config = TemplateLogConfiguration( + project="my-project", logstore="my-logstore" + ) + assert config.project == "my-project" + assert config.logstore == "my-logstore" + + def test_optional_fields(self): + config = TemplateLogConfiguration() + assert config.project is None + assert config.logstore is None + + +class TestTemplateCredentialConfiguration: + + def test_create(self): + config = TemplateCredentialConfiguration( + credential_name="my-credential" + ) + assert config.credential_name == "my-credential" + + def test_optional_fields(self): + config = TemplateCredentialConfiguration() + assert config.credential_name is None + + +class TestTemplateArmsConfiguration: + + def test_create_full(self): + config = TemplateArmsConfiguration( + arms_license_key="key-123", enable_arms=True + ) + assert config.arms_license_key == "key-123" + assert config.enable_arms is True + + def test_enable_arms_required(self): + with pytest.raises(Exception): + TemplateArmsConfiguration() # type: ignore + + def test_disabled_arms(self): + config = TemplateArmsConfiguration(enable_arms=False) + assert config.enable_arms is False + assert config.arms_license_key is None + + +class TestTemplateContainerConfiguration: + + def test_create_full(self): + config = TemplateContainerConfiguration( + image="registry.example.com/my-image:latest", + command=["python", "app.py"], + acr_instance_id="acr-123", + image_registry_type="enterprise", + port=8080, + ) + assert config.image == "registry.example.com/my-image:latest" + assert config.command == ["python", "app.py"] + assert config.acr_instance_id == "acr-123" + assert config.image_registry_type == "enterprise" + assert config.port == 8080 + + def test_optional_fields(self): + config = TemplateContainerConfiguration() + assert config.image is None + assert config.command is None + assert config.port is None + + +class TestTemplateMcpOptions: + + def test_create_full(self): + config = TemplateMcpOptions( + enabled_tools=["tool1", "tool2"], transport="sse" + ) + assert config.enabled_tools == ["tool1", "tool2"] + assert config.transport == "sse" + + def test_optional_fields(self): + config = TemplateMcpOptions() + assert config.enabled_tools is None + assert config.transport is None + + +class TestTemplateMcpState: + + def test_create_full(self): + state = TemplateMcpState( + access_endpoint="https://mcp.example.com", + status="READY", + status_reason="OK", + ) + assert state.access_endpoint == "https://mcp.example.com" + assert state.status == "READY" + assert state.status_reason == "OK" + + def test_optional_fields(self): + state = TemplateMcpState() + assert state.access_endpoint is None + assert state.status is None + assert state.status_reason is None + + +# ==================== TemplateInput 测试 ==================== + + +class TestTemplateInput: + + def test_create_code_interpreter(self): + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="my-ci-template", + ) + assert input_obj.template_type == TemplateType.CODE_INTERPRETER + assert input_obj.template_name == "my-ci-template" + assert input_obj.cpu == 2.0 + assert input_obj.memory == 4096 + assert input_obj.disk_size == 512 + + def test_create_browser_default_disk_size(self): + input_obj = TemplateInput( + template_type=TemplateType.BROWSER, + template_name="my-browser-template", + ) + assert input_obj.disk_size == 10240 + + def test_create_aio_default_values(self): + input_obj = TemplateInput( + template_type=TemplateType.AIO, + template_name="my-aio-template", + ) + assert input_obj.disk_size == 10240 + assert input_obj.cpu == 4.0 + assert input_obj.memory == 8192 + + def test_create_custom_default_disk_size(self): + input_obj = TemplateInput( + template_type=TemplateType.CUSTOM, + template_name="my-custom-template", + ) + assert input_obj.disk_size == 512 + + def test_explicit_disk_size_not_overridden(self): + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test", + disk_size=1024, + ) + assert input_obj.disk_size == 1024 + + def test_browser_wrong_disk_size_raises(self): + with pytest.raises(ValueError, match="disk_size should be 10240"): + TemplateInput( + template_type=TemplateType.BROWSER, + template_name="test", + disk_size=512, + ) + + def test_aio_wrong_disk_size_raises(self): + with pytest.raises(ValueError, match="disk_size should be 10240"): + TemplateInput( + template_type=TemplateType.AIO, + template_name="test", + disk_size=512, + ) + + def test_default_network_configuration(self): + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + assert input_obj.network_configuration is not None + assert ( + input_obj.network_configuration.network_mode + == TemplateNetworkMode.PUBLIC + ) + + def test_default_idle_timeout(self): + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + assert input_obj.sandbox_idle_timeout_in_seconds == 1800 + + def test_default_ttl(self): + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + assert input_obj.sandbox_ttlin_seconds == 21600 + + def test_default_concurrency(self): + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + assert input_obj.share_concurrency_limit_per_sandbox == 200 + + def test_all_optional_fields(self): + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="full-test", + cpu=4.0, + memory=8192, + execution_role_arn="acs:ram::123:role/my-role", + sandbox_idle_timeout_in_seconds=3600, + description="Test template", + environment_variables={"KEY": "VALUE"}, + oss_configuration=[ + TemplateOssConfiguration( + bucket_name="b", + mount_point="/mnt", + prefix="/", + region="cn-hangzhou", + ) + ], + log_configuration=TemplateLogConfiguration( + project="p", logstore="l" + ), + credential_configuration=TemplateCredentialConfiguration( + credential_name="cred" + ), + arms_configuration=TemplateArmsConfiguration(enable_arms=False), + container_configuration=TemplateContainerConfiguration(image="img"), + allow_anonymous_manage=True, + ) + assert input_obj.description == "Test template" + assert input_obj.environment_variables == {"KEY": "VALUE"} + assert input_obj.oss_configuration is not None + assert input_obj.log_configuration is not None + assert input_obj.credential_configuration is not None + assert input_obj.arms_configuration is not None + assert input_obj.container_configuration is not None + assert input_obj.allow_anonymous_manage is True + + def test_model_dump_alias(self): + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test", + ) + data = input_obj.model_dump(by_alias=True) + assert "templateType" in data + assert "templateName" in data + + def test_auto_generated_template_name(self): + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + assert input_obj.template_name is not None + assert input_obj.template_name.startswith("sandbox_template_") + + def test_set_disk_size_default_with_explicit_disk_size(self): + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + disk_size=2048, + ) + assert input_obj.disk_size == 2048 + + def test_set_disk_size_default_browser_enum(self): + input_obj = TemplateInput( + template_type=TemplateType.BROWSER, + ) + assert input_obj.disk_size == 10240 + + def test_set_disk_size_default_aio_enum(self): + input_obj = TemplateInput( + template_type=TemplateType.AIO, + ) + assert input_obj.disk_size == 10240 + assert input_obj.cpu == 4.0 + assert input_obj.memory == 8192 + + def test_model_validator_with_camel_case_dict(self): + input_obj = TemplateInput.model_validate({ + "template_type": "CodeInterpreter", + "disk_size": 2048, + }) + assert input_obj.disk_size == 2048 + + def test_model_validator_browser_from_dict(self): + input_obj = TemplateInput.model_validate({"template_type": "Browser"}) + assert input_obj.disk_size == 10240 + + def test_model_validator_aio_from_dict(self): + input_obj = TemplateInput.model_validate({"template_type": "AllInOne"}) + assert input_obj.disk_size == 10240 + assert input_obj.cpu == 4.0 + assert input_obj.memory == 8192 + + +# ==================== SandboxInput 测试 ==================== + + +class TestSandboxInput: + + def test_create_minimal(self): + input_obj = SandboxInput(template_name="my-template") + assert input_obj.template_name == "my-template" + assert input_obj.sandbox_idle_timeout_seconds == 600 + + def test_create_full(self): + input_obj = SandboxInput( + template_name="my-template", + sandbox_idle_timeout_seconds=1200, + sandbox_id="sandbox-123", + nas_config=NASConfig(group_id=1000), + oss_mount_config=OSSMountConfig( + mount_points=[OSSMountPoint(bucket_name="b")] + ), + polar_fs_config=PolarFsConfig(user_id=1000), + ) + assert input_obj.sandbox_id == "sandbox-123" + assert input_obj.sandbox_idle_timeout_seconds == 1200 + assert input_obj.nas_config is not None + assert input_obj.oss_mount_config is not None + assert input_obj.polar_fs_config is not None + + def test_optional_fields(self): + input_obj = SandboxInput(template_name="t") + assert input_obj.sandbox_id is None + assert input_obj.nas_config is None + assert input_obj.oss_mount_config is None + assert input_obj.polar_fs_config is None + + +# ==================== ListSandboxesInput 测试 ==================== + + +class TestListSandboxesInput: + + def test_default_values(self): + input_obj = ListSandboxesInput() + assert input_obj.max_results == 10 + assert input_obj.next_token is None + assert input_obj.status is None + assert input_obj.template_name is None + assert input_obj.template_type is None + + def test_create_full(self): + input_obj = ListSandboxesInput( + max_results=20, + next_token="token-123", + status="RUNNING", + template_name="my-template", + template_type=TemplateType.CODE_INTERPRETER, + ) + assert input_obj.max_results == 20 + assert input_obj.next_token == "token-123" + assert input_obj.status == "RUNNING" + assert input_obj.template_name == "my-template" + assert input_obj.template_type == TemplateType.CODE_INTERPRETER + + +# ==================== ListSandboxesOutput 测试 ==================== + + +class TestListSandboxesOutput: + + def test_create_empty(self): + output = ListSandboxesOutput(sandboxes=[]) + assert len(output.sandboxes) == 0 + assert output.next_token is None + + def test_create_with_next_token(self): + output = ListSandboxesOutput(sandboxes=[], next_token="next-page-token") + assert output.next_token == "next-page-token" + + +# ==================== PageableInput 测试 ==================== + + +class TestPageableInput: + + def test_default_values(self): + input_obj = PageableInput() + assert input_obj.page_number == 1 + assert input_obj.page_size == 10 + + def test_create_custom(self): + input_obj = PageableInput( + page_number=3, + page_size=20, + template_type=TemplateType.BROWSER, + ) + assert input_obj.page_number == 3 + assert input_obj.page_size == 20 + assert input_obj.template_type == TemplateType.BROWSER + + def test_template_type_optional(self): + input_obj = PageableInput() + assert input_obj.template_type is None diff --git a/tests/unittests/sandbox/test_sandbox.py b/tests/unittests/sandbox/test_sandbox.py new file mode 100644 index 0000000..dd11c90 --- /dev/null +++ b/tests/unittests/sandbox/test_sandbox.py @@ -0,0 +1,1113 @@ +"""测试 agentrun.sandbox.sandbox 模块 / Test agentrun.sandbox.sandbox module""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.aio_sandbox import AioSandbox +from agentrun.sandbox.browser_sandbox import BrowserSandbox +from agentrun.sandbox.code_interpreter_sandbox import CodeInterpreterSandbox +from agentrun.sandbox.custom_sandbox import CustomSandbox +from agentrun.sandbox.model import ( + ListSandboxesInput, + TemplateInput, + TemplateType, +) +from agentrun.sandbox.sandbox import Sandbox +from agentrun.sandbox.template import Template +from agentrun.utils.config import Config + + +class MockTemplateData: + + def to_map(self): + return { + "templateId": "tmpl-123", + "templateName": "test-template", + "templateType": "CodeInterpreter", + "status": "READY", + } + + +class MockBrowserTemplateData: + + def to_map(self): + return { + "templateId": "tmpl-456", + "templateName": "test-browser", + "templateType": "Browser", + "status": "READY", + } + + +class MockAioTemplateData: + + def to_map(self): + return { + "templateId": "tmpl-789", + "templateName": "test-aio", + "templateType": "AllInOne", + "status": "READY", + } + + +class MockCustomTemplateData: + + def to_map(self): + return { + "templateId": "tmpl-000", + "templateName": "test-custom", + "templateType": "CustomImage", + "status": "READY", + } + + +class MockListTemplatesResult: + + def __init__(self, items): + self.items = items + + +class MockListSandboxesResult: + + def __init__(self, items, next_token=None): + self.items = items + self.next_token = next_token + + +class MockSandboxListItem: + + def to_map(self): + return { + "sandboxId": "sandbox-123", + "templateName": "test-template", + "status": "RUNNING", + } + + +# ==================== Sandbox.create 测试 ==================== + + +class TestSandboxCreate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_code_interpreter( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api.create_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-ci-123", + "templateName": "test-template", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.create( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + assert isinstance(result, CodeInterpreterSandbox) + assert result.sandbox_id == "sandbox-ci-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_browser(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockBrowserTemplateData() + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-br-123", + "templateName": "test-browser", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.create( + template_type=TemplateType.BROWSER, + template_name="test-browser", + ) + assert isinstance(result, BrowserSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_aio(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockAioTemplateData() + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-aio-123", + "templateName": "test-aio", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.create( + template_type=TemplateType.AIO, + template_name="test-aio", + ) + assert isinstance(result, AioSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_custom(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockCustomTemplateData() + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-custom-123", + "templateName": "test-custom", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.create( + template_type=TemplateType.CUSTOM, + template_name="test-custom", + ) + assert isinstance(result, CustomSandbox) + + def test_create_without_template_name(self): + with pytest.raises(ValueError, match="template_name is required"): + Sandbox.create(template_type=TemplateType.CODE_INTERPRETER) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_type_mismatch( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + with pytest.raises(ValueError, match="template_type of"): + Sandbox.create( + template_type=TemplateType.BROWSER, + template_name="test-template", + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_unsupported_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + unsupported = MagicMock() + unsupported.to_map.return_value = { + "templateName": "test", + "templateType": "UnknownType", + "status": "READY", + } + mock_control_api.get_template.return_value = unsupported + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-x"}, + } + mock_data_api_class.return_value = mock_data_api + + with pytest.raises(ValueError, match="is not supported"): + Sandbox.create( + template_type="UnknownType", + template_name="test", + ) + + +class TestSandboxCreateAsync: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async_code_interpreter( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-ci-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.create_async( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + assert isinstance(result, CodeInterpreterSandbox) + + @pytest.mark.asyncio + async def test_create_async_without_template_name(self): + with pytest.raises(ValueError, match="template_name is required"): + await Sandbox.create_async( + template_type=TemplateType.CODE_INTERPRETER + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async_type_mismatch( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + with pytest.raises(ValueError, match="template_type of"): + await Sandbox.create_async( + template_type=TemplateType.BROWSER, + template_name="test-template", + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async_browser( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockBrowserTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-br"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.create_async( + template_type=TemplateType.BROWSER, + template_name="test-browser", + ) + assert isinstance(result, BrowserSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async_aio( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockAioTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-aio"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.create_async( + template_type=TemplateType.AIO, + template_name="test-aio", + ) + assert isinstance(result, AioSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async_custom( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockCustomTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-custom"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.create_async( + template_type=TemplateType.CUSTOM, + template_name="test-custom", + ) + assert isinstance(result, CustomSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async_unsupported_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + unsupported = MagicMock() + unsupported.to_map.return_value = { + "templateName": "test", + "templateType": "UnknownType", + "status": "READY", + } + mock_control_api.get_template_async = AsyncMock( + return_value=unsupported + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-x"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + with pytest.raises(ValueError, match="is not supported"): + await Sandbox.create_async( + template_type="UnknownType", + template_name="test", + ) + + +# ==================== Sandbox.connect 测试 ==================== + + +class TestSandboxConnect: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_connect_code_interpreter_with_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-template", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.connect( + "sandbox-123", + template_type=TemplateType.CODE_INTERPRETER, + ) + assert isinstance(result, CodeInterpreterSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_connect_browser_with_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-browser", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.connect( + "sandbox-123", + template_type=TemplateType.BROWSER, + ) + assert isinstance(result, BrowserSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_connect_aio_with_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.connect( + "sandbox-123", + template_type=TemplateType.AIO, + ) + assert isinstance(result, AioSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_connect_without_type_resolves( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-template", + }, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.connect("sandbox-123") + assert isinstance(result, CodeInterpreterSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_connect_unsupported_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + mock_data_api_class.return_value = mock_data_api + + with pytest.raises(ValueError, match="Unsupported template type"): + Sandbox.connect( + "sandbox-123", + template_type=TemplateType.CUSTOM, + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_connect_no_template_name( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123", "templateName": None}, + } + mock_data_api_class.return_value = mock_data_api + + with pytest.raises(ValueError, match="has no template_name"): + Sandbox.connect("sandbox-123") + + +class TestSandboxConnectAsync: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_connect_async_code_interpreter( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.connect_async( + "sandbox-123", + template_type=TemplateType.CODE_INTERPRETER, + ) + assert isinstance(result, CodeInterpreterSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_connect_async_without_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockBrowserTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-browser", + }, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.connect_async("sandbox-123") + assert isinstance(result, BrowserSandbox) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_connect_async_no_template_name( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123", "templateName": None}, + } + ) + mock_data_api_class.return_value = mock_data_api + + with pytest.raises(ValueError, match="has no template_name"): + await Sandbox.connect_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_connect_async_unsupported_type( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + with pytest.raises(ValueError, match="Unsupported template type"): + await Sandbox.connect_async( + "sandbox-123", + template_type=TemplateType.CUSTOM, + ) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_connect_async_aio( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.connect_async( + "sandbox-123", template_type=TemplateType.AIO + ) + assert isinstance(result, AioSandbox) + + +# ==================== Sandbox 实例方法测试 ==================== + + +class TestSandboxInstanceMethods: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_stop_by_id(self, mock_data_api_class, mock_control_api_class): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.stop_by_id("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_stop_by_id_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.stop_by_id_async("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_by_id(self, mock_data_api_class, mock_control_api_class): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + mock_data_api_class.return_value = mock_data_api + + result = Sandbox.delete_by_id("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_by_id_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-123"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + result = await Sandbox.delete_by_id_async("sandbox-123") + assert result.sandbox_id == "sandbox-123" + + def test_get_without_sandbox_id(self): + sandbox = Sandbox() + with pytest.raises(ValueError, match="sandbox_id is required"): + sandbox.get() + + @pytest.mark.asyncio + async def test_get_async_without_sandbox_id(self): + sandbox = Sandbox() + with pytest.raises(ValueError, match="sandbox_id is required"): + await sandbox.get_async() + + def test_delete_without_sandbox_id(self): + sandbox = Sandbox() + with pytest.raises(ValueError, match="sandbox_id is required"): + sandbox.delete() + + @pytest.mark.asyncio + async def test_delete_async_without_sandbox_id(self): + sandbox = Sandbox() + with pytest.raises(ValueError, match="sandbox_id is required"): + await sandbox.delete_async() + + def test_stop_without_sandbox_id(self): + sandbox = Sandbox() + with pytest.raises(ValueError, match="sandbox_id is required"): + sandbox.stop() + + @pytest.mark.asyncio + async def test_stop_async_without_sandbox_id(self): + sandbox = Sandbox() + with pytest.raises(ValueError, match="sandbox_id is required"): + await sandbox.stop_async() + + +# ==================== Sandbox.list 测试 ==================== + + +class TestSandboxList: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_sync(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.list_sandboxes.return_value = MockListSandboxesResult( + [MockSandboxListItem()] + ) + mock_control_api_class.return_value = mock_control_api + + result = Sandbox.list() + assert len(result.sandboxes) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_list_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_sandboxes_async = AsyncMock( + return_value=MockListSandboxesResult([MockSandboxListItem()]) + ) + mock_control_api_class.return_value = mock_control_api + + result = await Sandbox.list_async() + assert len(result.sandboxes) == 1 + + +# ==================== Sandbox Template 类方法测试 ==================== + + +class TestSandboxTemplateMethods: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_template(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.create_template.return_value = MockTemplateData() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = Sandbox.create_template(input_obj) + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.create_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = await Sandbox.create_template_async(input_obj) + assert result.template_name == "test-template" + + def test_create_template_no_type(self): + input_obj = MagicMock() + input_obj.template_type = None + with pytest.raises(ValueError, match="template_type is required"): + Sandbox.create_template(input_obj) + + @pytest.mark.asyncio + async def test_create_template_async_no_type(self): + input_obj = MagicMock() + input_obj.template_type = None + with pytest.raises(ValueError, match="template_type is required"): + await Sandbox.create_template_async(input_obj) + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_template(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + result = Sandbox.get_template("test-template") + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + result = await Sandbox.get_template_async("test-template") + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_update_template(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.update_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + result = Sandbox.update_template("test-template", input_obj) + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_update_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + result = await Sandbox.update_template_async("test-template", input_obj) + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_template(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.delete_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + result = Sandbox.delete_template("test-template") + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_template_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + result = await Sandbox.delete_template_async("test-template") + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + [MockTemplateData()] + ) + mock_control_api_class.return_value = mock_control_api + + result = Sandbox.list_templates() + assert len(result) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_list_templates_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates_async = AsyncMock( + return_value=MockListTemplatesResult([MockTemplateData()]) + ) + mock_control_api_class.return_value = mock_control_api + + result = await Sandbox.list_templates_async() + assert len(result) == 1 + + +# ==================== None-guard tests for class methods ==================== + + +class TestSandboxNoneGuards: + + def test_stop_by_id_none(self): + with pytest.raises(ValueError, match="sandbox_id is required"): + Sandbox.stop_by_id(None) + + @pytest.mark.asyncio + async def test_stop_by_id_async_none(self): + with pytest.raises(ValueError, match="sandbox_id is required"): + await Sandbox.stop_by_id_async(None) + + def test_delete_by_id_none(self): + with pytest.raises(ValueError, match="sandbox_id is required"): + Sandbox.delete_by_id(None) + + @pytest.mark.asyncio + async def test_delete_by_id_async_none(self): + with pytest.raises(ValueError, match="sandbox_id is required"): + await Sandbox.delete_by_id_async(None) + + def test_connect_none_sandbox_id(self): + with pytest.raises(ValueError, match="sandbox_id is required"): + Sandbox.connect(None) + + @pytest.mark.asyncio + async def test_connect_async_none_sandbox_id(self): + with pytest.raises(ValueError, match="sandbox_id is required"): + await Sandbox.connect_async(None) + + def test_get_template_none(self): + with pytest.raises(ValueError, match="template_name is required"): + Sandbox.get_template(None) + + @pytest.mark.asyncio + async def test_get_template_async_none(self): + with pytest.raises(ValueError, match="template_name is required"): + await Sandbox.get_template_async(None) + + def test_update_template_none(self): + with pytest.raises(ValueError, match="template_name is required"): + Sandbox.update_template( + None, TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + ) + + @pytest.mark.asyncio + async def test_update_template_async_none(self): + with pytest.raises(ValueError, match="template_name is required"): + await Sandbox.update_template_async( + None, TemplateInput(template_type=TemplateType.CODE_INTERPRETER) + ) + + def test_delete_template_none(self): + with pytest.raises(ValueError, match="template_name is required"): + Sandbox.delete_template(None) + + @pytest.mark.asyncio + async def test_delete_template_async_none(self): + with pytest.raises(ValueError, match="template_name is required"): + await Sandbox.delete_template_async(None) + + +# ==================== Instance method happy paths ==================== + + +class TestSandboxInstanceHappyPath: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_calls_connect( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.get_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sb-1", "templateName": "tpl"}, + } + mock_data_api_class.return_value = mock_data_api + + sb = Sandbox(sandbox_id="sb-1") + result = sb.get() + assert result.sandbox_id == "sb-1" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_async_calls_connect( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + mock_data_api = MagicMock() + mock_data_api.get_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sb-1", "templateName": "tpl"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + sb = Sandbox(sandbox_id="sb-1") + result = await sb.get_async() + assert result.sandbox_id == "sb-1" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_calls_delete_by_id( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sb-1"}, + } + mock_data_api_class.return_value = mock_data_api + + sb = Sandbox(sandbox_id="sb-1") + result = sb.delete() + assert result.sandbox_id == "sb-1" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_async_calls_delete_by_id( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sb-1"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + sb = Sandbox(sandbox_id="sb-1") + result = await sb.delete_async() + assert result.sandbox_id == "sb-1" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_stop_calls_stop_by_id( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sb-1"}, + } + mock_data_api_class.return_value = mock_data_api + + sb = Sandbox(sandbox_id="sb-1") + result = sb.stop() + assert result.sandbox_id == "sb-1" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_stop_async_calls_stop_by_id( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.stop_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": {"sandboxId": "sb-1"}, + } + ) + mock_data_api_class.return_value = mock_data_api + + sb = Sandbox(sandbox_id="sb-1") + result = await sb.stop_async() + assert result.sandbox_id == "sb-1" diff --git a/tests/unittests/sandbox/test_template.py b/tests/unittests/sandbox/test_template.py new file mode 100644 index 0000000..ea01f2d --- /dev/null +++ b/tests/unittests/sandbox/test_template.py @@ -0,0 +1,431 @@ +"""测试 agentrun.sandbox.template 模块 / Test agentrun.sandbox.template module""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.sandbox.model import PageableInput, TemplateInput, TemplateType +from agentrun.sandbox.template import Template +from agentrun.utils.config import Config + + +class MockTemplateData: + + def to_map(self): + return { + "templateId": "tmpl-123", + "templateName": "test-template", + "templateType": "CodeInterpreter", + "cpu": 2.0, + "memory": 4096, + "diskSize": 512, + "status": "READY", + "createdAt": "2024-01-01T00:00:00Z", + "lastUpdatedAt": "2024-01-01T00:00:00Z", + } + + +class MockListTemplatesResult: + + def __init__(self, items): + self.items = items + + +# ==================== Template.create 测试 ==================== + + +class TestTemplateCreate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_sync(self, mock_data_api_class, mock_control_api_class): + mock_control_api = MagicMock() + mock_control_api.create_template.return_value = MockTemplateData() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = Template.create(input_obj) + assert result.template_name == "test-template" + assert result.status == "READY" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.create_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = await Template.create_async(input_obj) + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_with_config( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.create_template.return_value = MockTemplateData() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + config = Config(access_key_id="custom-ak") + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = Template.create(input_obj, config=config) + assert result is not None + + +# ==================== Template.delete_by_name 测试 ==================== + + +class TestTemplateDelete: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_by_name_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + result = Template.delete_by_name("test-template") + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_by_name_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + result = await Template.delete_by_name_async("test-template") + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_with_config( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.delete_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + config = Config(access_key_id="custom-ak") + result = Template.delete_by_name("test-template", config=config) + assert result is not None + + +# ==================== Template.update_by_name 测试 ==================== + + +class TestTemplateUpdate: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_update_by_name_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = Template.update_by_name("test-template", input_obj) + assert result is not None + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_update_by_name_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.update_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + input_obj = TemplateInput( + template_type=TemplateType.CODE_INTERPRETER, + template_name="test-template", + ) + result = await Template.update_by_name_async("test-template", input_obj) + assert result is not None + + +# ==================== Template.get_by_name 测试 ==================== + + +class TestTemplateGet: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_by_name_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + result = Template.get_by_name("test-template") + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_get_by_name_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template_async = AsyncMock( + return_value=MockTemplateData() + ) + mock_control_api_class.return_value = mock_control_api + + result = await Template.get_by_name_async("test-template") + assert result.template_name == "test-template" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_get_by_name_with_config( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.get_template.return_value = MockTemplateData() + mock_control_api_class.return_value = mock_control_api + + config = Config(access_key_id="custom-ak") + result = Template.get_by_name("test-template", config=config) + assert result is not None + + +# ==================== Template.list_templates 测试 ==================== + + +class TestTemplateList: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + [MockTemplateData()] + ) + mock_control_api_class.return_value = mock_control_api + + result = Template.list_templates() + assert len(result) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_list_templates_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates_async = AsyncMock( + return_value=MockListTemplatesResult([MockTemplateData()]) + ) + mock_control_api_class.return_value = mock_control_api + + result = await Template.list_templates_async() + assert len(result) == 1 + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_list_templates_with_input( + self, mock_data_api_class, mock_control_api_class + ): + mock_control_api = MagicMock() + mock_control_api.list_templates.return_value = MockListTemplatesResult( + [MockTemplateData()] + ) + mock_control_api_class.return_value = mock_control_api + + input_obj = PageableInput(page_number=1, page_size=5) + result = Template.list_templates(input=input_obj) + assert len(result) == 1 + + +# ==================== Template.create_sandbox 测试 ==================== + + +class TestTemplateCreateSandbox: + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_sandbox_sync( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-template", + }, + } + mock_data_api_class.return_value = mock_data_api + + template = Template( + template_name="test-template", + template_type=TemplateType.CODE_INTERPRETER, + ) + result = template.create_sandbox() + assert result.sandbox_id == "sandbox-123" + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_create_sandbox_async( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox_async = AsyncMock( + return_value={ + "code": "SUCCESS", + "data": { + "sandboxId": "sandbox-123", + "templateName": "test-template", + }, + } + ) + mock_data_api_class.return_value = mock_data_api + + template = Template( + template_name="test-template", + template_type=TemplateType.CODE_INTERPRETER, + ) + result = await template.create_sandbox_async() + assert result.sandbox_id == "sandbox-123" + + def test_create_sandbox_no_template_name(self): + template = Template() + with pytest.raises(ValueError, match="Template name is required"): + template.create_sandbox() + + @pytest.mark.asyncio + async def test_create_sandbox_async_no_template_name(self): + template = Template() + with pytest.raises(ValueError, match="Template name is required"): + await template.create_sandbox_async() + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_create_sandbox_with_timeout( + self, mock_data_api_class, mock_control_api_class + ): + mock_data_api = MagicMock() + mock_data_api.create_sandbox.return_value = { + "code": "SUCCESS", + "data": {"sandboxId": "sandbox-456"}, + } + mock_data_api_class.return_value = mock_data_api + + template = Template(template_name="test-template") + result = template.create_sandbox(sandbox_idle_timeout_seconds=1200) + assert result.sandbox_id == "sandbox-456" + + +# ==================== Template 属性测试 ==================== + + +class TestTemplateProperties: + + def test_template_all_properties(self): + template = Template( + template_id="tmpl-123", + template_name="test-template", + template_version="v1", + template_arn="arn:123", + resource_name="res-123", + template_type=TemplateType.CODE_INTERPRETER, + cpu=2.0, + memory=4096, + disk_size=512, + description="Test template", + execution_role_arn="arn:role", + sandbox_idle_timeout_in_seconds=1800, + share_concurrency_limit_per_sandbox=200, + template_configuration={"key": "value"}, + environment_variables={"ENV": "VAL"}, + allow_anonymous_manage=False, + created_at="2024-01-01T00:00:00Z", + last_updated_at="2024-01-02T00:00:00Z", + status="READY", + status_reason="OK", + ) + assert template.template_id == "tmpl-123" + assert template.template_name == "test-template" + assert template.template_version == "v1" + assert template.template_arn == "arn:123" + assert template.resource_name == "res-123" + assert template.template_type == TemplateType.CODE_INTERPRETER + assert template.cpu == 2.0 + assert template.memory == 4096 + assert template.disk_size == 512 + assert template.description == "Test template" + assert template.execution_role_arn == "arn:role" + assert template.sandbox_idle_timeout_in_seconds == 1800 + assert template.share_concurrency_limit_per_sandbox == 200 + assert template.template_configuration == {"key": "value"} + assert template.environment_variables == {"ENV": "VAL"} + assert template.allow_anonymous_manage is False + assert template.created_at == "2024-01-01T00:00:00Z" + assert template.last_updated_at == "2024-01-02T00:00:00Z" + assert template.status == "READY" + assert template.status_reason == "OK" + + def test_template_optional_properties(self): + template = Template() + assert template.template_id is None + assert template.template_name is None + assert template.template_type is None + assert template.cpu is None + assert template.memory is None + assert template.disk_size is None + assert template.description is None + assert template.mcp_options is None + assert template.mcp_state is None + assert template.oss_configuration is None + assert template.log_configuration is None + assert template.credential_configuration is None + assert template.container_configuration is None + assert template.network_configuration is None + + def test_template_from_inner_object(self): + mock_data = MockTemplateData() + template = Template.from_inner_object(mock_data) + assert template.template_id == "tmpl-123" + assert template.template_name == "test-template" + assert template.template_type == TemplateType.CODE_INTERPRETER + assert template.status == "READY"