conftest.py 8.7 KB

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