conftest.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import os
  2. import random
  3. import shutil
  4. import stat
  5. import time
  6. from pathlib import Path
  7. import pytest
  8. from pytest import TempPathFactory
  9. from openhands.core.config import load_app_config
  10. from openhands.core.logger import openhands_logger as logger
  11. from openhands.events import EventStream
  12. from openhands.runtime.base import Runtime
  13. from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
  14. from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
  15. from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
  16. from openhands.storage import get_file_store
  17. from openhands.utils.async_utils import call_async_from_sync
  18. TEST_IN_CI = os.getenv('TEST_IN_CI', 'False').lower() in ['true', '1', 'yes']
  19. TEST_RUNTIME = os.getenv('TEST_RUNTIME', 'eventstream').lower()
  20. RUN_AS_OPENHANDS = os.getenv('RUN_AS_OPENHANDS', 'True').lower() in ['true', '1', 'yes']
  21. test_mount_path = ''
  22. project_dir = os.path.dirname(
  23. os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  24. )
  25. sandbox_test_folder = '/openhands/workspace'
  26. def _get_runtime_sid(runtime: Runtime):
  27. logger.debug(f'\nruntime.sid: {runtime.sid}')
  28. return runtime.sid
  29. def _get_host_folder(runtime: Runtime):
  30. return runtime.config.workspace_mount_path
  31. def _get_sandbox_folder(runtime: Runtime):
  32. sid = _get_runtime_sid(runtime)
  33. if sid:
  34. return Path(os.path.join(sandbox_test_folder, sid))
  35. return None
  36. def _remove_folder(folder: str) -> bool:
  37. success = False
  38. if folder and os.path.isdir(folder):
  39. try:
  40. os.rmdir(folder)
  41. success = True
  42. except OSError:
  43. try:
  44. shutil.rmtree(folder)
  45. success = True
  46. except OSError:
  47. pass
  48. logger.debug(f'\nCleanup: `{folder}`: ' + ('[OK]' if success else '[FAILED]'))
  49. return success
  50. def _close_test_runtime(runtime: Runtime):
  51. if isinstance(runtime, EventStreamRuntime):
  52. runtime.close(rm_all_containers=False)
  53. else:
  54. runtime.close()
  55. time.sleep(1)
  56. def _reset_pwd():
  57. global project_dir
  58. # Try to change back to project directory
  59. try:
  60. os.chdir(project_dir)
  61. logger.info(f'Changed back to project directory `{project_dir}')
  62. except Exception as e:
  63. logger.error(f'Failed to change back to project directory: {e}')
  64. # *****************************************************************************
  65. # *****************************************************************************
  66. @pytest.fixture(autouse=True)
  67. def print_method_name(request):
  68. print(
  69. '\n\n########################################################################'
  70. )
  71. print(f'Running test: {request.node.name}')
  72. print(
  73. '########################################################################\n\n'
  74. )
  75. @pytest.fixture
  76. def temp_dir(tmp_path_factory: TempPathFactory, request) -> str:
  77. """Creates a unique temporary directory.
  78. Upon finalization, the temporary directory and its content is removed.
  79. The cleanup function is also called upon KeyboardInterrupt.
  80. Parameters:
  81. - tmp_path_factory (TempPathFactory): A TempPathFactory class
  82. Returns:
  83. - str: The temporary directory path that was created
  84. """
  85. temp_dir = tmp_path_factory.mktemp(
  86. 'rt_' + str(random.randint(100000, 999999)), numbered=False
  87. )
  88. logger.info(f'\n*** {request.node.name}\n>> temp folder: {temp_dir}\n')
  89. # Set permissions to ensure the directory is writable and deletable
  90. os.chmod(temp_dir, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # 0777 permissions
  91. def cleanup():
  92. global project_dir
  93. os.chdir(project_dir)
  94. _remove_folder(temp_dir)
  95. request.addfinalizer(cleanup)
  96. return str(temp_dir)
  97. # Depending on TEST_RUNTIME, feed the appropriate box class(es) to the test.
  98. def get_runtime_classes():
  99. runtime = TEST_RUNTIME
  100. if runtime.lower() == 'eventstream':
  101. return [EventStreamRuntime]
  102. elif runtime.lower() == 'remote':
  103. return [RemoteRuntime]
  104. else:
  105. raise ValueError(f'Invalid runtime: {runtime}')
  106. def get_run_as_openhands():
  107. print(
  108. '\n\n########################################################################'
  109. )
  110. print('USER: ' + 'openhands' if RUN_AS_OPENHANDS else 'root')
  111. print(
  112. '########################################################################\n\n'
  113. )
  114. return [RUN_AS_OPENHANDS]
  115. @pytest.fixture(scope='module') # for xdist
  116. def runtime_setup_module():
  117. _reset_pwd()
  118. yield
  119. _reset_pwd()
  120. @pytest.fixture(scope='session') # not for xdist
  121. def runtime_setup_session():
  122. _reset_pwd()
  123. yield
  124. _reset_pwd()
  125. # This assures that all tests run together per runtime, not alternating between them,
  126. # which cause errors (especially outside GitHub actions).
  127. @pytest.fixture(scope='module', params=get_runtime_classes())
  128. def runtime_cls(request):
  129. time.sleep(1)
  130. return request.param
  131. # TODO: We will change this to `run_as_user` when `ServerRuntime` is deprecated.
  132. # since `EventStreamRuntime` supports running as an arbitrary user.
  133. @pytest.fixture(scope='module', params=get_run_as_openhands())
  134. def run_as_openhands(request):
  135. time.sleep(1)
  136. return request.param
  137. @pytest.fixture(scope='module', params=None)
  138. def base_container_image(request):
  139. time.sleep(1)
  140. env_image = os.environ.get('SANDBOX_BASE_CONTAINER_IMAGE')
  141. if env_image:
  142. request.param = env_image
  143. else:
  144. if not hasattr(request, 'param'): # prevent runtime AttributeError
  145. request.param = None
  146. if request.param is None and hasattr(request.config, 'sandbox'):
  147. try:
  148. request.param = request.config.sandbox.getoption(
  149. '--base_container_image'
  150. )
  151. except ValueError:
  152. request.param = None
  153. if request.param is None:
  154. request.param = pytest.param(
  155. 'nikolaik/python-nodejs:python3.12-nodejs22',
  156. 'golang:1.23-bookworm',
  157. )
  158. print(f'Container image: {request.param}')
  159. return request.param
  160. def _load_runtime(
  161. temp_dir,
  162. runtime_cls,
  163. run_as_openhands: bool = True,
  164. enable_auto_lint: bool = False,
  165. base_container_image: str | None = None,
  166. browsergym_eval_env: str | None = None,
  167. use_workspace: bool | None = None,
  168. force_rebuild_runtime: bool = False,
  169. runtime_startup_env_vars: dict[str, str] | None = None,
  170. ) -> Runtime:
  171. sid = 'rt_' + str(random.randint(100000, 999999))
  172. # AgentSkills need to be initialized **before** Jupyter
  173. # otherwise Jupyter will not access the proper dependencies installed by AgentSkills
  174. plugins = [AgentSkillsRequirement(), JupyterRequirement()]
  175. config = load_app_config()
  176. config.run_as_openhands = run_as_openhands
  177. config.sandbox.force_rebuild_runtime = force_rebuild_runtime
  178. # Folder where all tests create their own folder
  179. global test_mount_path
  180. if use_workspace:
  181. test_mount_path = os.path.join(config.workspace_base, 'rt')
  182. else:
  183. test_mount_path = os.path.join(
  184. temp_dir, sid
  185. ) # need a subfolder to avoid conflicts
  186. config.workspace_mount_path = test_mount_path
  187. # Mounting folder specific for this test inside the sandbox
  188. config.workspace_mount_path_in_sandbox = f'{sandbox_test_folder}/{sid}'
  189. print('\nPaths used:')
  190. print(f'use_host_network: {config.sandbox.use_host_network}')
  191. print(f'workspace_base: {config.workspace_base}')
  192. print(f'workspace_mount_path: {config.workspace_mount_path}')
  193. print(
  194. f'workspace_mount_path_in_sandbox: {config.workspace_mount_path_in_sandbox}\n'
  195. )
  196. config.sandbox.browsergym_eval_env = browsergym_eval_env
  197. config.sandbox.enable_auto_lint = enable_auto_lint
  198. if runtime_startup_env_vars is not None:
  199. config.sandbox.runtime_startup_env_vars = runtime_startup_env_vars
  200. if base_container_image is not None:
  201. config.sandbox.base_container_image = base_container_image
  202. config.sandbox.runtime_container_image = None
  203. file_store = get_file_store(config.file_store, config.file_store_path)
  204. event_stream = EventStream(sid, file_store)
  205. runtime = runtime_cls(
  206. config=config,
  207. event_stream=event_stream,
  208. sid=sid,
  209. plugins=plugins,
  210. )
  211. call_async_from_sync(runtime.connect)
  212. time.sleep(2)
  213. return runtime
  214. # Export necessary function
  215. __all__ = [
  216. '_load_runtime',
  217. '_get_host_folder',
  218. '_get_sandbox_folder',
  219. '_remove_folder',
  220. ]