Revert "tests: reorganize unit tests into subdirectories mirroring source modules" (#10437)

This commit is contained in:
Engel Nyst
2025-08-17 02:33:17 +02:00
committed by GitHub
parent 95ef8965b7
commit 315d391414
124 changed files with 4 additions and 4 deletions

View File

@@ -1,676 +0,0 @@
import hashlib
import os
import tempfile
import uuid
from importlib.metadata import version
from pathlib import Path
from unittest.mock import ANY, MagicMock, mock_open, patch
import docker
import pytest
import toml
from pytest import TempPathFactory
import openhands
from openhands import __version__ as oh_version
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder.docker import DockerRuntimeBuilder
from openhands.runtime.utils.runtime_build import (
BuildFromImageType,
_generate_dockerfile,
build_runtime_image,
get_hash_for_lock_files,
get_hash_for_source_files,
get_runtime_image_repo,
get_runtime_image_repo_and_tag,
prep_build_folder,
truncate_hash,
)
OH_VERSION = f'oh_v{oh_version}'
DEFAULT_BASE_IMAGE = 'nikolaik/python-nodejs:python3.12-nodejs22'
@pytest.fixture
def temp_dir(tmp_path_factory: TempPathFactory) -> str:
return str(tmp_path_factory.mktemp('test_runtime_build'))
@pytest.fixture
def mock_docker_client():
mock_client = MagicMock(spec=docker.DockerClient)
mock_client.version.return_value = {
'Version': '20.10.0',
'Components': [{'Name': 'Engine', 'Version': '20.10.0'}],
} # Ensure version is >= 18.09
return mock_client
@pytest.fixture
def docker_runtime_builder():
client = docker.from_env()
return DockerRuntimeBuilder(client)
def _check_source_code_in_dir(temp_dir):
# assert there is a folder called 'code' in the temp_dir
code_dir = os.path.join(temp_dir, 'code')
assert os.path.exists(code_dir)
assert os.path.isdir(code_dir)
# check the source file is the same as the current code base
assert os.path.exists(os.path.join(code_dir, 'pyproject.toml'))
# The source code should only include the `openhands` folder,
# and pyproject.toml & poetry.lock that are needed to build the runtime image
assert set(os.listdir(code_dir)) == {
'openhands',
'pyproject.toml',
'poetry.lock',
}
assert os.path.exists(os.path.join(code_dir, 'openhands'))
assert os.path.isdir(os.path.join(code_dir, 'openhands'))
# make sure the version from the pyproject.toml is the same as the current version
with open(os.path.join(code_dir, 'pyproject.toml'), 'r') as f:
pyproject = toml.load(f)
_pyproject_version = pyproject['tool']['poetry']['version']
assert _pyproject_version == version('openhands-ai')
def test_prep_build_folder(temp_dir):
shutil_mock = MagicMock()
with patch(f'{prep_build_folder.__module__}.shutil', shutil_mock):
prep_build_folder(
temp_dir,
base_image=DEFAULT_BASE_IMAGE,
build_from=BuildFromImageType.SCRATCH,
extra_deps=None,
)
# make sure that the code (openhands/) and microagents folder were copied
assert shutil_mock.copytree.call_count == 2
assert shutil_mock.copy2.call_count == 2
# Now check dockerfile is in the folder
dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
assert os.path.exists(dockerfile_path)
assert os.path.isfile(dockerfile_path)
def test_get_hash_for_lock_files():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash = get_hash_for_lock_files('some_base_image', enable_browser=True)
# Since we mocked open to always return "mock_data", the hash is the result
# of hashing the name of the base image followed by "mock-data" twice
md5 = hashlib.md5()
md5.update('some_base_image'.encode())
for _ in range(2):
md5.update('mock-data'.encode())
assert hash == truncate_hash(md5.hexdigest())
def test_get_hash_for_lock_files_different_enable_browser():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash_true = get_hash_for_lock_files('some_base_image', enable_browser=True)
hash_false = get_hash_for_lock_files('some_base_image', enable_browser=False)
# Hash with enable_browser=True should not include the enable_browser value
md5_true = hashlib.md5()
md5_true.update('some_base_image'.encode())
for _ in range(2):
md5_true.update('mock-data'.encode())
expected_hash_true = truncate_hash(md5_true.hexdigest())
# Hash with enable_browser=False should include the enable_browser value
md5_false = hashlib.md5()
md5_false.update('some_base_image'.encode())
md5_false.update('False'.encode()) # enable_browser=False is included
for _ in range(2):
md5_false.update('mock-data'.encode())
expected_hash_false = truncate_hash(md5_false.hexdigest())
assert hash_true == expected_hash_true
assert hash_false == expected_hash_false
assert hash_true != hash_false # They should be different
def test_get_hash_for_source_files():
dirhash_mock = MagicMock()
dirhash_mock.return_value = '1f69bd20d68d9e3874d5bf7f7459709b'
with patch(f'{get_hash_for_source_files.__module__}.dirhash', dirhash_mock):
result = get_hash_for_source_files()
assert result == truncate_hash(dirhash_mock.return_value)
dirhash_mock.assert_called_once_with(
Path(openhands.__file__).parent,
'md5',
ignore=[
'.*/', # hidden directories
'__pycache__/',
'*.pyc',
],
)
def test_generate_dockerfile_build_from_scratch():
base_image = 'debian:11'
dockerfile_content = _generate_dockerfile(
base_image,
build_from=BuildFromImageType.SCRATCH,
)
assert base_image in dockerfile_content
assert 'apt-get update' in dockerfile_content
assert 'wget curl' in dockerfile_content
assert 'poetry' in dockerfile_content and '-c conda-forge' in dockerfile_content
assert 'python=3.12' in dockerfile_content
# Check the update command
assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
assert (
'/openhands/micromamba/bin/micromamba run -n openhands poetry install'
in dockerfile_content
)
def test_generate_dockerfile_build_from_lock():
base_image = 'debian:11'
dockerfile_content = _generate_dockerfile(
base_image,
build_from=BuildFromImageType.LOCK,
)
# These commands SHOULD NOT include in the dockerfile if build_from_scratch is False
assert 'wget curl sudo apt-utils git' not in dockerfile_content
assert '-c conda-forge' not in dockerfile_content
assert 'python=3.12' not in dockerfile_content
assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content
assert 'poetry install' not in dockerfile_content
# These update commands SHOULD still in the dockerfile
assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
def test_generate_dockerfile_build_from_versioned():
base_image = 'debian:11'
dockerfile_content = _generate_dockerfile(
base_image,
build_from=BuildFromImageType.VERSIONED,
)
# these commands should not exist when build from versioned
assert 'wget curl sudo apt-utils git' not in dockerfile_content
assert '-c conda-forge' not in dockerfile_content
assert 'python=3.12' not in dockerfile_content
assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content
# this SHOULD exist when build from versioned
assert 'poetry install' in dockerfile_content
assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
def test_get_runtime_image_repo_and_tag_eventstream():
base_image = 'debian:11'
img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag == f'{OH_VERSION}_image_debian_tag_11'
)
img_repo, img_tag = get_runtime_image_repo_and_tag(DEFAULT_BASE_IMAGE)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag
== f'{OH_VERSION}_image_nikolaik_s_python-nodejs_tag_python3.12-nodejs22'
)
base_image = 'ubuntu'
img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag == f'{OH_VERSION}_image_ubuntu_tag_latest'
)
def test_build_runtime_image_from_scratch():
base_image = 'debian:11'
mock_lock_hash = MagicMock()
mock_lock_hash.return_value = 'mock-lock-tag'
mock_versioned_tag = MagicMock()
mock_versioned_tag.return_value = 'mock-versioned-tag'
mock_source_hash = MagicMock()
mock_source_hash.return_value = 'mock-source-tag'
mock_runtime_builder = MagicMock()
mock_runtime_builder.image_exists.return_value = False
mock_runtime_builder.build.return_value = (
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder = MagicMock()
mod = build_runtime_image.__module__
with (
patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
patch(
f'{build_runtime_image.__module__}.prep_build_folder',
mock_prep_build_folder,
),
):
image_name = build_runtime_image(base_image, mock_runtime_builder)
mock_runtime_builder.build.assert_called_once_with(
path=ANY,
tags=[
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag',
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
],
platform=None,
extra_build_args=None,
)
assert (
image_name
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder.assert_called_once_with(
ANY, base_image, BuildFromImageType.SCRATCH, None, True
)
def test_build_runtime_image_exact_hash_exist():
base_image = 'debian:11'
mock_lock_hash = MagicMock()
mock_lock_hash.return_value = 'mock-lock-tag'
mock_source_hash = MagicMock()
mock_source_hash.return_value = 'mock-source-tag'
mock_versioned_tag = MagicMock()
mock_versioned_tag.return_value = 'mock-versioned-tag'
mock_runtime_builder = MagicMock()
mock_runtime_builder.image_exists.return_value = True
mock_runtime_builder.build.return_value = (
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder = MagicMock()
mod = build_runtime_image.__module__
with (
patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
patch(
f'{build_runtime_image.__module__}.prep_build_folder',
mock_prep_build_folder,
),
):
image_name = build_runtime_image(base_image, mock_runtime_builder)
assert (
image_name
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_runtime_builder.build.assert_not_called()
mock_prep_build_folder.assert_not_called()
def test_build_runtime_image_exact_hash_not_exist_and_lock_exist():
base_image = 'debian:11'
mock_lock_hash = MagicMock()
mock_lock_hash.return_value = 'mock-lock-tag'
mock_source_hash = MagicMock()
mock_source_hash.return_value = 'mock-source-tag'
mock_versioned_tag = MagicMock()
mock_versioned_tag.return_value = 'mock-versioned-tag'
mock_runtime_builder = MagicMock()
def image_exists_side_effect(image_name, *args):
if 'mock-lock-tag_mock-source-tag' in image_name:
return False
elif 'mock-lock-tag' in image_name:
return True
elif 'mock-versioned-tag' in image_name:
# just to test we should never include versioned tag in a non-from-scratch build
# in real case it should be True when lock exists
return False
else:
raise ValueError(f'Unexpected image name: {image_name}')
mock_runtime_builder.image_exists.side_effect = image_exists_side_effect
mock_runtime_builder.build.return_value = (
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder = MagicMock()
mod = build_runtime_image.__module__
with (
patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
patch(
f'{build_runtime_image.__module__}.prep_build_folder',
mock_prep_build_folder,
),
):
image_name = build_runtime_image(base_image, mock_runtime_builder)
assert (
image_name
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_runtime_builder.build.assert_called_once_with(
path=ANY,
tags=[
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag',
# lock tag will NOT be included - since it already exists
# VERSION tag will NOT be included except from scratch
],
platform=None,
extra_build_args=None,
)
mock_prep_build_folder.assert_called_once_with(
ANY,
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
BuildFromImageType.LOCK,
None,
True,
)
def test_build_runtime_image_exact_hash_not_exist_and_lock_not_exist_and_versioned_exist():
base_image = 'debian:11'
mock_lock_hash = MagicMock()
mock_lock_hash.return_value = 'mock-lock-tag'
mock_source_hash = MagicMock()
mock_source_hash.return_value = 'mock-source-tag'
mock_versioned_tag = MagicMock()
mock_versioned_tag.return_value = 'mock-versioned-tag'
mock_runtime_builder = MagicMock()
def image_exists_side_effect(image_name, *args):
if 'mock-lock-tag_mock-source-tag' in image_name:
return False
elif 'mock-lock-tag' in image_name:
return False
elif 'mock-versioned-tag' in image_name:
return True
else:
raise ValueError(f'Unexpected image name: {image_name}')
mock_runtime_builder.image_exists.side_effect = image_exists_side_effect
mock_runtime_builder.build.return_value = (
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder = MagicMock()
mod = build_runtime_image.__module__
with (
patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
patch(
f'{build_runtime_image.__module__}.prep_build_folder',
mock_prep_build_folder,
),
):
image_name = build_runtime_image(base_image, mock_runtime_builder)
assert (
image_name
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_runtime_builder.build.assert_called_once_with(
path=ANY,
tags=[
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag',
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
# VERSION tag will NOT be included except from scratch
],
platform=None,
extra_build_args=None,
)
mock_prep_build_folder.assert_called_once_with(
ANY,
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
BuildFromImageType.VERSIONED,
None,
True,
)
# ==============================
# DockerRuntimeBuilder Tests
# ==============================
def test_output_build_progress(docker_runtime_builder):
layers = {}
docker_runtime_builder._output_build_progress(
{
'id': 'layer1',
'status': 'Downloading',
'progressDetail': {'current': 50, 'total': 100},
},
layers,
0,
)
assert layers['layer1']['status'] == 'Downloading'
assert layers['layer1']['progress'] == ''
assert layers['layer1']['last_logged'] == 50.0
@pytest.fixture(scope='function')
def live_docker_image():
client = docker.from_env()
unique_id = str(uuid.uuid4())[:8] # Use first 8 characters of a UUID
unique_prefix = f'test_image_{unique_id}'
dockerfile_content = f"""
# syntax=docker/dockerfile:1.4
FROM {DEFAULT_BASE_IMAGE} AS base
RUN apt-get update && apt-get install -y wget curl sudo apt-utils
FROM base AS intermediate
RUN mkdir -p /openhands
FROM intermediate AS final
RUN echo "Hello, OpenHands!" > /openhands/hello.txt
"""
with tempfile.TemporaryDirectory() as temp_dir:
dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
with open(dockerfile_path, 'w') as f:
f.write(dockerfile_content)
try:
image, logs = client.images.build(
path=temp_dir,
tag=f'{unique_prefix}:final',
buildargs={'DOCKER_BUILDKIT': '1'},
labels={'test': 'true'},
rm=True,
forcerm=True,
)
# Tag intermediary stages
client.api.tag(image.id, unique_prefix, 'base')
client.api.tag(image.id, unique_prefix, 'intermediate')
all_tags = [
f'{unique_prefix}:final',
f'{unique_prefix}:base',
f'{unique_prefix}:intermediate',
]
print(f'\nImage ID: {image.id}')
print(f'Image tags: {all_tags}\n')
yield image
finally:
# Clean up all tagged images
for tag in all_tags:
try:
client.images.remove(tag, force=True)
print(f'Removed image: {tag}')
except Exception as e:
print(f'Error removing image {tag}: {str(e)}')
def test_init(docker_runtime_builder):
assert isinstance(docker_runtime_builder.docker_client, docker.DockerClient)
assert docker_runtime_builder.rolling_logger.max_lines == 10
assert docker_runtime_builder.rolling_logger.log_lines == [''] * 10
def test_build_image_from_scratch(docker_runtime_builder, tmp_path):
context_path = str(tmp_path)
tags = ['test_build:latest']
# Create a minimal Dockerfile in the context path
with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
f.write("""FROM php:latest
CMD ["sh", "-c", "echo 'Hello, World!'"]
""")
built_image_name = None
container = None
client = docker.from_env()
try:
built_image_name = docker_runtime_builder.build(
context_path,
tags,
use_local_cache=False,
)
assert built_image_name == f'{tags[0]}'
# Verify the image was created
image = client.images.get(tags[0])
assert image is not None
except docker.errors.ImageNotFound:
pytest.fail('test_build_image_from_scratch: test image not found!')
except Exception as e:
pytest.fail(f'test_build_image_from_scratch: Build failed with error: {str(e)}')
finally:
# Clean up the container
if container:
try:
container.remove(force=True)
logger.info(f'Removed test container: `{container.id}`')
except Exception as e:
logger.warning(
f'Failed to remove test container `{container.id}`: {str(e)}'
)
# Clean up the image
if built_image_name:
try:
client.images.remove(built_image_name, force=True)
logger.info(f'Removed test image: `{built_image_name}`')
except Exception as e:
logger.warning(
f'Failed to remove test image `{built_image_name}`: {str(e)}'
)
else:
logger.warning('No image was built, so no image cleanup was necessary.')
def _format_size_to_gb(bytes_size):
"""Convert bytes to gigabytes with two decimal places."""
return round(bytes_size / (1024**3), 2)
def test_list_dangling_images():
client = docker.from_env()
dangling_images = client.images.list(filters={'dangling': True})
if dangling_images and len(dangling_images) > 0:
for image in dangling_images:
if 'Size' in image.attrs and isinstance(image.attrs['Size'], int):
size_gb = _format_size_to_gb(image.attrs['Size'])
logger.info(f'Dangling image: {image.tags}, Size: {size_gb} GB')
else:
logger.info(f'Dangling image: {image.tags}, Size: n/a')
else:
logger.info('No dangling images found')
def test_build_image_from_repo(docker_runtime_builder, tmp_path):
context_path = str(tmp_path)
tags = ['alpine:latest']
# Create a minimal Dockerfile in the context path
with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
f.write(f"""FROM {DEFAULT_BASE_IMAGE}
CMD ["sh", "-c", "echo 'Hello, World!'"]
""")
built_image_name = None
container = None
client = docker.from_env()
try:
built_image_name = docker_runtime_builder.build(
context_path,
tags,
use_local_cache=False,
)
assert built_image_name == f'{tags[0]}'
image = client.images.get(tags[0])
assert image is not None
except docker.errors.ImageNotFound:
pytest.fail('test_build_image_from_repo: test image not found!')
finally:
# Clean up the container
if container:
try:
container.remove(force=True)
logger.info(f'Removed test container: `{container.id}`')
except Exception as e:
logger.warning(
f'Failed to remove test container `{container.id}`: {str(e)}'
)
# Clean up the image
if built_image_name:
try:
client.images.remove(built_image_name, force=True)
logger.info(f'Removed test image: `{built_image_name}`')
except Exception as e:
logger.warning(
f'Failed to remove test image `{built_image_name}`: {str(e)}'
)
else:
logger.warning('No image was built, so no image cleanup was necessary.')
def test_image_exists_local(docker_runtime_builder):
mock_client = MagicMock()
mock_client.version.return_value = {
'Version': '20.10.0',
'Components': [{'Name': 'Engine', 'Version': '20.10.0'}],
} # Ensure version is >= 18.09
builder = DockerRuntimeBuilder(mock_client)
image_name = 'existing-local:image' # The mock pretends this exists by default
assert builder.image_exists(image_name)
def test_image_exists_not_found():
mock_client = MagicMock()
mock_client.version.return_value = {
'Version': '20.10.0',
'Components': [{'Name': 'Engine', 'Version': '20.10.0'}],
} # Ensure version is >= 18.09
mock_client.images.get.side_effect = docker.errors.ImageNotFound(
"He doesn't like you!"
)
mock_client.api.pull.side_effect = docker.errors.ImageNotFound(
"I don't like you either!"
)
builder = DockerRuntimeBuilder(mock_client)
assert not builder.image_exists('nonexistent:image')
mock_client.images.get.assert_called_once_with('nonexistent:image')
mock_client.api.pull.assert_called_once_with(
'nonexistent', tag='image', stream=True, decode=True
)
def test_truncate_hash():
truncated = truncate_hash('b08f254d76b1c6a7ad924708c0032251')
assert truncated == 'pma2wc71uq3c9a85'
truncated = truncate_hash('102aecc0cea025253c0278f54ebef078')
assert truncated == '4titk6gquia3taj5'

View File

@@ -1,167 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import OpenHandsConfig
from openhands.events import EventStream
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
@pytest.fixture
def mock_docker_client():
with patch('docker.from_env') as mock_client:
container_mock = MagicMock()
container_mock.status = 'running'
container_mock.attrs = {
'Config': {
'Env': ['port=12345', 'VSCODE_PORT=54321'],
'ExposedPorts': {'12345/tcp': {}, '54321/tcp': {}},
}
}
mock_client.return_value.containers.get.return_value = container_mock
mock_client.return_value.containers.run.return_value = container_mock
# Mock version info for BuildKit check
mock_client.return_value.version.return_value = {
'Version': '20.10.0',
'Components': [{'Name': 'Engine', 'Version': '20.10.0'}],
} # Ensure version is >= 18.09
yield mock_client.return_value
@pytest.fixture
def config():
config = OpenHandsConfig()
config.sandbox.keep_runtime_alive = False
return config
@pytest.fixture
def event_stream():
return MagicMock(spec=EventStream)
@patch('openhands.runtime.impl.docker.docker_runtime.stop_all_containers')
def test_container_stopped_when_keep_runtime_alive_false(
mock_stop_containers, mock_docker_client, config, event_stream
):
# Arrange
runtime = DockerRuntime(config, event_stream, sid='test-sid')
runtime.container = mock_docker_client.containers.get.return_value
# Act
runtime.close()
# Assert
mock_stop_containers.assert_called_once_with('openhands-runtime-test-sid')
@patch('openhands.runtime.impl.docker.docker_runtime.stop_all_containers')
def test_container_not_stopped_when_keep_runtime_alive_true(
mock_stop_containers, mock_docker_client, config, event_stream
):
# Arrange
config.sandbox.keep_runtime_alive = True
runtime = DockerRuntime(config, event_stream, sid='test-sid')
runtime.container = mock_docker_client.containers.get.return_value
# Act
runtime.close()
# Assert
mock_stop_containers.assert_not_called()
def test_volumes_mode_extraction():
"""Test that the mount mode is correctly extracted from sandbox.volumes."""
import os
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
# Create a DockerRuntime instance with a mock config
runtime = DockerRuntime.__new__(DockerRuntime)
runtime.config = MagicMock()
runtime.config.sandbox.volumes = '/host/path:/container/path:ro'
runtime.config.workspace_mount_path = '/host/path'
runtime.config.workspace_mount_path_in_sandbox = '/container/path'
# Call the actual method that processes volumes
volumes = runtime._process_volumes()
# Assert that the mode was correctly set to 'ro'
assert volumes[os.path.abspath('/host/path')]['mode'] == 'ro'
# This test has been replaced by test_volumes_multiple_mounts
def test_volumes_multiple_mounts():
"""Test that multiple mounts in sandbox.volumes are correctly processed."""
import os
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
# Create a DockerRuntime instance with a mock config
runtime = DockerRuntime.__new__(DockerRuntime)
runtime.config = MagicMock()
runtime.config.runtime_mount = None
runtime.config.sandbox.volumes = (
'/host/path1:/container/path1,/host/path2:/container/path2:ro'
)
runtime.config.workspace_mount_path = '/host/path1'
runtime.config.workspace_mount_path_in_sandbox = '/container/path1'
# Call the actual method that processes volumes
volumes = runtime._process_volumes()
# Assert that both mounts were processed correctly
assert len(volumes) == 2
assert volumes[os.path.abspath('/host/path1')]['bind'] == '/container/path1'
assert volumes[os.path.abspath('/host/path1')]['mode'] == 'rw' # Default mode
assert volumes[os.path.abspath('/host/path2')]['bind'] == '/container/path2'
assert volumes[os.path.abspath('/host/path2')]['mode'] == 'ro' # Specified mode
def test_multiple_volumes():
"""Test that multiple volumes are correctly processed."""
import os
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
# Create a DockerRuntime instance with a mock config
runtime = DockerRuntime.__new__(DockerRuntime)
runtime.config = MagicMock()
runtime.config.sandbox.volumes = '/host/path1:/container/path1,/host/path2:/container/path2,/host/path3:/container/path3:ro'
runtime.config.workspace_mount_path = '/host/path1'
runtime.config.workspace_mount_path_in_sandbox = '/container/path1'
# Call the actual method that processes volumes
volumes = runtime._process_volumes()
# Assert that all mounts were processed correctly
assert len(volumes) == 3
assert volumes[os.path.abspath('/host/path1')]['bind'] == '/container/path1'
assert volumes[os.path.abspath('/host/path1')]['mode'] == 'rw'
assert volumes[os.path.abspath('/host/path2')]['bind'] == '/container/path2'
assert volumes[os.path.abspath('/host/path2')]['mode'] == 'rw'
assert volumes[os.path.abspath('/host/path3')]['bind'] == '/container/path3'
assert volumes[os.path.abspath('/host/path3')]['mode'] == 'ro'
def test_volumes_default_mode():
"""Test that the default mount mode (rw) is used when not specified in sandbox.volumes."""
import os
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
# Create a DockerRuntime instance with a mock config
runtime = DockerRuntime.__new__(DockerRuntime)
runtime.config = MagicMock()
runtime.config.sandbox.volumes = '/host/path:/container/path'
runtime.config.workspace_mount_path = '/host/path'
runtime.config.workspace_mount_path_in_sandbox = '/container/path'
# Call the actual method that processes volumes
volumes = runtime._process_volumes()
# Assert that the mode remains 'rw' (default)
assert volumes[os.path.abspath('/host/path')]['mode'] == 'rw'

View File

@@ -1,245 +0,0 @@
"""Unit tests for LocalRuntime's URL-related methods."""
import os
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import OpenHandsConfig
from openhands.events import EventStream
from openhands.runtime.impl.local.local_runtime import LocalRuntime
@pytest.fixture
def config():
"""Create a mock OpenHandsConfig for testing."""
config = OpenHandsConfig()
config.sandbox.local_runtime_url = 'http://localhost'
config.workspace_mount_path_in_sandbox = '/workspace'
return config
@pytest.fixture
def event_stream():
"""Create a mock EventStream for testing."""
return MagicMock(spec=EventStream)
@pytest.fixture
def local_runtime(config, event_stream):
"""Create a LocalRuntime instance for testing."""
# Use __new__ to avoid calling __init__ which would start the server
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
runtime.event_stream = event_stream
runtime._vscode_port = 8080
runtime._app_ports = [12000, 12001]
runtime._runtime_initialized = True
# Add required attributes for testing
runtime._vscode_enabled = True
runtime._vscode_token = 'test-token'
# Mock the runtime_url property for testing
def mock_runtime_url(self):
return 'http://localhost'
# Create a property mock for runtime_url
type(runtime).runtime_url = property(mock_runtime_url)
return runtime
class TestLocalRuntime:
"""Tests for LocalRuntime's URL-related methods."""
def test_runtime_url_with_env_var(self):
"""Test runtime_url when RUNTIME_URL environment variable is set."""
# Create a fresh instance for this test
config = OpenHandsConfig()
config.sandbox.local_runtime_url = 'http://localhost'
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
with patch.dict(os.environ, {'RUNTIME_URL': 'http://custom-url'}, clear=True):
# Call the actual runtime_url property
original_property = LocalRuntime.runtime_url
try:
assert original_property.__get__(runtime) == 'http://custom-url'
finally:
# Restore the original property
LocalRuntime.runtime_url = original_property
def test_runtime_url_with_pattern(self):
"""Test runtime_url when RUNTIME_URL_PATTERN environment variable is set."""
# Create a fresh instance for this test
config = OpenHandsConfig()
config.sandbox.local_runtime_url = 'http://localhost'
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
env_vars = {
'RUNTIME_URL_PATTERN': 'http://runtime-{runtime_id}.example.com',
'HOSTNAME': 'runtime-abc123-xyz',
}
with patch.dict(os.environ, env_vars, clear=True):
# Call the actual runtime_url property
original_property = LocalRuntime.runtime_url
try:
assert (
original_property.__get__(runtime)
== 'http://runtime-abc123.example.com'
)
finally:
# Restore the original property
LocalRuntime.runtime_url = original_property
def test_runtime_url_fallback(self):
"""Test runtime_url fallback to local_runtime_url."""
# Create a fresh instance for this test
config = OpenHandsConfig()
config.sandbox.local_runtime_url = 'http://localhost'
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
with patch.dict(os.environ, {}, clear=True):
# Call the actual runtime_url property
original_property = LocalRuntime.runtime_url
try:
assert original_property.__get__(runtime) == 'http://localhost'
finally:
# Restore the original property
LocalRuntime.runtime_url = original_property
def test_create_url_with_localhost(self):
"""Test _create_url when runtime_url contains 'localhost'."""
# Create a fresh instance for this test
config = OpenHandsConfig()
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
runtime._vscode_port = 8080
# Create a mock method for runtime_url that accepts self parameter
def mock_runtime_url(self):
return 'http://localhost'
# Temporarily replace the runtime_url property
original_property = LocalRuntime.runtime_url
try:
LocalRuntime.runtime_url = property(mock_runtime_url)
url = runtime._create_url('test-prefix', 9000)
assert url == 'http://localhost:8080'
finally:
# Restore the original property
LocalRuntime.runtime_url = original_property
def test_create_url_with_remote_url(self):
"""Test _create_url when runtime_url is a remote URL."""
# Create a fresh instance for this test
config = OpenHandsConfig()
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
# Create a mock method for runtime_url that accepts self parameter
def mock_runtime_url(self):
return 'https://example.com'
# Temporarily replace the runtime_url property
original_property = LocalRuntime.runtime_url
try:
LocalRuntime.runtime_url = property(mock_runtime_url)
url = runtime._create_url('test-prefix', 9000)
assert url == 'https://test-prefix-example.com'
finally:
# Restore the original property
LocalRuntime.runtime_url = original_property
def test_vscode_url_with_token(self):
"""Test vscode_url when token is available."""
# Create a fresh instance for this test
config = OpenHandsConfig()
config.workspace_mount_path_in_sandbox = '/workspace'
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
# Add required attributes
runtime._vscode_enabled = True
runtime._runtime_initialized = True
runtime._vscode_token = 'test-token'
# Create a direct implementation of the method to test
def mock_vscode_url(self):
# Simplified version of the actual method
token = 'test-token' # Mocked token
if not token:
return None
vscode_url = 'https://vscode-example.com' # Mocked URL
return f'{vscode_url}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
# Temporarily replace the vscode_url method
original_method = LocalRuntime.vscode_url
try:
LocalRuntime.vscode_url = property(mock_vscode_url)
url = runtime.vscode_url
assert url == 'https://vscode-example.com/?tkn=test-token&folder=/workspace'
finally:
# Restore the original method
LocalRuntime.vscode_url = original_method
def test_vscode_url_without_token(self):
"""Test vscode_url when token is not available."""
# Create a fresh instance for this test
config = OpenHandsConfig()
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
# Create a direct implementation of the method to test
def mock_vscode_url(self):
# Simplified version that returns None (no token)
return None
# Temporarily replace the vscode_url method
original_method = LocalRuntime.vscode_url
try:
LocalRuntime.vscode_url = property(mock_vscode_url)
assert runtime.vscode_url is None
finally:
# Restore the original method
LocalRuntime.vscode_url = original_method
def test_web_hosts_with_multiple_ports(self):
"""Test web_hosts with multiple app ports."""
# Create a fresh instance for this test
config = OpenHandsConfig()
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
runtime._app_ports = [12000, 12001]
# Mock _create_url to return predictable values
def mock_create_url(prefix, port):
return f'https://{prefix}-example.com'
with patch.object(runtime, '_create_url', side_effect=mock_create_url):
# Call the web_hosts property
hosts = runtime.web_hosts
# Verify the result
assert len(hosts) == 2
assert 'https://work-1-example.com' in hosts
assert hosts['https://work-1-example.com'] == 12000
assert 'https://work-2-example.com' in hosts
assert hosts['https://work-2-example.com'] == 12001
def test_web_hosts_with_no_ports(self):
"""Test web_hosts with no app ports."""
# Create a fresh instance for this test
config = OpenHandsConfig()
runtime = LocalRuntime.__new__(LocalRuntime)
runtime.config = config
runtime._app_ports = []
# Call the web_hosts property
hosts = runtime.web_hosts
# Verify the result is an empty dictionary
assert hosts == {}

View File

@@ -1,717 +0,0 @@
import contextlib
import io
import sys
import docx
import pytest
from openhands.runtime.plugins.agent_skills.file_ops.file_ops import (
WINDOW,
_print_window,
find_file,
goto_line,
open_file,
scroll_down,
scroll_up,
search_dir,
search_file,
)
from openhands.runtime.plugins.agent_skills.file_reader.file_readers import (
parse_docx,
parse_latex,
parse_pdf,
parse_pptx,
)
# CURRENT_FILE must be reset for each test
@pytest.fixture(autouse=True)
def reset_current_file():
from openhands.runtime.plugins.agent_skills import agentskills
agentskills.CURRENT_FILE = None
def _numbered_test_lines(start, end) -> str:
return ('\n'.join(f'{i}|' for i in range(start, end + 1))) + '\n'
def _generate_test_file_with_lines(temp_path, num_lines) -> str:
file_path = temp_path / 'test_file.py'
file_path.write_text('\n' * num_lines)
return file_path
def _generate_ruby_test_file_with_lines(temp_path, num_lines) -> str:
file_path = temp_path / 'test_file.rb'
file_path.write_text('\n' * num_lines)
return file_path
def _calculate_window_bounds(current_line, total_lines, window_size):
"""Calculate the bounds of the window around the current line."""
half_window = window_size // 2
if current_line - half_window < 0:
start = 1
end = window_size
else:
start = current_line - half_window
end = current_line + half_window
return start, end
def _capture_file_operation_error(operation, expected_error_msg):
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
operation()
result = buf.getvalue().strip()
assert result == expected_error_msg
SEP = '-' * 49 + '\n'
# =============================================================================
def test_open_file_unexist_path():
_capture_file_operation_error(
lambda: open_file('/unexist/path/a.txt'),
'ERROR: File /unexist/path/a.txt not found.',
)
def test_open_file(tmp_path):
assert tmp_path is not None
temp_file_path = tmp_path / 'a.txt'
temp_file_path.write_text('Line 1\nLine 2\nLine 3\nLine 4\nLine 5')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = (
f'[File: {temp_file_path} (5 lines total)]\n'
'(this is the beginning of the file)\n'
'1|Line 1\n'
'2|Line 2\n'
'3|Line 3\n'
'4|Line 4\n'
'5|Line 5\n'
'(this is the end of the file)\n'
)
assert result.split('\n') == expected.split('\n')
def test_open_file_with_indentation(tmp_path):
temp_file_path = tmp_path / 'a.txt'
temp_file_path.write_text('Line 1\n Line 2\nLine 3\nLine 4\nLine 5')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = (
f'[File: {temp_file_path} (5 lines total)]\n'
'(this is the beginning of the file)\n'
'1|Line 1\n'
'2| Line 2\n'
'3|Line 3\n'
'4|Line 4\n'
'5|Line 5\n'
'(this is the end of the file)\n'
)
assert result.split('\n') == expected.split('\n')
def test_open_file_long(tmp_path):
temp_file_path = tmp_path / 'a.txt'
content = '\n'.join([f'Line {i}' for i in range(1, 1001)])
temp_file_path.write_text(content)
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path), 1, 50)
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} (1000 lines total)]\n'
expected += '(this is the beginning of the file)\n'
for i in range(1, 51):
expected += f'{i}|Line {i}\n'
expected += '(950 more lines below)\n'
expected += '[Use `scroll_down` to view the next 100 lines of the file!]\n'
assert result.split('\n') == expected.split('\n')
def test_open_file_long_with_lineno(tmp_path):
temp_file_path = tmp_path / 'a.txt'
content = '\n'.join([f'Line {i}' for i in range(1, 1001)])
temp_file_path.write_text(content)
cur_line = 100
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path), cur_line)
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} (1000 lines total)]\n'
# since 100 is < WINDOW and 100 - WINDOW//2 < 0, so it should show all lines from 1 to WINDOW
start, end = _calculate_window_bounds(cur_line, 1000, WINDOW)
if start == 1:
expected += '(this is the beginning of the file)\n'
else:
expected += f'({start - 1} more lines above)\n'
for i in range(start, end + 1):
expected += f'{i}|Line {i}\n'
if end == 1000:
expected += '(this is the end of the file)\n'
else:
expected += f'({1000 - end} more lines below)\n'
expected += '[Use `scroll_down` to view the next 100 lines of the file!]\n'
assert result.split('\n') == expected.split('\n')
def test_goto_line(tmp_path):
temp_file_path = tmp_path / 'a.txt'
total_lines = 1000
content = '\n'.join([f'Line {i}' for i in range(1, total_lines + 1)])
temp_file_path.write_text(content)
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} ({total_lines} lines total)]\n'
expected += '(this is the beginning of the file)\n'
for i in range(1, WINDOW + 1):
expected += f'{i}|Line {i}\n'
expected += f'({total_lines - WINDOW} more lines below)\n'
expected += '[Use `scroll_down` to view the next 100 lines of the file!]\n'
assert result.split('\n') == expected.split('\n')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
goto_line(500)
result = buf.getvalue()
assert result is not None
cur_line = 500
expected = f'[File: {temp_file_path} ({total_lines} lines total)]\n'
start, end = _calculate_window_bounds(cur_line, total_lines, WINDOW)
if start == 1:
expected += '(this is the beginning of the file)\n'
else:
expected += f'({start - 1} more lines above)\n'
for i in range(start, end + 1):
expected += f'{i}|Line {i}\n'
if end == total_lines:
expected += '(this is the end of the file)\n'
else:
expected += f'({total_lines - end} more lines below)\n'
assert result.split('\n') == expected.split('\n')
def test_goto_line_negative(tmp_path):
temp_file_path = tmp_path / 'a.txt'
content = '\n'.join([f'Line {i}' for i in range(1, 5)])
temp_file_path.write_text(content)
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
_capture_file_operation_error(
lambda: goto_line(-1), 'ERROR: Line number must be between 1 and 4.'
)
def test_goto_line_out_of_bound(tmp_path):
temp_file_path = tmp_path / 'a.txt'
content = '\n'.join([f'Line {i}' for i in range(1, 10)])
temp_file_path.write_text(content)
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
_capture_file_operation_error(
lambda: goto_line(100), 'ERROR: Line number must be between 1 and 9.'
)
def test_scroll_down(tmp_path):
temp_file_path = tmp_path / 'a.txt'
total_lines = 1000
content = '\n'.join([f'Line {i}' for i in range(1, total_lines + 1)])
temp_file_path.write_text(content)
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} ({total_lines} lines total)]\n'
start, end = _calculate_window_bounds(1, total_lines, WINDOW)
if start == 1:
expected += '(this is the beginning of the file)\n'
else:
expected += f'({start - 1} more lines above)\n'
for i in range(start, end + 1):
expected += f'{i}|Line {i}\n'
if end == total_lines:
expected += '(this is the end of the file)\n'
else:
expected += f'({total_lines - end} more lines below)\n'
expected += '[Use `scroll_down` to view the next 100 lines of the file!]\n'
assert result.split('\n') == expected.split('\n')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
scroll_down()
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} ({total_lines} lines total)]\n'
start = WINDOW + 1
end = 2 * WINDOW + 1
if start == 1:
expected += '(this is the beginning of the file)\n'
else:
expected += f'({start - 1} more lines above)\n'
for i in range(start, end + 1):
expected += f'{i}|Line {i}\n'
if end == total_lines:
expected += '(this is the end of the file)\n'
else:
expected += f'({total_lines - end} more lines below)\n'
assert result.split('\n') == expected.split('\n')
def test_scroll_up(tmp_path):
temp_file_path = tmp_path / 'a.txt'
total_lines = 1000
content = '\n'.join([f'Line {i}' for i in range(1, total_lines + 1)])
temp_file_path.write_text(content)
cur_line = 300
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path), cur_line)
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} ({total_lines} lines total)]\n'
start, end = _calculate_window_bounds(cur_line, total_lines, WINDOW)
if start == 1:
expected += '(this is the beginning of the file)\n'
else:
expected += f'({start - 1} more lines above)\n'
for i in range(start, end + 1):
expected += f'{i}|Line {i}\n'
if end == total_lines:
expected += '(this is the end of the file)\n'
else:
expected += f'({total_lines - end} more lines below)\n'
expected += '[Use `scroll_down` to view the next 100 lines of the file!]\n'
assert result.split('\n') == expected.split('\n')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
scroll_up()
result = buf.getvalue()
assert result is not None
cur_line = cur_line - WINDOW
expected = f'[File: {temp_file_path} ({total_lines} lines total)]\n'
start = cur_line
end = cur_line + WINDOW
if start == 1:
expected += '(this is the beginning of the file)\n'
else:
expected += f'({start - 1} more lines above)\n'
for i in range(start, end + 1):
expected += f'{i}|Line {i}\n'
if end == total_lines:
expected += '(this is the end of the file)\n'
else:
expected += f'({total_lines - end} more lines below)\n'
assert result.split('\n') == expected.split('\n')
def test_scroll_down_edge(tmp_path):
temp_file_path = tmp_path / 'a.txt'
content = '\n'.join([f'Line {i}' for i in range(1, 10)])
temp_file_path.write_text(content)
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
open_file(str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = f'[File: {temp_file_path} (9 lines total)]\n'
expected += '(this is the beginning of the file)\n'
for i in range(1, 10):
expected += f'{i}|Line {i}\n'
expected += '(this is the end of the file)\n'
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
scroll_down()
result = buf.getvalue()
assert result is not None
# expected should be unchanged
assert result.split('\n') == expected.split('\n')
def test_print_window_internal(tmp_path):
test_file_path = tmp_path / 'a.txt'
test_file_path.write_text('')
open_file(str(test_file_path))
with open(test_file_path, 'w') as file:
for i in range(1, 101):
file.write(f'Line `{i}`\n')
# Define the parameters for the test
current_line = 50
window = 2
# Test _print_window especially with backticks
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
_print_window(str(test_file_path), current_line, window, return_str=False)
result = buf.getvalue()
expected = (
'(48 more lines above)\n'
'49|Line `49`\n'
'50|Line `50`\n'
'51|Line `51`\n'
'(49 more lines below)\n'
)
assert result == expected
def test_open_file_large_line_number(tmp_path):
test_file_path = tmp_path / 'a.txt'
test_file_path.write_text('')
open_file(str(test_file_path))
with open(test_file_path, 'w') as file:
for i in range(1, 1000):
file.write(f'Line `{i}`\n')
# Define the parameters for the test
current_line = 800
window = 100
# Test _print_window especially with backticks
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
# _print_window(str(test_file_path), current_line, window, return_str=False)
open_file(str(test_file_path), current_line, window)
result = buf.getvalue()
expected = f'[File: {test_file_path} (999 lines total)]\n'
expected += '(749 more lines above)\n'
for i in range(750, 850 + 1):
expected += f'{i}|Line `{i}`\n'
expected += '(149 more lines below)\n'
expected += '[Use `scroll_down` to view the next 100 lines of the file!]\n'
assert result == expected
def test_search_dir(tmp_path):
# create files with the search term "bingo"
for i in range(1, 101):
temp_file_path = tmp_path / f'a{i}.txt'
with open(temp_file_path, 'w') as file:
file.write('Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n')
if i == 50:
file.write('bingo')
# test
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
search_dir('bingo', str(tmp_path))
result = buf.getvalue()
assert result is not None
expected = (
f'[Found 1 matches for "bingo" in {tmp_path}]\n'
f'{tmp_path}/a50.txt (Line 6): bingo\n'
f'[End of matches for "bingo" in {tmp_path}]\n'
)
assert result.split('\n') == expected.split('\n')
def test_search_dir_not_exist_term(tmp_path):
# create files with the search term "bingo"
for i in range(1, 101):
temp_file_path = tmp_path / f'a{i}.txt'
with open(temp_file_path, 'w') as file:
file.write('Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n')
# test
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
search_dir('non-exist', str(tmp_path))
result = buf.getvalue()
assert result is not None
expected = f'No matches found for "non-exist" in {tmp_path}\n'
assert result.split('\n') == expected.split('\n')
def test_search_dir_too_much_match(tmp_path):
# create files with the search term "Line 5"
for i in range(1, 1000):
temp_file_path = tmp_path / f'a{i}.txt'
with open(temp_file_path, 'w') as file:
file.write('Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
search_dir('Line 5', str(tmp_path))
result = buf.getvalue()
assert result is not None
expected = f'More than 999 files matched for "Line 5" in {tmp_path}. Please narrow your search.\n'
assert result.split('\n') == expected.split('\n')
def test_search_dir_cwd(tmp_path, monkeypatch):
# Using pytest's monkeypatch to change directory without affecting other tests
monkeypatch.chdir(tmp_path)
# create files with the search term "bingo"
for i in range(1, 101):
temp_file_path = tmp_path / f'a{i}.txt'
with open(temp_file_path, 'w') as file:
file.write('Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n')
if i == 50:
file.write('bingo')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
search_dir('bingo')
result = buf.getvalue()
assert result is not None
expected = (
'[Found 1 matches for "bingo" in ./]\n'
'./a50.txt (Line 6): bingo\n'
'[End of matches for "bingo" in ./]\n'
)
assert result.split('\n') == expected.split('\n')
def test_search_file(tmp_path):
temp_file_path = tmp_path / 'a.txt'
temp_file_path.write_text('Line 1\nLine 2\nLine 3\nLine 4\nLine 5')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
search_file('Line 5', str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = f'[Found 1 matches for "Line 5" in {temp_file_path}]\n'
expected += 'Line 5: Line 5\n'
expected += f'[End of matches for "Line 5" in {temp_file_path}]\n'
assert result.split('\n') == expected.split('\n')
def test_search_file_not_exist_term(tmp_path):
temp_file_path = tmp_path / 'a.txt'
temp_file_path.write_text('Line 1\nLine 2\nLine 3\nLine 4\nLine 5')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
search_file('Line 6', str(temp_file_path))
result = buf.getvalue()
assert result is not None
expected = f'[No matches found for "Line 6" in {temp_file_path}]\n'
assert result.split('\n') == expected.split('\n')
def test_search_file_not_exist_file():
_capture_file_operation_error(
lambda: search_file('Line 6', '/unexist/path/a.txt'),
'ERROR: File /unexist/path/a.txt not found.',
)
def test_find_file(tmp_path):
temp_file_path = tmp_path / 'a.txt'
temp_file_path.write_text('Line 1\nLine 2\nLine 3\nLine 4\nLine 5')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
find_file('a.txt', str(tmp_path))
result = buf.getvalue()
assert result is not None
expected = f'[Found 1 matches for "a.txt" in {tmp_path}]\n'
expected += f'{tmp_path}/a.txt\n'
expected += f'[End of matches for "a.txt" in {tmp_path}]\n'
assert result.split('\n') == expected.split('\n')
def test_find_file_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
temp_file_path = tmp_path / 'a.txt'
temp_file_path.write_text('Line 1\nLine 2\nLine 3\nLine 4\nLine 5')
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
find_file('a.txt')
result = buf.getvalue()
assert result is not None
def test_find_file_not_exist_file():
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
find_file('nonexist.txt')
result = buf.getvalue()
assert result is not None
expected = '[No matches found for "nonexist.txt" in ./]\n'
assert result.split('\n') == expected.split('\n')
def test_find_file_not_exist_file_specific_path(tmp_path):
with io.StringIO() as buf:
with contextlib.redirect_stdout(buf):
find_file('nonexist.txt', str(tmp_path))
result = buf.getvalue()
assert result is not None
expected = f'[No matches found for "nonexist.txt" in {tmp_path}]\n'
assert result.split('\n') == expected.split('\n')
def test_parse_docx(tmp_path):
# Create a DOCX file with some content
test_docx_path = tmp_path / 'test.docx'
doc = docx.Document()
doc.add_paragraph('Hello, this is a test document.')
doc.add_paragraph('This is the second paragraph.')
doc.save(str(test_docx_path))
old_stdout = sys.stdout
sys.stdout = io.StringIO()
# Call the parse_docx function
parse_docx(str(test_docx_path))
# Capture the output
output = sys.stdout.getvalue()
sys.stdout = old_stdout
# Check if the output is correct
expected_output = (
f'[Reading DOCX file from {test_docx_path}]\n'
'@@ Page 1 @@\nHello, this is a test document.\n\n'
'@@ Page 2 @@\nThis is the second paragraph.\n\n\n'
)
assert output == expected_output, f'Expected output does not match. Got: {output}'
def test_parse_latex(tmp_path):
# Create a LaTeX file with some content
test_latex_path = tmp_path / 'test.tex'
with open(test_latex_path, 'w') as f:
f.write(r"""
\documentclass{article}
\begin{document}
Hello, this is a test LaTeX document.
\end{document}
""")
old_stdout = sys.stdout
sys.stdout = io.StringIO()
# Call the parse_latex function
parse_latex(str(test_latex_path))
# Capture the output
output = sys.stdout.getvalue()
sys.stdout = old_stdout
# Check if the output is correct
expected_output = (
f'[Reading LaTex file from {test_latex_path}]\n'
'Hello, this is a test LaTeX document.\n'
)
assert output == expected_output, f'Expected output does not match. Got: {output}'
def test_parse_pdf(tmp_path):
# Create a PDF file with some content
test_pdf_path = tmp_path / 'test.pdf'
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas(str(test_pdf_path), pagesize=letter)
c.drawString(100, 750, 'Hello, this is a test PDF document.')
c.save()
old_stdout = sys.stdout
sys.stdout = io.StringIO()
# Call the parse_pdf function
parse_pdf(str(test_pdf_path))
# Capture the output
output = sys.stdout.getvalue()
sys.stdout = old_stdout
# Check if the output is correct
expected_output = (
f'[Reading PDF file from {test_pdf_path}]\n'
'@@ Page 1 @@\n'
'Hello, this is a test PDF document.\n'
)
assert output == expected_output, f'Expected output does not match. Got: {output}'
def test_parse_pptx(tmp_path):
test_pptx_path = tmp_path / 'test.pptx'
from pptx import Presentation
pres = Presentation()
slide1 = pres.slides.add_slide(pres.slide_layouts[0])
title1 = slide1.shapes.title
title1.text = 'Hello, this is the first test PPTX slide.'
slide2 = pres.slides.add_slide(pres.slide_layouts[0])
title2 = slide2.shapes.title
title2.text = 'Hello, this is the second test PPTX slide.'
pres.save(str(test_pptx_path))
old_stdout = sys.stdout
sys.stdout = io.StringIO()
parse_pptx(str(test_pptx_path))
output = sys.stdout.getvalue()
sys.stdout = old_stdout
expected_output = (
f'[Reading PowerPoint file from {test_pptx_path}]\n'
'@@ Slide 1 @@\n'
'Hello, this is the first test PPTX slide.\n\n'
'@@ Slide 2 @@\n'
'Hello, this is the second test PPTX slide.\n\n'
)
assert output == expected_output, f'Expected output does not match. Got: {output}'

View File

@@ -1,158 +0,0 @@
from unittest.mock import MagicMock, call
import pytest
from openhands.events.action import CmdRunAction, FileReadAction
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileReadObservation,
)
from openhands.runtime.base import Runtime
class TestGitHooks:
@pytest.fixture
def mock_runtime(self):
# Create a mock runtime
mock_runtime = MagicMock(spec=Runtime)
mock_runtime.status_callback = None
# Set up read to return different values based on the path
def mock_read(action):
if action.path == '.openhands/pre-commit.sh':
return FileReadObservation(
content="#!/bin/bash\necho 'Test pre-commit hook'\nexit 0",
path='.openhands/pre-commit.sh',
)
elif action.path == '.git/hooks/pre-commit':
# Simulate no existing pre-commit hook
return ErrorObservation(content='File not found')
return ErrorObservation(content='Unexpected path')
mock_runtime.read.side_effect = mock_read
mock_runtime.run_action.return_value = CmdOutputObservation(
content='', exit_code=0, command='test command'
)
mock_runtime.write.return_value = None
return mock_runtime
def test_maybe_setup_git_hooks_success(self, mock_runtime):
# Test successful setup of git hooks
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to read the pre-commit script
assert mock_runtime.read.call_args_list[0] == call(
FileReadAction(path='.openhands/pre-commit.sh')
)
# Verify that the runtime created the git hooks directory
# We can't directly compare the CmdRunAction objects, so we check if run_action was called
assert mock_runtime.run_action.called
# Verify that the runtime made the pre-commit script executable
# We can't directly compare the CmdRunAction objects, so we check if run_action was called
assert mock_runtime.run_action.called
# Verify that the runtime wrote the pre-commit hook
assert mock_runtime.write.called
# Verify that the runtime made the pre-commit hook executable
# We can't directly compare the CmdRunAction objects, so we check if run_action was called
assert mock_runtime.run_action.call_count >= 3
# Verify that the runtime logged success
mock_runtime.log.assert_called_with(
'info', 'Git pre-commit hook installed successfully'
)
def test_maybe_setup_git_hooks_no_script(self, mock_runtime):
# Test when pre-commit script doesn't exist
mock_runtime.read.side_effect = lambda action: ErrorObservation(
content='File not found'
)
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to read the pre-commit script
mock_runtime.read.assert_called_with(
FileReadAction(path='.openhands/pre-commit.sh')
)
# Verify that no other actions were taken
mock_runtime.run_action.assert_not_called()
mock_runtime.write.assert_not_called()
def test_maybe_setup_git_hooks_mkdir_failure(self, mock_runtime):
# Test failure to create git hooks directory
def mock_run_action(action):
if (
isinstance(action, CmdRunAction)
and action.command == 'mkdir -p .git/hooks'
):
return CmdOutputObservation(
content='Permission denied',
exit_code=1,
command='mkdir -p .git/hooks',
)
return CmdOutputObservation(content='', exit_code=0, command=action.command)
mock_runtime.run_action.side_effect = mock_run_action
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to create the git hooks directory
assert mock_runtime.run_action.called
# Verify that the runtime logged an error
mock_runtime.log.assert_called_with(
'error', 'Failed to create git hooks directory: Permission denied'
)
# Verify that no other actions were taken
mock_runtime.write.assert_not_called()
def test_maybe_setup_git_hooks_with_existing_hook(self, mock_runtime):
# Test when there's an existing pre-commit hook
def mock_read(action):
if action.path == '.openhands/pre-commit.sh':
return FileReadObservation(
content="#!/bin/bash\necho 'Test pre-commit hook'\nexit 0",
path='.openhands/pre-commit.sh',
)
elif action.path == '.git/hooks/pre-commit':
# Simulate existing pre-commit hook
return FileReadObservation(
content="#!/bin/bash\necho 'Existing hook'\nexit 0",
path='.git/hooks/pre-commit',
)
return ErrorObservation(content='Unexpected path')
mock_runtime.read.side_effect = mock_read
Runtime.maybe_setup_git_hooks(mock_runtime)
# Verify that the runtime tried to read both scripts
assert len(mock_runtime.read.call_args_list) >= 2
# Verify that the runtime preserved the existing hook
assert mock_runtime.log.call_args_list[0] == call(
'info', 'Preserving existing pre-commit hook'
)
# Verify that the runtime moved the existing hook
move_calls = [
call
for call in mock_runtime.run_action.call_args_list
if isinstance(call[0][0], CmdRunAction) and 'mv' in call[0][0].command
]
assert len(move_calls) > 0
# Verify that the runtime wrote the new pre-commit hook
assert mock_runtime.write.called
# Verify that the runtime logged success
assert mock_runtime.log.call_args_list[-1] == call(
'info', 'Git pre-commit hook installed successfully'
)

View File

@@ -1,434 +0,0 @@
from types import MappingProxyType
from unittest.mock import MagicMock, patch
import pytest
from pydantic import SecretStr
from openhands.core.config import OpenHandsConfig
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
from openhands.events.action import Action
from openhands.events.action.commands import CmdRunAction
from openhands.events.observation import NullObservation, Observation
from openhands.events.stream import EventStream
from openhands.integrations.provider import ProviderHandler, ProviderToken, ProviderType
from openhands.integrations.service_types import AuthenticationError, Repository
from openhands.runtime.base import Runtime
from openhands.storage import get_file_store
class TestRuntime(Runtime):
"""A concrete implementation of Runtime for testing"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.run_action_calls = []
self._execute_shell_fn_git_handler = MagicMock(
return_value=MagicMock(exit_code=0, stdout='', stderr='')
)
async def connect(self):
pass
def close(self):
pass
def browse(self, action):
return NullObservation(content='')
def browse_interactive(self, action):
return NullObservation(content='')
def run(self, action):
return NullObservation(content='')
def run_ipython(self, action):
return NullObservation(content='')
def read(self, action):
return NullObservation(content='')
def write(self, action):
return NullObservation(content='')
def copy_from(self, path):
return ''
def copy_to(self, path, content):
pass
def list_files(self, path):
return []
def run_action(self, action: Action) -> Observation:
self.run_action_calls.append(action)
return NullObservation(content='')
def call_tool_mcp(self, action):
return NullObservation(content='')
def edit(self, action):
return NullObservation(content='')
def get_mcp_config(
self, extra_stdio_servers: list[MCPStdioServerConfig] | None = None
):
return MCPConfig()
@pytest.fixture
def temp_dir(tmp_path_factory: pytest.TempPathFactory) -> str:
return str(tmp_path_factory.mktemp('test_event_stream'))
@pytest.fixture
def runtime(temp_dir):
"""Fixture for runtime testing"""
config = OpenHandsConfig()
git_provider_tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr('test_token'))}
)
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
return runtime
def mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB, is_public=True):
repo = Repository(
id='123', full_name='owner/repo', git_provider=provider, is_public=is_public
)
async def mock_verify_repo_provider(*_args, **_kwargs):
return repo
monkeypatch.setattr(
ProviderHandler, 'verify_repo_provider', mock_verify_repo_provider
)
return repo
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_no_user_id(temp_dir):
"""Test that no token export happens when user_id is not set"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(config=config, event_stream=event_stream, sid='test')
# Create a command that would normally trigger token export
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
# This should not raise any errors and should return None
await runtime._export_latest_git_provider_tokens(cmd)
# Verify no secrets were set
assert not event_stream.secrets
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_no_token_ref(temp_dir):
"""Test that no token export happens when command doesn't reference tokens"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
# Create a command that doesn't reference any tokens
cmd = CmdRunAction(command='echo "hello"')
# This should not raise any errors and should return None
await runtime._export_latest_git_provider_tokens(cmd)
# Verify no secrets were set
assert not event_stream.secrets
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_success(runtime):
"""Test successful token export when command references tokens"""
# Create a command that references the GitHub token
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
# Export the tokens
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that the token was exported to the event stream
assert runtime.event_stream.secrets == {'github_token': 'test_token'}
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_multiple_refs(temp_dir):
"""Test token export with multiple token references"""
config = OpenHandsConfig()
# Initialize with both GitHub and GitLab tokens
git_provider_tokens = MappingProxyType(
{
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
}
)
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
# Create a command that references multiple tokens
cmd = CmdRunAction(command='echo $GITHUB_TOKEN && echo $GITLAB_TOKEN')
# Export the tokens
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that both tokens were exported
assert event_stream.secrets == {
'github_token': 'github_token',
'gitlab_token': 'gitlab_token',
}
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_token_update(runtime):
"""Test that token updates are handled correctly"""
# First export with initial token
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
await runtime._export_latest_git_provider_tokens(cmd)
# Update the token
new_token = 'new_test_token'
runtime.provider_handler._provider_tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr(new_token))}
)
# Export again with updated token
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that the new token was exported
assert runtime.event_stream.secrets == {'github_token': new_token}
@pytest.mark.asyncio
async def test_clone_or_init_repo_no_repo_init_git_in_empty_workspace(temp_dir):
"""Test that git init is run when no repository is selected and init_git_in_empty_workspace"""
config = OpenHandsConfig()
config.init_git_in_empty_workspace = True
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config, event_stream=event_stream, sid='test', user_id=None
)
# Call the function with no repository
result = await runtime.clone_or_init_repo(None, None, None)
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert result == ''
@pytest.mark.asyncio
async def test_clone_or_init_repo_no_repo_no_user_id_with_workspace_base(temp_dir):
"""Test that git init is not run when no repository is selected, no user_id, but workspace_base is set"""
config = OpenHandsConfig()
config.workspace_base = '/some/path' # Set workspace_base
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config, event_stream=event_stream, sid='test', user_id=None
)
# Call the function with no repository
result = await runtime.clone_or_init_repo(None, None, None)
# Verify that git init was not called
assert len(runtime.run_action_calls) == 0
assert result == ''
@pytest.mark.asyncio
async def test_clone_or_init_repo_auth_error(temp_dir):
"""Test that RuntimeError is raised when authentication fails"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
# Mock the verify_repo_provider method to raise AuthenticationError
with patch.object(
ProviderHandler,
'verify_repo_provider',
side_effect=AuthenticationError('Auth failed'),
):
# Call the function with a repository
with pytest.raises(Exception) as excinfo:
await runtime.clone_or_init_repo(None, 'owner/repo', None)
# Verify the error message
assert 'Git provider authentication issue when getting remote URL' in str(
excinfo.value
)
@pytest.mark.asyncio
async def test_clone_or_init_repo_github_with_token(temp_dir, monkeypatch):
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
github_token = 'github_test_token'
git_provider_tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr(github_token))}
)
runtime = TestRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB)
result = await runtime.clone_or_init_repo(git_provider_tokens, 'owner/repo', None)
# Verify that git clone and checkout were called as separate commands
assert len(runtime.run_action_calls) == 2
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
# Check that the first command is the git clone with the correct URL format with token
clone_cmd = runtime.run_action_calls[0].command
assert (
f'git clone https://{github_token}@github.com/owner/repo.git repo' in clone_cmd
)
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert 'cd repo' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
assert result == 'repo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_github_no_token(temp_dir, monkeypatch):
"""Test cloning a GitHub repository without a token"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB)
result = await runtime.clone_or_init_repo(None, 'owner/repo', None)
# Verify that git clone and checkout were called as separate commands
assert len(runtime.run_action_calls) == 2
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
# Check that the first command is the git clone with the correct URL format without token
clone_cmd = runtime.run_action_calls[0].command
assert 'git clone https://github.com/owner/repo.git repo' in clone_cmd
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert 'cd repo' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
assert result == 'repo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_gitlab_with_token(temp_dir, monkeypatch):
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
gitlab_token = 'gitlab_test_token'
git_provider_tokens = MappingProxyType(
{ProviderType.GITLAB: ProviderToken(token=SecretStr(gitlab_token))}
)
runtime = TestRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITLAB)
result = await runtime.clone_or_init_repo(git_provider_tokens, 'owner/repo', None)
# Verify that git clone and checkout were called as separate commands
assert len(runtime.run_action_calls) == 2
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
# Check that the first command is the git clone with the correct URL format with token
clone_cmd = runtime.run_action_calls[0].command
assert (
f'git clone https://oauth2:{gitlab_token}@gitlab.com/owner/repo.git repo'
in clone_cmd
)
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert 'cd repo' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
assert result == 'repo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_with_branch(temp_dir, monkeypatch):
"""Test cloning a repository with a specified branch"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = TestRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB)
result = await runtime.clone_or_init_repo(None, 'owner/repo', 'feature-branch')
# Verify that git clone and checkout were called as separate commands
assert len(runtime.run_action_calls) == 2
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
# Check that the first command is the git clone
clone_cmd = runtime.run_action_calls[0].command
# Check that the second command contains the correct branch checkout
checkout_cmd = runtime.run_action_calls[1].command
assert 'git clone https://github.com/owner/repo.git repo' in clone_cmd
assert 'cd repo' in checkout_cmd
assert 'git checkout feature-branch' in checkout_cmd
assert 'git checkout -b' not in checkout_cmd # Should not create a new branch
assert result == 'repo'

View File

@@ -1,324 +0,0 @@
"""Tests for GitLab alternative directory support for microagents."""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import OpenHandsConfig, SandboxConfig
from openhands.events import EventStream
from openhands.integrations.service_types import ProviderType, Repository
from openhands.microagent.microagent import (
RepoMicroagent,
)
from openhands.runtime.base import Runtime
class MockRuntime(Runtime):
"""Mock runtime for testing."""
def __init__(self, workspace_root: Path):
# Create a minimal config for testing
config = OpenHandsConfig()
config.workspace_mount_path_in_sandbox = str(workspace_root)
config.sandbox = SandboxConfig()
# Create a mock event stream
event_stream = MagicMock(spec=EventStream)
# Initialize the parent class properly
super().__init__(
config=config, event_stream=event_stream, sid='test', git_provider_tokens={}
)
self._workspace_root = workspace_root
self._logs = []
@property
def workspace_root(self) -> Path:
"""Return the workspace root path."""
return self._workspace_root
def log(self, level: str, message: str):
"""Mock log method."""
self._logs.append((level, message))
def run_action(self, action):
"""Mock run_action method."""
# For testing, we'll simulate successful cloning
from openhands.events.observation import CmdOutputObservation
return CmdOutputObservation(content='', exit_code=0)
def read(self, action):
"""Mock read method."""
from openhands.events.observation import ErrorObservation
return ErrorObservation('File not found')
def _load_microagents_from_directory(self, directory: Path, source: str):
"""Mock microagent loading."""
if not directory.exists():
return []
# Create mock microagents based on directory structure
microagents = []
for md_file in directory.rglob('*.md'):
if md_file.name == 'README.md':
continue
# Create a simple mock microagent
from openhands.microagent.types import MicroagentMetadata, MicroagentType
agent = RepoMicroagent(
name=f'mock_{md_file.stem}',
content=f'Mock content from {md_file}',
metadata=MicroagentMetadata(name=f'mock_{md_file.stem}'),
source=str(md_file),
type=MicroagentType.REPO_KNOWLEDGE,
)
microagents.append(agent)
return microagents
# Implement abstract methods with minimal functionality
def connect(self):
pass
def run(self, action):
from openhands.events.observation import CmdOutputObservation
return CmdOutputObservation(content='', exit_code=0)
def run_ipython(self, action):
from openhands.events.observation import IPythonRunCellObservation
return IPythonRunCellObservation(content='', code='')
def edit(self, action):
from openhands.events.observation import FileEditObservation
return FileEditObservation(content='', path='')
def browse(self, action):
from openhands.events.observation import BrowserObservation
return BrowserObservation(content='', url='', screenshot='')
def browse_interactive(self, action):
from openhands.events.observation import BrowserObservation
return BrowserObservation(content='', url='', screenshot='')
def write(self, action):
from openhands.events.observation import FileWriteObservation
return FileWriteObservation(content='', path='')
def copy_to(self, host_src, sandbox_dest, recursive=False):
pass
def copy_from(self, sandbox_src, host_dest, recursive=False):
pass
def list_files(self, path=None):
return []
def get_mcp_config(self, extra_stdio_servers=None):
from openhands.core.config.mcp_config import MCPConfig
return MCPConfig()
def call_tool_mcp(self, action):
from openhands.events.observation import MCPObservation
return MCPObservation(content='', tool='', result='')
def create_test_microagents(base_dir: Path, config_dir_name: str = '.openhands'):
"""Create test microagent files in the specified directory."""
microagents_dir = base_dir / config_dir_name / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create a test microagent
test_agent = """---
name: test_agent
type: repo
version: 1.0.0
agent: CodeActAgent
---
# Test Agent
This is a test microagent.
"""
(microagents_dir / 'test.md').write_text(test_agent)
return microagents_dir
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace directory."""
with tempfile.TemporaryDirectory() as temp_dir:
yield Path(temp_dir)
def test_is_gitlab_repository_github(temp_workspace):
"""Test that GitHub repositories are correctly identified as non-GitLab."""
runtime = MockRuntime(temp_workspace)
# Mock the provider handler to return GitHub
mock_repo = Repository(
id='123',
full_name='owner/repo',
git_provider=ProviderType.GITHUB,
is_public=True,
)
with patch('openhands.runtime.base.ProviderHandler') as mock_handler_class:
mock_handler = MagicMock()
mock_handler_class.return_value = mock_handler
with patch('openhands.runtime.base.call_async_from_sync') as mock_async:
mock_async.return_value = mock_repo
result = runtime._is_gitlab_repository('github.com/owner/repo')
assert result is False
def test_is_gitlab_repository_gitlab(temp_workspace):
"""Test that GitLab repositories are correctly identified."""
runtime = MockRuntime(temp_workspace)
# Mock the provider handler to return GitLab
mock_repo = Repository(
id='456',
full_name='owner/repo',
git_provider=ProviderType.GITLAB,
is_public=True,
)
with patch('openhands.runtime.base.ProviderHandler') as mock_handler_class:
mock_handler = MagicMock()
mock_handler_class.return_value = mock_handler
with patch('openhands.runtime.base.call_async_from_sync') as mock_async:
mock_async.return_value = mock_repo
result = runtime._is_gitlab_repository('gitlab.com/owner/repo')
assert result is True
def test_is_gitlab_repository_exception(temp_workspace):
"""Test that exceptions in provider detection return False."""
runtime = MockRuntime(temp_workspace)
with patch('openhands.runtime.base.ProviderHandler') as mock_handler_class:
mock_handler_class.side_effect = Exception('Provider error')
result = runtime._is_gitlab_repository('unknown.com/owner/repo')
assert result is False
def test_get_microagents_from_org_or_user_github(temp_workspace):
"""Test that GitHub repositories only try .openhands directory."""
runtime = MockRuntime(temp_workspace)
# Mock the provider detection to return GitHub
with patch.object(runtime, '_is_gitlab_repository', return_value=False):
# Mock the _get_authenticated_git_url to simulate failure (no org repo)
with patch('openhands.runtime.base.call_async_from_sync') as mock_async:
mock_async.side_effect = Exception('Repository not found')
result = runtime.get_microagents_from_org_or_user('github.com/owner/repo')
# Should only try .openhands, not openhands-config
assert len(result) == 0
# Check that only one attempt was made (for .openhands)
assert mock_async.call_count == 1
def test_get_microagents_from_org_or_user_gitlab_success_with_config(temp_workspace):
"""Test that GitLab repositories use openhands-config and succeed."""
runtime = MockRuntime(temp_workspace)
# Create a mock org directory with microagents
org_dir = temp_workspace / 'org_openhands_owner'
create_test_microagents(org_dir, '.') # Create microagents directly in org_dir
# Mock the provider detection to return GitLab
with patch.object(runtime, '_is_gitlab_repository', return_value=True):
# Mock successful cloning for openhands-config
with patch('openhands.runtime.base.call_async_from_sync') as mock_async:
mock_async.return_value = 'https://gitlab.com/owner/openhands-config.git'
result = runtime.get_microagents_from_org_or_user('gitlab.com/owner/repo')
# Should succeed with openhands-config
assert len(result) >= 0 # May be empty if no microagents found
# Should only try once for openhands-config
assert mock_async.call_count == 1
def test_get_microagents_from_org_or_user_gitlab_failure(temp_workspace):
"""Test that GitLab repositories handle failure gracefully when openhands-config doesn't exist."""
runtime = MockRuntime(temp_workspace)
# Mock the provider detection to return GitLab
with patch.object(runtime, '_is_gitlab_repository', return_value=True):
# Mock the _get_authenticated_git_url to fail for openhands-config
with patch('openhands.runtime.base.call_async_from_sync') as mock_async:
mock_async.side_effect = Exception('openhands-config not found')
result = runtime.get_microagents_from_org_or_user('gitlab.com/owner/repo')
# Should return empty list when repository doesn't exist
assert len(result) == 0
# Should only try once for openhands-config
assert mock_async.call_count == 1
def test_get_microagents_from_selected_repo_gitlab_uses_openhands(temp_workspace):
"""Test that GitLab repositories use .openhands directory for repository-specific microagents."""
runtime = MockRuntime(temp_workspace)
# Create a repository directory structure
repo_dir = temp_workspace / 'repo'
repo_dir.mkdir()
# Create microagents in .openhands directory
create_test_microagents(repo_dir, '.openhands')
# Mock the provider detection to return GitLab
with patch.object(runtime, '_is_gitlab_repository', return_value=True):
# Mock org-level microagents (empty)
with patch.object(runtime, 'get_microagents_from_org_or_user', return_value=[]):
result = runtime.get_microagents_from_selected_repo('gitlab.com/owner/repo')
# Should find microagents from .openhands directory
# The exact assertion depends on the mock implementation
# At minimum, it should not raise an exception
assert isinstance(result, list)
def test_get_microagents_from_selected_repo_github_only_openhands(temp_workspace):
"""Test that GitHub repositories only check .openhands directory."""
runtime = MockRuntime(temp_workspace)
# Create a repository directory structure
repo_dir = temp_workspace / 'repo'
repo_dir.mkdir()
# Create microagents in both directories
create_test_microagents(repo_dir, 'openhands-config')
create_test_microagents(repo_dir, '.openhands')
# Mock the provider detection to return GitHub
with patch.object(runtime, '_is_gitlab_repository', return_value=False):
# Mock org-level microagents (empty)
with patch.object(runtime, 'get_microagents_from_org_or_user', return_value=[]):
result = runtime.get_microagents_from_selected_repo('github.com/owner/repo')
# Should only check .openhands directory, not openhands-config
assert isinstance(result, list)

View File

@@ -1,153 +0,0 @@
"""Test that the runtime import system is robust against broken third-party dependencies.
This test specifically addresses the issue where broken third-party runtime dependencies
(like runloop-api-client with incompatible httpx_aiohttp versions) would break the entire
OpenHands CLI and system.
"""
import logging
import sys
import pytest
def test_cli_import_with_broken_third_party_runtime():
"""Test that CLI can be imported even with broken third-party runtime dependencies."""
# Clear any cached modules to ensure fresh import
modules_to_clear = [
k for k in sys.modules.keys() if 'openhands' in k or 'third_party' in k
]
for module in modules_to_clear:
del sys.modules[module]
# This should not raise an exception even if third-party runtimes have broken dependencies
try:
import openhands.cli.main # noqa: F401
assert True
except Exception as e:
pytest.fail(f'CLI import failed: {e}')
def test_runtime_import_robustness():
"""Test that runtime import system is robust against broken dependencies."""
# Clear any cached runtime modules
modules_to_clear = [k for k in sys.modules.keys() if 'openhands.runtime' in k]
for module in modules_to_clear:
del sys.modules[module]
# Import the runtime module - should succeed even with broken third-party runtimes
try:
import openhands.runtime # noqa: F401
assert True
except Exception as e:
pytest.fail(f'Runtime import failed: {e}')
def test_get_runtime_cls_works():
"""Test that get_runtime_cls works even when third-party runtimes are broken."""
# Import the runtime module
import openhands.runtime
# Test that we can still get core runtime classes
docker_runtime = openhands.runtime.get_runtime_cls('docker')
assert docker_runtime is not None
local_runtime = openhands.runtime.get_runtime_cls('local')
assert local_runtime is not None
# Test that requesting a non-existent runtime raises appropriate error
with pytest.raises(ValueError, match='Runtime nonexistent not supported'):
openhands.runtime.get_runtime_cls('nonexistent')
def test_runtime_exception_handling():
"""Test that the runtime discovery code properly handles exceptions."""
# This test verifies that the fix in openhands/runtime/__init__.py
# properly catches all exceptions (not just ImportError) during
# third-party runtime discovery
import openhands.runtime
# The fact that we can import this module successfully means
# the exception handling is working correctly, even if there
# are broken third-party runtime dependencies
assert hasattr(openhands.runtime, 'get_runtime_cls')
assert hasattr(openhands.runtime, '_THIRD_PARTY_RUNTIME_CLASSES')
def test_runtime_import_exception_handling_behavior():
"""Test that runtime import handles ImportError silently but logs other exceptions."""
# Test the exception handling logic by simulating the exact code from runtime init
from io import StringIO
from openhands.core.logger import openhands_logger as logger
# Create a string buffer to capture log output
log_capture = StringIO()
handler = logging.StreamHandler(log_capture)
handler.setLevel(logging.WARNING)
# Add our test handler to the OpenHands logger
logger.addHandler(handler)
original_level = logger.level
logger.setLevel(logging.WARNING)
try:
# Test 1: ImportError should be handled silently (no logging)
module_path = 'third_party.runtime.impl.missing.missing_runtime'
try:
raise ImportError("No module named 'missing_library'")
except ImportError:
# This is the exact code from runtime init: just pass, no logging
pass
# Test 2: Other exceptions should be logged
module_path = 'third_party.runtime.impl.runloop.runloop_runtime'
try:
raise AttributeError(
"module 'httpx_aiohttp' has no attribute 'HttpxAiohttpClient'"
)
except ImportError:
# ImportError means the library is not installed (expected for optional dependencies)
pass
except Exception as e:
# Other exceptions mean the library is present but broken, which should be logged
# This is the exact code from runtime init
logger.warning(f'Failed to import third-party runtime {module_path}: {e}')
# Check the captured log output
log_output = log_capture.getvalue()
# Should contain the AttributeError warning
assert 'Failed to import third-party runtime' in log_output
assert 'HttpxAiohttpClient' in log_output
# Should NOT contain the ImportError message
assert 'missing_library' not in log_output
finally:
logger.removeHandler(handler)
logger.setLevel(original_level)
def test_import_error_handled_silently(caplog):
"""Test that ImportError is handled silently (no logging) as it means library is not installed."""
# Simulate the exact code path for ImportError
logging.getLogger('openhands.runtime')
with caplog.at_level(logging.WARNING):
# Simulate ImportError handling - this should NOT log anything
try:
raise ImportError("No module named 'optional_runtime_library'")
except ImportError:
# This is the exact code from runtime init: just pass, no logging
pass
# Check that NO warning was logged for ImportError
warning_records = [
record for record in caplog.records if record.levelname == 'WARNING'
]
assert len(warning_records) == 0, (
f'ImportError should not generate warnings, but got: {warning_records}'
)

View File

@@ -1,89 +0,0 @@
from unittest.mock import MagicMock, Mock
import httpx
import pytest
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeTimeoutError,
)
from openhands.events.action import CmdRunAction
from openhands.runtime.base import Runtime
@pytest.fixture
def mock_session():
return Mock()
@pytest.fixture
def runtime(mock_session):
runtime = Mock(spec=Runtime)
runtime.session = mock_session
runtime.send_action_for_execution = Mock()
return runtime
def test_runtime_timeout_error(runtime, mock_session):
# Create a command action
action = CmdRunAction(command='test command')
action.set_hard_timeout(120)
# Mock the runtime to raise a timeout error
runtime.send_action_for_execution.side_effect = AgentRuntimeTimeoutError(
'Runtime failed to return execute_action before the requested timeout of 120s'
)
# Verify that the error message indicates a timeout
with pytest.raises(AgentRuntimeTimeoutError) as exc_info:
runtime.send_action_for_execution(action)
assert (
str(exc_info.value)
== 'Runtime failed to return execute_action before the requested timeout of 120s'
)
@pytest.mark.parametrize(
'status_code,expected_message',
[
(404, 'Runtime is not responding. This may be temporary, please try again.'),
(
502,
'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again.',
),
],
)
def test_runtime_disconnected_error(
runtime, mock_session, status_code, expected_message
):
# Mock the request to return the specified status code
mock_response = Mock()
mock_response.status_code = status_code
mock_response.raise_for_status = Mock(
side_effect=httpx.HTTPStatusError(
'mock_error', request=MagicMock(), response=mock_response
)
)
mock_response.json = Mock(
return_value={
'observation': 'run',
'content': 'test',
'extras': {'command_id': 'test_id', 'command': 'test command'},
}
)
# Mock the runtime to raise the error
runtime.send_action_for_execution.side_effect = AgentRuntimeDisconnectedError(
expected_message
)
# Create a command action
action = CmdRunAction(command='test command')
action.set_hard_timeout(120)
# Verify that the error message is correct
with pytest.raises(AgentRuntimeDisconnectedError) as exc_info:
runtime.send_action_for_execution(action)
assert str(exc_info.value) == expected_message

View File

@@ -1,73 +0,0 @@
"""Unit tests for the setup script functionality."""
from unittest.mock import MagicMock, patch
from openhands.events.action import CmdRunAction, FileReadAction
from openhands.events.event import EventSource
from openhands.events.observation import ErrorObservation, FileReadObservation
from openhands.runtime.base import Runtime
def test_maybe_run_setup_script_executes_action():
"""Test that maybe_run_setup_script executes the action after adding it to the event stream."""
# Create mock runtime
runtime = MagicMock(spec=Runtime)
runtime.read.return_value = FileReadObservation(
content="#!/bin/bash\necho 'test'", path='.openhands/setup.sh'
)
# Mock the event stream
runtime.event_stream = MagicMock()
# Add required attributes
runtime.status_callback = None
# Call the actual implementation
with patch.object(
Runtime, 'maybe_run_setup_script', Runtime.maybe_run_setup_script
):
Runtime.maybe_run_setup_script(runtime)
# Verify that read was called with the correct action
runtime.read.assert_called_once_with(FileReadAction(path='.openhands/setup.sh'))
# Verify that add_event was called with the correct action and source
runtime.event_stream.add_event.assert_called_once()
args, kwargs = runtime.event_stream.add_event.call_args
action, source = args
assert isinstance(action, CmdRunAction)
assert source == EventSource.ENVIRONMENT
# Verify that run_action was called with the correct action
runtime.run_action.assert_called_once()
args, kwargs = runtime.run_action.call_args
action = args[0]
assert isinstance(action, CmdRunAction)
assert (
action.command == 'chmod +x .openhands/setup.sh && source .openhands/setup.sh'
)
def test_maybe_run_setup_script_skips_when_file_not_found():
"""Test that maybe_run_setup_script skips execution when the setup script is not found."""
# Create mock runtime
runtime = MagicMock(spec=Runtime)
runtime.read.return_value = ErrorObservation(content='File not found', error_id='')
# Mock the event stream
runtime.event_stream = MagicMock()
# Call the actual implementation
with patch.object(
Runtime, 'maybe_run_setup_script', Runtime.maybe_run_setup_script
):
Runtime.maybe_run_setup_script(runtime)
# Verify that read was called with the correct action
runtime.read.assert_called_once_with(FileReadAction(path='.openhands/setup.sh'))
# Verify that add_event was not called
runtime.event_stream.add_event.assert_not_called()
# Verify that run_action was not called
runtime.run_action.assert_not_called()

View File

@@ -1,468 +0,0 @@
import pytest
from openhands.runtime.utils.bash import escape_bash_special_chars, split_bash_commands
def test_split_commands_util():
cmds = [
'ls -l',
'echo -e "hello\nworld"',
"""
echo -e "hello it\\'s me"
""".strip(),
"""
echo \\
-e 'hello' \\
-v
""".strip(),
"""
echo -e 'hello\\nworld\\nare\\nyou\\nthere?'
""".strip(),
"""
echo -e 'hello
world
are
you\\n
there?'
""".strip(),
"""
echo -e 'hello
world "
'
""".strip(),
"""
kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: busybox-sleep
spec:
containers:
- name: busybox
image: busybox:1.28
args:
- sleep
- "1000000"
EOF
""".strip(),
"""
mkdir -p _modules && \
for month in {01..04}; do
for day in {01..05}; do
touch "_modules/2024-${month}-${day}-sample.md"
done
done
""".strip(),
]
joined_cmds = '\n'.join(cmds)
split_cmds = split_bash_commands(joined_cmds)
for s in split_cmds:
print('\nCMD')
print(s)
for i in range(len(cmds)):
assert split_cmds[i].strip() == cmds[i].strip(), (
f'At index {i}: {split_cmds[i]} != {cmds[i]}.'
)
@pytest.mark.parametrize(
'input_command, expected_output',
[
('ls -l', ['ls -l']),
("echo 'Hello, world!'", ["echo 'Hello, world!'"]),
('cd /tmp && touch test.txt', ['cd /tmp && touch test.txt']),
("echo -e 'line1\\nline2\\nline3'", ["echo -e 'line1\\nline2\\nline3'"]),
(
"grep 'pattern' file.txt | sort | uniq",
["grep 'pattern' file.txt | sort | uniq"],
),
('for i in {1..5}; do echo $i; done', ['for i in {1..5}; do echo $i; done']),
(
"echo 'Single quotes don\\'t escape'",
["echo 'Single quotes don\\'t escape'"],
),
(
'echo "Double quotes \\"do\\" escape"',
['echo "Double quotes \\"do\\" escape"'],
),
],
)
def test_single_commands(input_command, expected_output):
assert split_bash_commands(input_command) == expected_output
def test_heredoc():
input_commands = """
cat <<EOF
multiline
text
EOF
echo "Done"
"""
expected_output = ['cat <<EOF\nmultiline\ntext\nEOF', 'echo "Done"']
assert split_bash_commands(input_commands) == expected_output
def test_backslash_continuation():
input_commands = """
echo "This is a long \
command that spans \
multiple lines"
echo "Next command"
"""
expected_output = [
'echo "This is a long command that spans multiple lines"',
'echo "Next command"',
]
assert split_bash_commands(input_commands) == expected_output
def test_comments():
input_commands = """
echo "Hello" # This is a comment
# This is another comment
ls -l
"""
expected_output = [
'echo "Hello" # This is a comment\n# This is another comment',
'ls -l',
]
assert split_bash_commands(input_commands) == expected_output
def test_complex_quoting():
input_commands = """
echo "This is a \\"quoted\\" string"
echo 'This is a '\''single-quoted'\'' string'
echo "Mixed 'quotes' in \\"double quotes\\""
"""
expected_output = [
'echo "This is a \\"quoted\\" string"',
"echo 'This is a '''single-quoted''' string'",
'echo "Mixed \'quotes\' in \\"double quotes\\""',
]
assert split_bash_commands(input_commands) == expected_output
def test_invalid_syntax():
invalid_inputs = [
'echo "Unclosed quote',
"echo 'Unclosed quote",
'cat <<EOF\nUnclosed heredoc',
]
for input_command in invalid_inputs:
# it will fall back to return the original input
assert split_bash_commands(input_command) == [input_command]
def test_unclosed_backtick():
# This test reproduces issue #7391
# The issue occurs when parsing a command with an unclosed backtick
# which causes a TypeError: ParsingError.__init__() missing 2 required positional arguments: 's' and 'position'
command = 'echo `unclosed backtick'
# Should not raise TypeError
try:
result = split_bash_commands(command)
# If we get here, the error was handled properly
assert result == [command]
except TypeError as e:
# This is the error we're trying to fix
raise e
# Also test with the original command from the issue (with placeholder org/repo)
curl_command = 'curl -X POST "https://api.github.com/repos/example-org/example-repo/pulls" \\ -H "Authorization: Bearer $GITHUB_TOKEN" \\ -H "Accept: application/vnd.github.v3+json" \\ -d \'{ "title": "XXX", "head": "XXX", "base": "main", "draft": false }\' `echo unclosed'
try:
result = split_bash_commands(curl_command)
assert result == [curl_command]
except TypeError as e:
raise e
def test_over_escaped_command():
# This test reproduces issue #8369 Example 1
# The issue occurs when parsing a command with over-escaped quotes
over_escaped_command = r'# 0. Setup directory\\nrm -rf /workspace/repro_sphinx_bug && mkdir -p /workspace/repro_sphinx_bug && cd /workspace/repro_sphinx_bug\\n\\n# 1. Run sphinx-quickstart\\nsphinx-quickstart --no-sep --project myproject --author me -v 0.1.0 --release 0.1.0 --language en . -q\\n\\n# 2. Create index.rst\\necho -e \'Welcome\\\\\\\\n=======\\\\\\\\n\\\\\\\\n.. toctree::\\\\n :maxdepth: 2\\\\\\\\n\\\\\\\\n mypackage_file\\\\\\\\n\' > index.rst'
# Should not raise any exception
try:
result = split_bash_commands(over_escaped_command)
# If parsing fails, it should return the original command
assert result == [over_escaped_command]
except Exception as e:
# This is the error we're trying to fix
pytest.fail(f'split_bash_commands raised {type(e).__name__} unexpectedly: {e}')
@pytest.fixture
def sample_commands():
return [
'ls -l',
'echo "Hello, world!"',
'cd /tmp && touch test.txt',
'echo -e "line1\\nline2\\nline3"',
'grep "pattern" file.txt | sort | uniq',
'for i in {1..5}; do echo $i; done',
'cat <<EOF\nmultiline\ntext\nEOF',
'echo "Escaped \\"quotes\\""',
"echo 'Single quotes don\\'t escape'",
'echo "Command with a trailing backslash \\\n and continuation"',
]
def test_split_single_commands(sample_commands):
for cmd in sample_commands:
result = split_bash_commands(cmd)
assert len(result) == 1, f'Expected single command, got: {result}'
def test_split_commands_with_heredoc():
input_commands = """
cat <<EOF
multiline
text
EOF
echo "Done"
"""
expected_output = ['cat <<EOF\nmultiline\ntext\nEOF', 'echo "Done"']
result = split_bash_commands(input_commands)
assert result == expected_output, f'Expected {expected_output}, got {result}'
def test_split_commands_with_backslash_continuation():
input_commands = """
echo "This is a long \
command that spans \
multiple lines"
echo "Next command"
"""
expected_output = [
'echo "This is a long command that spans multiple lines"',
'echo "Next command"',
]
result = split_bash_commands(input_commands)
assert result == expected_output, f'Expected {expected_output}, got {result}'
def test_split_commands_with_empty_lines():
input_commands = """
ls -l
echo "Hello"
cd /tmp
"""
expected_output = ['ls -l', 'echo "Hello"', 'cd /tmp']
result = split_bash_commands(input_commands)
assert result == expected_output, f'Expected {expected_output}, got {result}'
def test_split_commands_with_comments():
input_commands = """
echo "Hello" # This is a comment
# This is another comment
ls -l
"""
expected_output = [
'echo "Hello" # This is a comment\n# This is another comment',
'ls -l',
]
result = split_bash_commands(input_commands)
assert result == expected_output, f'Expected {expected_output}, got {result}'
def test_split_commands_with_complex_quoting():
input_commands = """
echo "This is a \\"quoted\\" string"
echo "Mixed 'quotes' in \\"double quotes\\""
"""
# echo 'This is a '\''single-quoted'\'' string'
expected_output = [
'echo "This is a \\"quoted\\" string"',
'echo "Mixed \'quotes\' in \\"double quotes\\""',
]
# "echo 'This is a '\\''single-quoted'\\'' string'",
result = split_bash_commands(input_commands)
assert result == expected_output, f'Expected {expected_output}, got {result}'
def test_split_commands_with_invalid_input():
invalid_inputs = [
'echo "Unclosed quote',
"echo 'Unclosed quote",
'cat <<EOF\nUnclosed heredoc',
]
for input_command in invalid_inputs:
# it will fall back to return the original input
assert split_bash_commands(input_command) == [input_command]
def test_escape_bash_special_chars():
test_cases = [
# Basic cases - use raw strings (r'') to avoid Python escape sequence warnings
('echo test \\; ls', 'echo test \\\\; ls'),
('grep pattern \\| sort', 'grep pattern \\\\| sort'),
('cmd1 \\&\\& cmd2', 'cmd1 \\\\&\\\\& cmd2'),
('cat file \\> output.txt', 'cat file \\\\> output.txt'),
('cat \\< input.txt', 'cat \\\\< input.txt'),
# Quoted strings should remain unchanged
('echo "test \\; unchanged"', 'echo "test \\; unchanged"'),
("echo 'test \\| unchanged'", "echo 'test \\| unchanged'"),
# Mixed quoted and unquoted
(
'echo "quoted \\;" \\; "more" \\| grep',
'echo "quoted \\;" \\\\; "more" \\\\| grep',
),
# Multiple escapes in sequence
('cmd1 \\;\\|\\& cmd2', 'cmd1 \\\\;\\\\|\\\\& cmd2'),
# Commands with other backslashes
('echo test\\ntest', 'echo test\\ntest'),
('echo "test\\ntest"', 'echo "test\\ntest"'),
# Edge cases
('', ''), # Empty string
('\\\\', '\\\\'), # Double backslash
('\\"', '\\"'), # Escaped quote
]
for input_cmd, expected in test_cases:
result = escape_bash_special_chars(input_cmd)
assert result == expected, (
f'Failed on input "{input_cmd}"\nExpected: "{expected}"\nGot: "{result}"'
)
def test_escape_bash_special_chars_with_invalid_syntax():
invalid_inputs = [
'echo "unclosed quote',
"echo 'unclosed quote",
'cat <<EOF\nunclosed heredoc',
]
for input_cmd in invalid_inputs:
# Should return original input when parsing fails
result = escape_bash_special_chars(input_cmd)
assert result == input_cmd, f'Failed to handle invalid input: {input_cmd}'
def test_escape_bash_special_chars_with_heredoc():
input_cmd = r"""cat <<EOF
line1 \; not escaped
line2 \| not escaped
EOF"""
# Heredoc content should not be escaped
expected = input_cmd
result = escape_bash_special_chars(input_cmd)
assert result == expected, (
f'Failed to handle heredoc correctly\nExpected: {expected}\nGot: {result}'
)
def test_escape_bash_special_chars_with_parameter_expansion():
test_cases = [
# Parameter expansion should be preserved
('echo $HOME', 'echo $HOME'),
('echo ${HOME}', 'echo ${HOME}'),
('echo ${HOME:-default}', 'echo ${HOME:-default}'),
# Mixed with special chars
('echo $HOME \\; ls', 'echo $HOME \\\\; ls'),
('echo ${PATH} \\| grep bin', 'echo ${PATH} \\\\| grep bin'),
# Quoted parameter expansion
('echo "$HOME"', 'echo "$HOME"'),
('echo "${HOME}"', 'echo "${HOME}"'),
# Complex parameter expansions
('echo ${var:=default} \\; ls', 'echo ${var:=default} \\\\; ls'),
('echo ${!prefix*} \\| sort', 'echo ${!prefix*} \\\\| sort'),
]
for input_cmd, expected in test_cases:
result = escape_bash_special_chars(input_cmd)
assert result == expected, (
f'Failed on input "{input_cmd}"\nExpected: "{expected}"\nGot: "{result}"'
)
def test_escape_bash_special_chars_with_command_substitution():
test_cases = [
# Basic command substitution
('echo $(pwd)', 'echo $(pwd)'),
('echo `pwd`', 'echo `pwd`'),
# Mixed with special chars
('echo $(pwd) \\; ls', 'echo $(pwd) \\\\; ls'),
('echo `pwd` \\| grep home', 'echo `pwd` \\\\| grep home'),
# Nested command substitution
('echo $(echo `pwd`)', 'echo $(echo `pwd`)'),
# Complex command substitution
('echo $(find . -name "*.txt" \\; ls)', 'echo $(find . -name "*.txt" \\; ls)'),
# Mixed with quotes
('echo "$(pwd)"', 'echo "$(pwd)"'),
('echo "`pwd`"', 'echo "`pwd`"'),
]
for input_cmd, expected in test_cases:
result = escape_bash_special_chars(input_cmd)
assert result == expected, (
f'Failed on input "{input_cmd}"\nExpected: "{expected}"\nGot: "{result}"'
)
def test_escape_bash_special_chars_mixed_nodes():
test_cases = [
# Mix of parameter expansion and command substitution
('echo $HOME/$(pwd)', 'echo $HOME/$(pwd)'),
# Mix with special chars
('echo $HOME/$(pwd) \\; ls', 'echo $HOME/$(pwd) \\\\; ls'),
# Complex mixed cases
(
'echo "${HOME}/$(basename `pwd`) \\; next"',
'echo "${HOME}/$(basename `pwd`) \\; next"',
),
(
'VAR=${HOME} \\; echo $(pwd)',
'VAR=${HOME} \\\\; echo $(pwd)',
),
# Real-world examples
(
'find . -name "*.txt" -exec grep "${PATTERN:-default}" {} \\;',
'find . -name "*.txt" -exec grep "${PATTERN:-default}" {} \\\\;',
),
(
'echo "Current path: ${PWD}/$(basename `pwd`)" \\| grep home',
'echo "Current path: ${PWD}/$(basename `pwd`)" \\\\| grep home',
),
]
for input_cmd, expected in test_cases:
result = escape_bash_special_chars(input_cmd)
assert result == expected, (
f'Failed on input "{input_cmd}"\nExpected: "{expected}"\nGot: "{result}"'
)
def test_escape_bash_special_chars_with_chained_commands():
test_cases = [
# Basic chained commands
('ls && pwd', 'ls && pwd'),
('echo "hello" && ls', 'echo "hello" && ls'),
# Chained commands with special chars
('ls \\; pwd && echo test', 'ls \\\\; pwd && echo test'),
('echo test && grep pattern \\| sort', 'echo test && grep pattern \\\\| sort'),
# Complex chained cases
('echo ${HOME} && ls \\; pwd', 'echo ${HOME} && ls \\\\; pwd'),
(
'echo "$(pwd)" && cat file \\> out.txt',
'echo "$(pwd)" && cat file \\\\> out.txt',
),
# Multiple chains
('cmd1 && cmd2 && cmd3', 'cmd1 && cmd2 && cmd3'),
(
'cmd1 \\; ls && cmd2 \\| grep && cmd3',
'cmd1 \\\\; ls && cmd2 \\\\| grep && cmd3',
),
]
for input_cmd, expected in test_cases:
result = escape_bash_special_chars(input_cmd)
assert result == expected, (
f'Failed on input "{input_cmd}"\nExpected: "{expected}"\nGot: "{result}"'
)

View File

@@ -1,339 +0,0 @@
import json
from openhands.events.observation.commands import (
CMD_OUTPUT_METADATA_PS1_REGEX,
CMD_OUTPUT_PS1_BEGIN,
CMD_OUTPUT_PS1_END,
CmdOutputMetadata,
CmdOutputObservation,
)
def test_ps1_metadata_format():
"""Test that PS1 prompt has correct format markers"""
prompt = CmdOutputMetadata.to_ps1_prompt()
print(prompt)
assert prompt.startswith('\n###PS1JSON###\n')
assert prompt.endswith('\n###PS1END###\n')
assert r'\"exit_code\"' in prompt, 'PS1 prompt should contain escaped double quotes'
def test_ps1_metadata_json_structure():
"""Test that PS1 prompt contains valid JSON with expected fields"""
prompt = CmdOutputMetadata.to_ps1_prompt()
# Extract JSON content between markers
json_str = prompt.replace('###PS1JSON###\n', '').replace('\n###PS1END###\n', '')
# Remove escaping before parsing
json_str = json_str.replace(r'\"', '"')
# Remove any trailing content after the JSON
json_str = json_str.split('###PS1END###')[0].strip()
data = json.loads(json_str)
# Check required fields
expected_fields = {
'pid',
'exit_code',
'username',
'hostname',
'working_dir',
'py_interpreter_path',
}
assert set(data.keys()) == expected_fields
def test_ps1_metadata_parsing():
"""Test parsing PS1 output into CmdOutputMetadata"""
test_data = {
'exit_code': 0,
'username': 'testuser',
'hostname': 'localhost',
'working_dir': '/home/testuser',
'py_interpreter_path': '/usr/bin/python',
}
ps1_str = f"""###PS1JSON###
{json.dumps(test_data, indent=2)}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == test_data['exit_code']
assert metadata.username == test_data['username']
assert metadata.hostname == test_data['hostname']
assert metadata.working_dir == test_data['working_dir']
assert metadata.py_interpreter_path == test_data['py_interpreter_path']
def test_ps1_metadata_parsing_string():
"""Test parsing PS1 output into CmdOutputMetadata"""
ps1_str = r"""###PS1JSON###
{
"exit_code": "0",
"username": "myname",
"hostname": "myhostname",
"working_dir": "~/mydir",
"py_interpreter_path": "/my/python/path"
}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == 0
assert metadata.username == 'myname'
assert metadata.hostname == 'myhostname'
assert metadata.working_dir == '~/mydir'
assert metadata.py_interpreter_path == '/my/python/path'
def test_ps1_metadata_parsing_string_real_example():
"""Test parsing PS1 output into CmdOutputMetadata"""
ps1_str = r"""
###PS1JSON###
{
"pid": "",
"exit_code": "0",
"username": "runner",
"hostname": "fv-az1055-610",
"working_dir": "/home/runner/work/OpenHands/OpenHands",
"py_interpreter_path": "/home/runner/.cache/pypoetry/virtualenvs/openhands-ai-ULPBlkAi-py3.12/bin/python"
}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == 0
assert metadata.username == 'runner'
assert metadata.hostname == 'fv-az1055-610'
assert metadata.working_dir == '/home/runner/work/OpenHands/OpenHands'
assert (
metadata.py_interpreter_path
== '/home/runner/.cache/pypoetry/virtualenvs/openhands-ai-ULPBlkAi-py3.12/bin/python'
)
def test_ps1_metadata_parsing_additional_prefix():
"""Test parsing PS1 output into CmdOutputMetadata"""
test_data = {
'exit_code': 0,
'username': 'testuser',
'hostname': 'localhost',
'working_dir': '/home/testuser',
'py_interpreter_path': '/usr/bin/python',
}
ps1_str = f"""
This is something that not part of the PS1 prompt
###PS1JSON###
{json.dumps(test_data, indent=2)}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == test_data['exit_code']
assert metadata.username == test_data['username']
assert metadata.hostname == test_data['hostname']
assert metadata.working_dir == test_data['working_dir']
assert metadata.py_interpreter_path == test_data['py_interpreter_path']
def test_ps1_metadata_parsing_invalid():
"""Test parsing invalid PS1 output returns default metadata"""
# Test with invalid JSON
invalid_json = """###PS1JSON###
{invalid json}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(invalid_json)
assert len(matches) == 0 # No matches should be found for invalid JSON
# Test with missing markers
invalid_format = """NOT A VALID PS1 PROMPT"""
matches = CmdOutputMetadata.matches_ps1_metadata(invalid_format)
assert len(matches) == 0
# Test with empty PS1 metadata
empty_metadata = """###PS1JSON###
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(empty_metadata)
assert len(matches) == 0 # No matches should be found for empty metadata
# Test with whitespace in PS1 metadata
whitespace_metadata = """###PS1JSON###
{
"exit_code": "0",
"pid": "123",
"username": "test",
"hostname": "localhost",
"working_dir": "/home/test",
"py_interpreter_path": "/usr/bin/python"
}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(whitespace_metadata)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == 0
assert metadata.pid == 123
def test_ps1_metadata_missing_fields():
"""Test handling of missing fields in PS1 metadata"""
# Test with only required fields
minimal_data = {'exit_code': 0, 'pid': 123}
ps1_str = f"""###PS1JSON###
{json.dumps(minimal_data)}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == 0
assert metadata.pid == 123
assert metadata.username is None
assert metadata.hostname is None
assert metadata.working_dir is None
assert metadata.py_interpreter_path is None
# Test with missing exit_code but valid pid
no_exit_code = {'pid': 123, 'username': 'test'}
ps1_str = f"""###PS1JSON###
{json.dumps(no_exit_code)}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == -1 # default value
assert metadata.pid == 123
assert metadata.username == 'test'
def test_ps1_metadata_multiple_blocks():
"""Test handling multiple PS1 metadata blocks"""
test_data = {
'exit_code': 0,
'username': 'testuser',
'hostname': 'localhost',
'working_dir': '/home/testuser',
'py_interpreter_path': '/usr/bin/python',
}
ps1_str = f"""###PS1JSON###
{json.dumps(test_data, indent=2)}
###PS1END###
Some other content
###PS1JSON###
{json.dumps(test_data, indent=2)}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 2 # Should find both blocks
# Both blocks should parse successfully
metadata1 = CmdOutputMetadata.from_ps1_match(matches[0])
metadata2 = CmdOutputMetadata.from_ps1_match(matches[1])
assert metadata1.exit_code == test_data['exit_code']
assert metadata2.exit_code == test_data['exit_code']
def test_ps1_metadata_regex_pattern():
"""Test the regex pattern used to extract PS1 metadata"""
# Test basic pattern matching
test_str = f'{CMD_OUTPUT_PS1_BEGIN}test\n{CMD_OUTPUT_PS1_END}'
matches = CMD_OUTPUT_METADATA_PS1_REGEX.finditer(test_str)
match = next(matches)
assert match.group(1).strip() == 'test'
# Test with content before and after
test_str = f'prefix\n{CMD_OUTPUT_PS1_BEGIN}test\n{CMD_OUTPUT_PS1_END}suffix'
matches = CMD_OUTPUT_METADATA_PS1_REGEX.finditer(test_str)
match = next(matches)
assert match.group(1).strip() == 'test'
# Test with multiline content
test_str = f'{CMD_OUTPUT_PS1_BEGIN}line1\nline2\nline3\n{CMD_OUTPUT_PS1_END}'
matches = CMD_OUTPUT_METADATA_PS1_REGEX.finditer(test_str)
match = next(matches)
assert match.group(1).strip() == 'line1\nline2\nline3'
def test_cmd_output_observation_properties():
"""Test CmdOutputObservation class properties"""
# Test with successful command
metadata = CmdOutputMetadata(exit_code=0, pid=123)
obs = CmdOutputObservation(command='ls', content='file1\nfile2', metadata=metadata)
assert obs.command_id == 123
assert obs.exit_code == 0
assert not obs.error
assert 'exit code 0' in obs.message
assert 'ls' in obs.message
assert 'file1' in str(obs)
assert 'file2' in str(obs)
assert 'metadata' in str(obs)
# Test with failed command
metadata = CmdOutputMetadata(exit_code=1, pid=456)
obs = CmdOutputObservation(command='invalid', content='error', metadata=metadata)
assert obs.command_id == 456
assert obs.exit_code == 1
assert obs.error
assert 'exit code 1' in obs.message
assert 'invalid' in obs.message
assert 'error' in str(obs)
def test_ps1_metadata_empty_fields():
"""Test handling of empty fields in PS1 metadata"""
# Test with empty strings
empty_data = {
'exit_code': 0,
'pid': 123,
'username': '',
'hostname': '',
'working_dir': '',
'py_interpreter_path': '',
}
ps1_str = f"""###PS1JSON###
{json.dumps(empty_data)}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(ps1_str)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == 0
assert metadata.pid == 123
assert metadata.username == ''
assert metadata.hostname == ''
assert metadata.working_dir == ''
assert metadata.py_interpreter_path == ''
# Test with malformed but valid JSON
malformed_json = """###PS1JSON###
{
"exit_code":0,
"pid" : 123,
"username": "test" ,
"hostname": "host",
"working_dir" :"dir",
"py_interpreter_path":"path"
}
###PS1END###
"""
matches = CmdOutputMetadata.matches_ps1_metadata(malformed_json)
assert len(matches) == 1
metadata = CmdOutputMetadata.from_ps1_match(matches[0])
assert metadata.exit_code == 0
assert metadata.pid == 123
assert metadata.username == 'test'
assert metadata.hostname == 'host'
assert metadata.working_dir == 'dir'
assert metadata.py_interpreter_path == 'path'

View File

@@ -1,388 +0,0 @@
import os
import tempfile
import time
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.runtime.utils.bash import BashCommandStatus, BashSession
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_no_change_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected no-change timeout suffix."""
return (
f'\n[The command has no new output after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
def test_session_initialization():
# Test with custom working directory
with tempfile.TemporaryDirectory() as temp_dir:
session = BashSession(work_dir=temp_dir)
session.initialize()
obs = session.execute(CmdRunAction('pwd'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert temp_dir in obs.content
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
session.close()
# Test with custom username
session = BashSession(work_dir=os.getcwd(), username='nobody')
session.initialize()
assert 'openhands-nobody' in session.session.name
session.close()
def test_cwd_property(tmp_path):
session = BashSession(work_dir=tmp_path)
session.initialize()
# Change directory and verify pwd updates
random_dir = tmp_path / 'random'
random_dir.mkdir()
session.execute(CmdRunAction(f'cd {random_dir}'))
assert session.cwd == str(random_dir)
session.close()
def test_basic_command():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Test simple command
obs = session.execute(CmdRunAction("echo 'hello world'"))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'hello world' in obs.content
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == 0
assert session.prev_status == BashCommandStatus.COMPLETED
# Test command with error
obs = session.execute(CmdRunAction('nonexistent_command'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == 127
assert 'nonexistent_command: command not found' in obs.content
assert obs.metadata.suffix == '\n[The command completed with exit code 127.]'
assert obs.metadata.prefix == ''
assert session.prev_status == BashCommandStatus.COMPLETED
# Test multiple commands in sequence
obs = session.execute(CmdRunAction('echo "first" && echo "second" && echo "third"'))
assert 'first' in obs.content
assert 'second' in obs.content
assert 'third' in obs.content
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == 0
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_long_running_command_follow_by_execute():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Test command that produces output slowly
obs = session.execute(
CmdRunAction('for i in {1..3}; do echo $i; sleep 3; done', blocking=False)
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.prefix == ''
# Continue watching output
obs = session.execute(CmdRunAction('', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Test command that produces no output
obs = session.execute(CmdRunAction('sleep 15'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '3' not in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert 'The previous command is still running' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
time.sleep(3)
# Run it again, this time it should produce output
obs = session.execute(CmdRunAction('sleep 15'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '3' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert 'The previous command is still running' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
session.close()
def test_interactive_command():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=3)
session.initialize()
# Test interactive command with blocking=True
obs = session.execute(
CmdRunAction(
'read -p \'Enter name: \' name && echo "Hello $name"',
)
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Enter name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == ''
# Send input
obs = session.execute(CmdRunAction('John', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Hello John' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
assert session.prev_status == BashCommandStatus.COMPLETED
# Test multiline command input
obs = session.execute(CmdRunAction('cat << EOF'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == ''
obs = session.execute(CmdRunAction('line 1', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('line 2', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('EOF', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'line 1' in obs.content and 'line 2' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
session.close()
def test_ctrl_c():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Start infinite loop
obs = session.execute(
CmdRunAction("while true; do echo 'looping'; sleep 3; done"),
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'looping' in obs.content
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Send Ctrl+C
obs = session.execute(CmdRunAction('C-c', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Check that the process was interrupted (exit code can be 1 or 130 depending on the shell/OS)
assert obs.metadata.exit_code in (
1,
130,
) # Accept both common exit codes for interrupted processes
assert 'CTRL+C was sent' in obs.metadata.suffix
assert obs.metadata.prefix == ''
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_empty_command_errors():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Test empty command without previous command
obs = session.execute(CmdRunAction(''))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.content == 'ERROR: No previous running command to retrieve logs from.'
assert obs.metadata.exit_code == -1
assert obs.metadata.prefix == ''
assert obs.metadata.suffix == ''
assert session.prev_status is None
session.close()
def test_command_output_continuation():
"""Test that we can continue to get output from a long-running command.
This test has been modified to be more robust against timing issues.
"""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=1)
session.initialize()
# Start a command that produces output slowly but with longer sleep time
# to ensure we hit the timeout
obs = session.execute(CmdRunAction('for i in {1..5}; do echo $i; sleep 2; done'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Check if the command completed immediately or timed out
if session.prev_status == BashCommandStatus.COMPLETED:
# If the command completed immediately, verify we got all the output
logger.info('Command completed immediately', extra={'msg_type': 'TEST_INFO'})
assert '1' in obs.content
assert '2' in obs.content
assert '3' in obs.content
assert '4' in obs.content
assert '5' in obs.content
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
else:
# If the command timed out, verify we got the timeout message
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert '1' in obs.content
assert '[The command has no new output after 1 seconds.' in obs.metadata.suffix
# Continue getting output until we see all numbers
numbers_seen = set()
for i in range(1, 6):
if str(i) in obs.content:
numbers_seen.add(i)
# We need to see numbers 2-5 and then the command completion
while (
len(numbers_seen) < 5 or session.prev_status != BashCommandStatus.COMPLETED
):
obs = session.execute(CmdRunAction('', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Check for numbers in the output
for i in range(1, 6):
if str(i) in obs.content and i not in numbers_seen:
numbers_seen.add(i)
logger.info(
f'Found number {i} in output', extra={'msg_type': 'TEST_INFO'}
)
# Check if the command has completed
if session.prev_status == BashCommandStatus.COMPLETED:
assert (
'[The command completed with exit code 0.]' in obs.metadata.suffix
)
break
else:
assert (
'[The command has no new output after 1 seconds.'
in obs.metadata.suffix
)
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Verify we've seen all numbers
assert numbers_seen == {1, 2, 3, 4, 5}, (
f'Expected to see numbers 1-5, but saw {numbers_seen}'
)
# Verify the command completed
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_long_output():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Generate a long output that may exceed buffer size
obs = session.execute(CmdRunAction('for i in {1..5000}; do echo "Line $i"; done'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Line 1' in obs.content
assert 'Line 5000' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.prefix == ''
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
session.close()
def test_long_output_exceed_history_limit():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Generate a long output that may exceed buffer size
obs = session.execute(CmdRunAction('for i in {1..50000}; do echo "Line $i"; done'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Previous command outputs are truncated' in obs.metadata.prefix
assert 'Line 40000' in obs.content
assert 'Line 50000' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
session.close()
def test_multiline_command():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Test multiline command with PS2 prompt disabled
obs = session.execute(
CmdRunAction("""if true; then
echo "inside if"
fi""")
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'inside if' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.prefix == ''
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
session.close()
def test_python_interactive_input():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Test Python program that asks for input - properly escaped for bash
python_script = """name = input('Enter your name: '); age = input('Enter your age: '); print(f'Hello {name}, you are {age} years old')"""
# Start Python with the interactive script
obs = session.execute(CmdRunAction(f'python3 -c "{python_script}"'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Enter your name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Send first input (name)
obs = session.execute(CmdRunAction('Alice', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Enter your age:' in obs.content
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Send second input (age)
obs = session.execute(CmdRunAction('25', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Hello Alice, you are 25 years old' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()

View File

@@ -1,294 +0,0 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pytest
from openhands.runtime.utils import git_changes, git_diff, git_handler
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
@pytest.mark.skipif(sys.platform == 'win32', reason='Windows is not supported')
class TestGitHandler(unittest.TestCase):
def setUp(self):
# Create temporary directories for our test repositories
self.test_dir = tempfile.mkdtemp()
self.origin_dir = os.path.join(self.test_dir, 'origin')
self.local_dir = os.path.join(self.test_dir, 'local')
# Create the directories
os.makedirs(self.origin_dir, exist_ok=True)
os.makedirs(self.local_dir, exist_ok=True)
# Track executed commands for verification
self.executed_commands = []
self.created_files = []
# Initialize the GitHandler with our mock functions
self.git_handler = GitHandler(
execute_shell_fn=self._execute_command, create_file_fn=self._create_file
)
self.git_handler.set_cwd(self.local_dir)
self.git_handler.git_changes_cmd = f'python3 {git_changes.__file__}'
self.git_handler.git_diff_cmd = f'python3 {git_diff.__file__} "{{file_path}}"'
# Set up the git repositories
self._setup_git_repos()
def tearDown(self):
# Clean up the temporary directories
shutil.rmtree(self.test_dir)
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
result = subprocess.run(
args=cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
)
stderr = result.stderr or b''
stdout = result.stdout or b''
return CommandResult((stderr + stdout).decode(), result.returncode)
def run_command(self, cmd, cwd=None):
result = self._execute_command(cmd, cwd)
if result.exit_code != 0:
raise RuntimeError(
f'command_error:{cmd};{result.exit_code};{result.content}'
)
def _create_file(self, path, content):
"""Mock function for creating files."""
self.created_files.append((path, content))
try:
with open(path, 'w') as f:
f.write(content)
return 0
except Exception:
return -1
def write_file(
self,
dir: str,
name: str,
additional_content: tuple[str, ...] = ('Line 1', 'Line 2', 'Line 3'),
):
with open(os.path.join(dir, name), 'w') as f:
f.write(name)
for line in additional_content:
f.write('\n')
f.write(line)
assert os.path.exists(os.path.join(dir, name))
def _setup_git_repos(self):
"""Set up real git repositories for testing."""
# Set up origin repository
self.run_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Set up the initial state...
self.write_file(self.origin_dir, 'unchanged.txt')
self.write_file(self.origin_dir, 'committed_modified.txt')
self.write_file(self.origin_dir, 'staged_modified.txt')
self.write_file(self.origin_dir, 'unstaged_modified.txt')
self.write_file(self.origin_dir, 'committed_delete.txt')
self.write_file(self.origin_dir, 'staged_delete.txt')
self.write_file(self.origin_dir, 'unstaged_delete.txt')
self.run_command("git add . && git commit -m 'Initial Commit'", self.origin_dir)
# Clone the origin repository to local
self.run_command(f'git clone "{self.origin_dir}" "{self.local_dir}"')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
self.run_command('git checkout -b feature-branch', self.local_dir)
# Setup committed changes...
self.write_file(self.local_dir, 'committed_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'committed_add.txt')
os.remove(os.path.join(self.local_dir, 'committed_delete.txt'))
self.run_command(
"git add . && git commit -m 'First batch of changes'", self.local_dir
)
# Setup staged changes...
self.write_file(self.local_dir, 'staged_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'staged_add.txt')
os.remove(os.path.join(self.local_dir, 'staged_delete.txt'))
self.run_command('git add .', self.local_dir)
# Setup unstaged changes...
self.write_file(self.local_dir, 'unstaged_modified.txt', ('Line 4',))
self.write_file(self.local_dir, 'unstaged_add.txt')
os.remove(os.path.join(self.local_dir, 'unstaged_delete.txt'))
def setup_nested(self):
nested_1 = Path(self.local_dir, 'nested 1')
nested_1.mkdir()
nested_1 = str(nested_1)
self.run_command('git init --initial-branch=main', nested_1)
self._execute_command("git config user.email 'test@example.com'", nested_1)
self._execute_command("git config user.name 'Test User'", nested_1)
self.write_file(nested_1, 'committed_add.txt')
self.run_command('git add .', nested_1)
self.run_command('git commit -m "Initial Commit"', nested_1)
self.write_file(nested_1, 'staged_add.txt')
nested_2 = Path(self.local_dir, 'nested_2')
nested_2.mkdir()
nested_2 = str(nested_2)
self.run_command('git init --initial-branch=main', nested_2)
self._execute_command("git config user.email 'test@example.com'", nested_2)
self._execute_command("git config user.name 'Test User'", nested_2)
self.write_file(nested_2, 'committed_add.txt')
self.run_command('git add .', nested_2)
self.run_command('git commit -m "Initial Commit"', nested_2)
self.write_file(nested_2, 'unstaged_add.txt')
def test_get_git_changes(self):
"""Test with unpushed commits, staged commits, and unstaged commits"""
changes = self.git_handler.get_git_changes()
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_changes_after_push(self):
"""Test with staged commits, and unstaged commits"""
self.run_command('git push -u origin feature-branch', self.local_dir)
changes = self.git_handler.get_git_changes()
expected_changes = [
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_changes_nested_repos(self):
"""Test with staged commits, and unstaged commits"""
self.setup_nested()
changes = self.git_handler.get_git_changes()
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'nested 1/committed_add.txt'},
{'status': 'A', 'path': 'nested 1/staged_add.txt'},
{'status': 'A', 'path': 'nested_2/committed_add.txt'},
{'status': 'A', 'path': 'nested_2/unstaged_add.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_diff_staged_modified(self):
"""Test on a staged modified"""
diff = self.git_handler.get_git_diff('staged_modified.txt')
expected_diff = {
'original': 'staged_modified.txt\nLine 1\nLine 2\nLine 3',
'modified': 'staged_modified.txt\nLine 4',
}
assert diff == expected_diff
def test_get_git_diff_unchanged(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('unchanged.txt')
expected_diff = {
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
def test_get_git_diff_unpushed(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('committed_modified.txt')
expected_diff = {
'original': 'committed_modified.txt\nLine 1\nLine 2\nLine 3',
'modified': 'committed_modified.txt\nLine 4',
}
assert diff == expected_diff
def test_get_git_diff_unstaged_add(self):
"""Test that get_git_diff delegates to the git_diff module."""
diff = self.git_handler.get_git_diff('unstaged_add.txt')
expected_diff = {
'original': '',
'modified': 'unstaged_add.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff
def test_get_git_changes_fallback(self):
"""Test that get_git_changes falls back to creating a script file when needed."""
# Break the git changes command
with patch(
'openhands.runtime.utils.git_handler.GIT_CHANGES_CMD',
'non-existant-command',
):
self.git_handler.git_changes_cmd = git_handler.GIT_CHANGES_CMD
changes = self.git_handler.get_git_changes()
expected_changes = [
{'status': 'A', 'path': 'committed_add.txt'},
{'status': 'D', 'path': 'committed_delete.txt'},
{'status': 'M', 'path': 'committed_modified.txt'},
{'status': 'A', 'path': 'staged_add.txt'},
{'status': 'D', 'path': 'staged_delete.txt'},
{'status': 'M', 'path': 'staged_modified.txt'},
{'status': 'A', 'path': 'unstaged_add.txt'},
{'status': 'D', 'path': 'unstaged_delete.txt'},
{'status': 'M', 'path': 'unstaged_modified.txt'},
]
assert changes == expected_changes
def test_get_git_diff_fallback(self):
"""Test that get_git_diff delegates to the git_diff module."""
# Break the git diff command
with patch(
'openhands.runtime.utils.git_handler.GIT_DIFF_CMD', 'non-existant-command'
):
self.git_handler.git_diff_cmd = git_handler.GIT_DIFF_CMD
diff = self.git_handler.get_git_diff('unchanged.txt')
expected_diff = {
'original': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
'modified': 'unchanged.txt\nLine 1\nLine 2\nLine 3',
}
assert diff == expected_diff

View File

@@ -1,90 +0,0 @@
from unittest.mock import Mock
import pytest
from openhands.runtime.utils.log_streamer import LogStreamer
@pytest.fixture
def mock_container():
return Mock()
@pytest.fixture
def mock_log_fn():
return Mock()
def test_init_failure_handling(mock_container, mock_log_fn):
"""Test that LogStreamer handles initialization failures gracefully."""
mock_container.logs.side_effect = Exception('Test error')
streamer = LogStreamer(mock_container, mock_log_fn)
assert streamer.stdout_thread is None
assert streamer.log_generator is None
mock_log_fn.assert_called_with(
'error', 'Failed to initialize log streaming: Test error'
)
def test_stream_logs_without_generator(mock_container, mock_log_fn):
"""Test that _stream_logs handles missing log generator gracefully."""
streamer = LogStreamer(mock_container, mock_log_fn)
streamer.log_generator = None
streamer._stream_logs()
mock_log_fn.assert_called_with('error', 'Log generator not initialized')
def test_cleanup_without_thread(mock_container, mock_log_fn):
"""Test that cleanup works even if stdout_thread is not initialized."""
streamer = LogStreamer(mock_container, mock_log_fn)
streamer.stdout_thread = None
streamer.close() # Should not raise any exceptions
def test_normal_operation(mock_container, mock_log_fn):
"""Test normal operation of LogStreamer."""
# Create a mock generator class that mimics Docker's log generator
class MockLogGenerator:
def __init__(self, logs):
self.logs = iter(logs)
self.closed = False
def __iter__(self):
return self
def __next__(self):
if self.closed:
raise StopIteration
return next(self.logs)
def close(self):
self.closed = True
mock_logs = MockLogGenerator([b'test log 1\n', b'test log 2\n'])
mock_container.logs.return_value = mock_logs
streamer = LogStreamer(mock_container, mock_log_fn)
assert streamer.stdout_thread is not None
assert streamer.log_generator is not None
streamer.close()
# Verify logs were processed
expected_calls = [
('debug', '[inside container] test log 1'),
('debug', '[inside container] test log 2'),
]
actual_calls = [(args[0], args[1]) for args, _ in mock_log_fn.call_args_list]
for expected in expected_calls:
assert expected in actual_calls
def test_del_without_thread(mock_container, mock_log_fn):
"""Test that __del__ works even if stdout_thread was not initialized."""
streamer = LogStreamer(mock_container, mock_log_fn)
delattr(
streamer, 'stdout_thread'
) # Simulate case where the thread was never created
streamer.__del__() # Should not raise any exceptions

View File

@@ -1,602 +0,0 @@
import os
import sys
import tempfile
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from openhands.events.action import CmdRunAction
from openhands.events.observation import ErrorObservation
from openhands.events.observation.commands import (
CmdOutputObservation,
)
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected timeout suffix."""
return (
f'[The command timed out after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
sys.platform != 'win32', reason='WindowsPowershellSession tests require Windows'
)
@pytest.fixture
def temp_work_dir():
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir
@pytest.fixture
def windows_bash_session(temp_work_dir):
"""Create a WindowsPowershellSession instance for testing."""
# Instantiate the class. Initialization happens in __init__.
session = WindowsPowershellSession(
work_dir=temp_work_dir,
username=None,
)
assert session._initialized # Should be true after __init__
yield session
# Ensure cleanup happens even if test fails
session.close()
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
def test_command_execution(windows_bash_session):
"""Test basic command execution."""
# Test a simple command
action = CmdRunAction(command="Write-Output 'Hello World'")
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check content, stripping potential trailing newlines
content = result.content.strip()
assert content == 'Hello World'
assert result.exit_code == 0
# Test a simple command with multiline input but single line output
action = CmdRunAction(
command="""Write-Output `
('hello ' + `
'world')"""
)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check content, stripping potential trailing newlines
content = result.content.strip()
assert content == 'hello world'
assert result.exit_code == 0
# Test a simple command with a newline
action = CmdRunAction(command='Write-Output "Hello\\n World"')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check content, stripping potential trailing newlines
content = result.content.strip()
assert content == 'Hello\\n World'
assert result.exit_code == 0
def test_command_with_error(windows_bash_session):
"""Test command execution with an error reported via Write-Error."""
# Test a command that will write an error
action = CmdRunAction(command="Write-Error 'Test Error'")
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Error stream is captured and appended
assert 'ERROR' in result.content
# Our implementation should set exit code to 1 when errors occur in stream
assert result.exit_code == 1
def test_command_failure_exit_code(windows_bash_session):
"""Test command execution that results in a non-zero exit code."""
# Test a command that causes a script failure (e.g., invalid cmdlet)
action = CmdRunAction(command='Get-NonExistentCmdlet')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Error should be captured in the output
assert 'ERROR' in result.content
assert (
'is not recognized' in result.content
or 'CommandNotFoundException' in result.content
)
assert result.exit_code == 1
def test_control_commands(windows_bash_session):
"""Test handling of control commands (not supported)."""
# Test Ctrl+C - should return ErrorObservation if no command is running
action_c = CmdRunAction(command='C-c', is_input=True)
result_c = windows_bash_session.execute(action_c)
assert isinstance(result_c, ErrorObservation)
assert 'No previous running command to interact with' in result_c.content
# Run a long-running command
action_long_running = CmdRunAction(command='Start-Sleep -Seconds 100')
result_long_running = windows_bash_session.execute(action_long_running)
assert isinstance(result_long_running, CmdOutputObservation)
assert result_long_running.exit_code == -1
# Test unsupported control command
action_d = CmdRunAction(command='C-d', is_input=True)
result_d = windows_bash_session.execute(action_d)
assert "Your input command 'C-d' was NOT processed" in result_d.metadata.suffix
assert (
'Direct input to running processes (is_input=True) is not supported by this PowerShell session implementation.'
in result_d.metadata.suffix
)
assert 'You can use C-c to stop the process' in result_d.metadata.suffix
# Ctrl+C now can cancel the long-running command
action_c = CmdRunAction(command='C-c', is_input=True)
result_c = windows_bash_session.execute(action_c)
assert isinstance(result_c, CmdOutputObservation)
assert result_c.exit_code == 0
def test_command_timeout(windows_bash_session):
"""Test command timeout handling."""
# Test a command that will timeout
test_timeout_sec = 1
action = CmdRunAction(command='Start-Sleep -Seconds 5')
action.set_hard_timeout(test_timeout_sec)
start_time = time.monotonic()
result = windows_bash_session.execute(action)
duration = time.monotonic() - start_time
assert isinstance(result, CmdOutputObservation)
# Check for timeout specific metadata
assert 'timed out' in result.metadata.suffix.lower() # Check suffix, not content
assert result.exit_code == -1 # Timeout should result in exit code -1
# Check that it actually timed out near the specified time
assert abs(duration - test_timeout_sec) < 0.5 # Allow some buffer
def test_long_running_command(windows_bash_session):
action = CmdRunAction(command='python -u -m http.server 8081')
action.set_hard_timeout(1)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Verify the initial output was captured
assert 'Serving HTTP on' in result.content
# Check for timeout specific metadata
assert get_timeout_suffix(1.0) in result.metadata.suffix
assert result.exit_code == -1
# The action timed out, but the command should be still running
# We should now be able to interrupt it
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30) # Give it enough time to stop
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# On Windows, Stop-Job termination doesn't inherently return output.
# The CmdOutputObservation will have content="" and exit_code=0 if successful.
# The KeyboardInterrupt message assertion is removed as it's added manually
# by the wrapper and might not be guaranteed depending on timing/implementation details.
assert result.exit_code == 0
# Verify the server is actually stopped by starting another one on the same port
action = CmdRunAction(command='python -u -m http.server 8081')
action.set_hard_timeout(1) # Set a short timeout to check if it starts
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Verify the initial output was captured, indicating the port was free
assert 'Serving HTTP on' in result.content
# The command will time out again, so the exit code should be -1
assert result.exit_code == -1
# Clean up the second server process
action = CmdRunAction(command='C-c', is_input=True)
action.set_hard_timeout(30)
result = windows_bash_session.execute(action)
assert result.exit_code == 0
def test_multiple_commands_rejected_and_individual_execution(windows_bash_session):
"""Test that executing multiple commands separated by newline is rejected,
but individual commands (including multiline) execute correctly.
"""
# Define a list of commands, including multiline and special characters
cmds = [
'Get-ChildItem',
'Write-Output "hello`nworld"',
"""Write-Output "hello it's me\"""",
"""Write-Output `
'hello' `
-NoNewline""",
"""Write-Output 'hello`nworld`nare`nyou`nthere?'""",
"""Write-Output 'hello`nworld`nare`nyou`n`nthere?'""",
"""Write-Output 'hello`nworld `"'""", # Escape the trailing double quote
]
joined_cmds = '\n'.join(cmds)
# 1. Test that executing multiple commands at once fails
action_multi = CmdRunAction(command=joined_cmds)
result_multi = windows_bash_session.execute(action_multi)
assert isinstance(result_multi, ErrorObservation)
assert 'ERROR: Cannot execute multiple commands at once' in result_multi.content
# 2. Now run each command individually and verify they work
results = []
for cmd in cmds:
action_single = CmdRunAction(command=cmd)
obs = windows_bash_session.execute(action_single)
assert isinstance(obs, CmdOutputObservation)
assert obs.exit_code == 0
results.append(obs.content.strip()) # Strip trailing newlines for comparison
def test_working_directory(windows_bash_session, temp_work_dir):
"""Test working directory handling."""
initial_cwd = windows_bash_session._cwd
abs_temp_work_dir = os.path.abspath(temp_work_dir)
assert initial_cwd == abs_temp_work_dir
# Create a subdirectory
sub_dir_path = Path(abs_temp_work_dir) / 'subdir'
sub_dir_path.mkdir()
assert sub_dir_path.is_dir()
# Test changing directory
action_cd = CmdRunAction(command='Set-Location subdir')
result_cd = windows_bash_session.execute(action_cd)
assert isinstance(result_cd, CmdOutputObservation)
assert result_cd.exit_code == 0
# Check that the session's internal CWD state was updated - only check the last component of path
assert windows_bash_session._cwd.lower().endswith('\\subdir')
# Check that the metadata reflects the directory *after* the command
assert result_cd.metadata.working_dir.lower().endswith('\\subdir')
# Execute a command in the new directory to confirm
action_pwd = CmdRunAction(command='(Get-Location).Path')
result_pwd = windows_bash_session.execute(action_pwd)
assert isinstance(result_pwd, CmdOutputObservation)
assert result_pwd.exit_code == 0
# Check the command output reflects the new directory
assert result_pwd.content.strip().lower().endswith('\\subdir')
# Metadata should also reflect the current directory
assert result_pwd.metadata.working_dir.lower().endswith('\\subdir')
# Test changing back to original directory
action_cd_back = CmdRunAction(command=f"Set-Location '{abs_temp_work_dir}'")
result_cd_back = windows_bash_session.execute(action_cd_back)
assert isinstance(result_cd_back, CmdOutputObservation)
assert result_cd_back.exit_code == 0
# Check only the base name of the temp directory
temp_dir_basename = os.path.basename(abs_temp_work_dir)
assert windows_bash_session._cwd.lower().endswith(temp_dir_basename.lower())
assert result_cd_back.metadata.working_dir.lower().endswith(
temp_dir_basename.lower()
)
def test_cleanup(windows_bash_session):
"""Test proper cleanup of resources (runspace)."""
# Session should be initialized before close
assert windows_bash_session._initialized
assert windows_bash_session.runspace is not None
# Close the session
windows_bash_session.close()
# Verify cleanup
assert not windows_bash_session._initialized
assert windows_bash_session.runspace is None
assert windows_bash_session._closed
def test_syntax_error_handling(windows_bash_session):
"""Test handling of syntax errors in PowerShell commands."""
# Test invalid command syntax
action = CmdRunAction(command="Write-Output 'Missing Quote")
result = windows_bash_session.execute(action)
assert isinstance(result, ErrorObservation)
# Error message appears in the output via PowerShell error stream
assert 'missing' in result.content.lower() or 'terminator' in result.content.lower()
def test_special_characters_handling(windows_bash_session):
"""Test handling of commands containing special characters."""
# Test command with special characters
special_chars_cmd = '''Write-Output "Special Chars: \\`& \\`| \\`< \\`> \\`\\` \\`' \\`\" \\`! \\`$ \\`% \\`^ \\`( \\`) \\`- \\`= \\`+ \\`[ \\`] \\`{ \\`} \\`; \\`: \\`, \\`. \\`? \\`/ \\`~"'''
action = CmdRunAction(command=special_chars_cmd)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check output contains the special characters
assert 'Special Chars:' in result.content
assert '&' in result.content and '|' in result.content
assert result.exit_code == 0
def test_empty_command(windows_bash_session):
"""Test handling of empty command string when no command is running."""
action = CmdRunAction(command='')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Should indicate error as per test_bash.py behavior
assert 'ERROR: No previous running command to retrieve logs from.' in result.content
# Exit code is typically 0 even for this specific "error" message in the bash implementation
assert result.exit_code == 0
def test_exception_during_execution(windows_bash_session):
"""Test handling of exceptions during command execution."""
# Patch the PowerShell class itself within the module where it's used
patch_target = 'openhands.runtime.utils.windows_bash.PowerShell'
# Create a mock PowerShell class
mock_powershell_class = MagicMock()
# Configure its Create method (which is called in execute) to raise an exception
# This simulates an error during the creation of the PowerShell object itself.
mock_powershell_class.Create.side_effect = Exception(
'Test exception from mocked Create'
)
with patch(patch_target, mock_powershell_class):
action = CmdRunAction(command="Write-Output 'Test'")
# Now, when execute calls PowerShell.Create(), it will hit our mock and raise the exception
result = windows_bash_session.execute(action)
# The exception should be caught by the try...except block in execute()
assert isinstance(result, ErrorObservation)
# Check the error message generated by the execute method's exception handler
assert 'Failed to start PowerShell job' in result.content
assert 'Test exception from mocked Create' in result.content
def test_streaming_output(windows_bash_session):
"""Test handling of streaming output from commands."""
# Command that produces output incrementally
command = """
1..3 | ForEach-Object {
Write-Output "Line $_"
Start-Sleep -Milliseconds 100
}
"""
action = CmdRunAction(command=command)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert 'Line 1' in result.content
assert 'Line 2' in result.content
assert 'Line 3' in result.content
assert result.exit_code == 0
def test_shutdown_signal_handling(windows_bash_session):
"""Test handling of shutdown signal during command execution."""
# This would require mocking the shutdown_listener, which might be complex.
# For now, we'll just verify that a long-running command can be executed
# and that execute() returns properly.
command = 'Start-Sleep -Seconds 1'
action = CmdRunAction(command=command)
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert result.exit_code == 0
def test_runspace_state_after_error(windows_bash_session):
"""Test that the runspace remains usable after a command error."""
# First, execute a command with an error
error_action = CmdRunAction(command='NonExistentCommand')
error_result = windows_bash_session.execute(error_action)
assert isinstance(error_result, CmdOutputObservation)
assert error_result.exit_code == 1
# Then, execute a valid command
valid_action = CmdRunAction(command="Write-Output 'Still working'")
valid_result = windows_bash_session.execute(valid_action)
assert isinstance(valid_result, CmdOutputObservation)
assert 'Still working' in valid_result.content
assert valid_result.exit_code == 0
def test_stateful_file_operations(windows_bash_session, temp_work_dir):
"""Test file operations to verify runspace state persistence.
This test verifies that:
1. The working directory state persists between commands
2. File operations work correctly relative to the current directory
3. The runspace maintains state for path-dependent operations
"""
abs_temp_work_dir = os.path.abspath(temp_work_dir)
# 1. Create a subdirectory
sub_dir_name = 'file_test_dir'
sub_dir_path = Path(abs_temp_work_dir) / sub_dir_name
# Use PowerShell to create directory
create_dir_action = CmdRunAction(
command=f'New-Item -Path "{sub_dir_name}" -ItemType Directory'
)
result = windows_bash_session.execute(create_dir_action)
assert result.exit_code == 0
# Verify directory exists on disk
assert sub_dir_path.exists() and sub_dir_path.is_dir()
# 2. Change to the new directory
cd_action = CmdRunAction(command=f"Set-Location '{sub_dir_name}'")
result = windows_bash_session.execute(cd_action)
assert result.exit_code == 0
# Check only the last directory component
assert windows_bash_session._cwd.lower().endswith(f'\\{sub_dir_name.lower()}')
# 3. Create a file in the current directory (which should be the subdirectory)
test_content = 'This is a test file created by PowerShell'
create_file_action = CmdRunAction(
command=f'Set-Content -Path "test_file.txt" -Value "{test_content}"'
)
result = windows_bash_session.execute(create_file_action)
assert result.exit_code == 0
# 4. Verify file exists at the expected path (in the subdirectory)
expected_file_path = sub_dir_path / 'test_file.txt'
assert expected_file_path.exists() and expected_file_path.is_file()
# 5. Read file contents using PowerShell and verify
read_file_action = CmdRunAction(command='Get-Content -Path "test_file.txt"')
result = windows_bash_session.execute(read_file_action)
assert result.exit_code == 0
assert test_content in result.content
# 6. Go back to parent and try to access file using relative path
cd_parent_action = CmdRunAction(command='Set-Location ..')
result = windows_bash_session.execute(cd_parent_action)
assert result.exit_code == 0
# Check only the base name of the temp directory
temp_dir_basename = os.path.basename(abs_temp_work_dir)
assert windows_bash_session._cwd.lower().endswith(temp_dir_basename.lower())
# 7. Read the file using relative path
read_from_parent_action = CmdRunAction(
command=f'Get-Content -Path "{sub_dir_name}/test_file.txt"'
)
result = windows_bash_session.execute(read_from_parent_action)
assert result.exit_code == 0
assert test_content in result.content
# 8. Clean up
remove_file_action = CmdRunAction(
command=f'Remove-Item -Path "{sub_dir_name}/test_file.txt" -Force'
)
result = windows_bash_session.execute(remove_file_action)
assert result.exit_code == 0
def test_command_output_continuation(windows_bash_session):
"""Test retrieving continued output using empty command after timeout."""
# Windows PowerShell version
action = CmdRunAction('1..5 | ForEach-Object { Write-Output $_; Start-Sleep 3 }')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert obs.content.strip() == '1'
assert obs.metadata.prefix == ''
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
# Continue watching output
action = CmdRunAction('')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '[Below is the output of the previous command.]' in obs.metadata.prefix
assert obs.content.strip() == '2'
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
# Continue until completion
for expected in ['3', '4', '5']:
action = CmdRunAction('')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '[Below is the output of the previous command.]' in obs.metadata.prefix
assert obs.content.strip() == expected
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
# Final empty command to complete
action = CmdRunAction('')
obs = windows_bash_session.execute(action)
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
def test_long_running_command_followed_by_execute(windows_bash_session):
"""Tests behavior when a new command is sent while another is running after timeout."""
# Start a slow command
action = CmdRunAction('1..3 | ForEach-Object { Write-Output $_; Start-Sleep 3 }')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
assert obs.metadata.prefix == ''
# Continue watching output
action = CmdRunAction('')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
# Test command that produces no output
action = CmdRunAction('sleep 15')
action.set_hard_timeout(2.5)
obs = windows_bash_session.execute(action)
assert '3' not in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert 'The previous command is still running' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
# Finally continue again
action = CmdRunAction('')
obs = windows_bash_session.execute(action)
assert '3' in obs.content
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
def test_command_non_existent_file(windows_bash_session):
"""Test command execution for a non-existent file returns non-zero exit code."""
# Use Get-Content which should fail if the file doesn't exist
action = CmdRunAction(command='Get-Content non_existent_file.txt')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
# Check that the exit code is non-zero (should be 1 due to the '$?' check)
assert result.exit_code == 1
# Check that the error message is captured in the output (error stream part)
assert 'Cannot find path' in result.content or 'does not exist' in result.content
def test_interactive_input(windows_bash_session):
"""Test interactive input attempt reflects implementation limitations."""
action = CmdRunAction('$name = Read-Host "Enter name"')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert (
'A command that prompts the user failed because the host program or the command type does not support user interaction. The host was attempting to request confirmation with the following message'
in result.content
)
assert result.exit_code == 1
def test_windows_path_handling(windows_bash_session, temp_work_dir):
"""Test that os.chdir works with both forward slashes and escaped backslashes on Windows."""
# Create a test directory
test_dir = Path(temp_work_dir) / 'test_dir'
test_dir.mkdir()
# Test both path formats
path_formats = [
str(test_dir).replace('\\', '/'), # Forward slashes
str(test_dir).replace('\\', '\\\\'), # Escaped backslashes
]
for path in path_formats:
# Test changing directory using os.chdir through PowerShell
action = CmdRunAction(command=f'python -c "import os; os.chdir(\'{path}\')"')
result = windows_bash_session.execute(action)
assert isinstance(result, CmdOutputObservation)
assert result.exit_code == 0, f'Failed with path format: {path}'