"""Step definitions for container_clone_into.feature. Tests the CloneIntoHandler module: URL validation, argument validation, successful clone, failure handling, and container-instance resource type CLI argument registration. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from cleveragents.application.services._resource_registry_data import ( BUILTIN_TYPES, ) from cleveragents.domain.models.core.container_lifecycle import ( ContainerLifecycleState, ContainerLifecycleTracker, ) from cleveragents.resource.handlers.clone_into import ( CloneIntoError, clone_repo_into_container, validate_clone_into_url, ) from cleveragents.resource.handlers.devcontainer import ( DevcontainerHandler, clear_lifecycle_registry, set_lifecycle_tracker, ) from cleveragents.domain.models.core.resource import Resource # ── When steps ─────────────────────────────────────────────── @when('I validate clone-into URL "{url}"') def step_validate_url(context: Context, url: str) -> None: """Validate a clone-into URL.""" context.clone_url_error = None try: validate_clone_into_url(url) context.clone_url_valid = True except ValueError as exc: context.clone_url_valid = False context.clone_url_error = exc @when("I validate clone-into URL with whitespace only") def step_validate_whitespace_url(context: Context) -> None: """Validate a whitespace-only clone-into URL.""" context.clone_url_error = None try: validate_clone_into_url(" ") context.clone_url_valid = True except ValueError as exc: context.clone_url_valid = False context.clone_url_error = exc @when("I call clone_repo_into_container with empty container_id") def step_clone_empty_container_id(context: Context) -> None: """Call clone_repo_into_container with an empty container_id.""" context.clone_error = None try: clone_repo_into_container("", "https://github.com/org/repo.git") except ValueError as exc: context.clone_error = exc @when("I call clone_repo_into_container with empty repo_url") def step_clone_empty_repo_url(context: Context) -> None: """Call clone_repo_into_container with an empty repo_url.""" context.clone_error = None try: clone_repo_into_container("abc123def456", "") except ValueError as exc: context.clone_error = exc @when('I clone "{repo_url}" into container "{container_id}"') def step_clone_into_container( context: Context, repo_url: str, container_id: str ) -> None: """Clone a repository into a container.""" context.clone_error = None context.clone_result = None try: context.clone_result = clone_repo_into_container(container_id, repo_url) except (CloneIntoError, ValueError) as exc: context.clone_error = exc @when( 'I clone repo "{repo_url}" into container "{container_id}" with target "{target_dir}"' ) def step_clone_into_container_at( context: Context, repo_url: str, container_id: str, target_dir: str ) -> None: """Clone a repository into a container at a specific target directory.""" context.clone_error = None context.clone_result = None try: context.clone_result = clone_repo_into_container( container_id, repo_url, target_dir ) except (CloneIntoError, ValueError) as exc: context.clone_error = exc @when('I look up the "{type_name}" resource type spec') def step_look_up_resource_type_spec(context: Context, type_name: str) -> None: """Look up a resource type spec from BUILTIN_TYPES.""" context.resource_type_spec = None for entry in BUILTIN_TYPES: if entry.get("name") == type_name: context.resource_type_spec = entry break # ── Given steps ────────────────────────────────────────────── @given("a mock docker exec that succeeds for clone") def step_mock_docker_exec_success(context: Context) -> None: """Patch subprocess.run to simulate a successful docker exec git clone.""" mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "" mock_result.stderr = "" patcher = patch( "cleveragents.resource.handlers.clone_into.subprocess.run", return_value=mock_result, ) patcher.start() context.add_cleanup(patcher.stop) @given('a mock docker exec that fails for clone with stderr "{stderr_msg}"') def step_mock_docker_exec_failure(context: Context, stderr_msg: str) -> None: """Patch subprocess.run to simulate a failed docker exec git clone.""" mock_result = MagicMock() mock_result.returncode = 1 mock_result.stdout = "" mock_result.stderr = stderr_msg patcher = patch( "cleveragents.resource.handlers.clone_into.subprocess.run", return_value=mock_result, ) patcher.start() context.add_cleanup(patcher.stop) @given( 'a CloneIntoError with repo "{repo_url}" container "{container_id}" stderr "{stderr}"' ) def step_create_clone_into_error( context: Context, repo_url: str, container_id: str, stderr: str ) -> None: """Create a CloneIntoError instance for attribute testing.""" context.clone_into_error = CloneIntoError( repo_url=repo_url, container_id=container_id, stderr=stderr, ) # ── Then steps ─────────────────────────────────────────────── @then("the clone-into URL should be valid") def step_url_should_be_valid(context: Context) -> None: assert context.clone_url_valid is True, ( "Expected URL to be valid but validate_clone_into_url returned False" ) @then("the clone-into URL should be invalid") def step_url_should_be_invalid(context: Context) -> None: assert context.clone_url_valid is False, ( "Expected URL to be invalid but validate_clone_into_url returned True" ) @then('it should raise ValueError mentioning "{field}"') def step_should_raise_value_error(context: Context, field: str) -> None: assert context.clone_error is not None, ( "Expected a ValueError but no error was raised" ) assert isinstance(context.clone_error, ValueError), ( f"Expected ValueError, got {type(context.clone_error).__name__}" ) assert field.lower() in str(context.clone_error).lower(), ( f"Expected '{field}' in error message, got: {context.clone_error!r}" ) @then("the clone should succeed") def step_clone_should_succeed(context: Context) -> None: assert context.clone_error is None, ( f"Expected clone to succeed but got error: {context.clone_error!r}" ) assert context.clone_result is not None, "Expected a clone result but got None" @then('the clone target should be "{expected_target}"') def step_clone_target(context: Context, expected_target: str) -> None: assert context.clone_result == expected_target, ( f"Expected clone target '{expected_target}', got '{context.clone_result}'" ) @then("the clone should raise CloneIntoError") def step_clone_should_raise(context: Context) -> None: assert context.clone_error is not None, ( "Expected CloneIntoError but no error was raised" ) assert isinstance(context.clone_error, CloneIntoError), ( f"Expected CloneIntoError, got {type(context.clone_error).__name__}" ) @then('the CloneIntoError should mention "{text}"') def step_clone_error_mentions(context: Context, text: str) -> None: error_str = str(context.clone_error) assert text.lower() in error_str.lower(), ( f"Expected '{text}' in CloneIntoError message, got: {error_str!r}" ) @then('the CloneIntoError repo_url should be "{expected}"') def step_clone_error_repo_url(context: Context, expected: str) -> None: assert context.clone_into_error.repo_url == expected, ( f"Expected repo_url '{expected}', got '{context.clone_into_error.repo_url}'" ) @then('the CloneIntoError container_id should be "{expected}"') def step_clone_error_container_id(context: Context, expected: str) -> None: assert context.clone_into_error.container_id == expected, ( f"Expected container_id '{expected}', " f"got '{context.clone_into_error.container_id}'" ) @then('the CloneIntoError stderr should be "{expected}"') def step_clone_error_stderr(context: Context, expected: str) -> None: assert context.clone_into_error.stderr == expected, ( f"Expected stderr '{expected}', got '{context.clone_into_error.stderr}'" ) @then('the spec should have a CLI argument named "{arg_name}"') def step_spec_has_cli_arg(context: Context, arg_name: str) -> None: spec = context.resource_type_spec assert spec is not None, "No resource type spec found" cli_args: list[dict[str, Any]] = spec.get("cli_args", []) arg_names = [arg.get("name") for arg in cli_args] assert arg_name in arg_names, f"Expected CLI argument '{arg_name}' in {arg_names}" @then('the clone-into argument should be of type "{expected_type}"') def step_clone_into_arg_type(context: Context, expected_type: str) -> None: spec = context.resource_type_spec assert spec is not None, "No resource type spec found" cli_args: list[dict[str, Any]] = spec.get("cli_args", []) clone_into_arg = next( (arg for arg in cli_args if arg.get("name") == "clone-into"), None ) assert clone_into_arg is not None, "clone-into argument not found" assert clone_into_arg.get("type") == expected_type, ( f"Expected type '{expected_type}', got '{clone_into_arg.get('type')}'" ) @then("the clone-into argument should not be required") def step_clone_into_not_required(context: Context) -> None: spec = context.resource_type_spec assert spec is not None, "No resource type spec found" cli_args: list[dict[str, Any]] = spec.get("cli_args", []) clone_into_arg = next( (arg for arg in cli_args if arg.get("name") == "clone-into"), None ) assert clone_into_arg is not None, "clone-into argument not found" assert clone_into_arg.get("required") is False, ( f"Expected clone-into to be optional (required=False), " f"got required={clone_into_arg.get('required')}" ) # -- End-to-end handler wiring steps -- @given( 'a devcontainer-instance resource with clone_into "{clone_url}" and tracker container_id "{container_id}"' ) def step_devcontainer_resource_with_clone_into( context: Context, clone_url: str, container_id: str ) -> None: """Set up a devcontainer-instance resource with clone_into property and tracker.""" # Clear any existing lifecycle state clear_lifecycle_registry() # Create a resource with clone_into in properties # resource_id must be a valid ULID (Crockford Base32, 26 chars, no I/L/O/U) # classification must be 'physical' or 'virtual' context.handler_resource = Resource( resource_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", name="test-devcontainer", resource_type_name="devcontainer-instance", classification="physical", description="Test devcontainer for clone-into wiring", location="/tmp/test-workspace", properties={"clone_into": clone_url}, ) # Set up the lifecycle tracker in DISCOVERED state with container_id pre-set # (simulates the state after activate_container() has run and set container_id) tracker = ContainerLifecycleTracker( resource_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", current_state=ContainerLifecycleState.DISCOVERED, container_id=container_id, ) set_lifecycle_tracker(tracker) context.handler_clone_url = clone_url context.handler_container_id = container_id # Store cleanup reference context.add_cleanup(clear_lifecycle_registry) @given("clone_repo_into_container is mocked to succeed") def step_mock_clone_repo_into_container(context: Context) -> None: """Mock clone_repo_into_container to capture calls without running docker.""" mock_clone = MagicMock(return_value="/workspace") patcher = patch( "cleveragents.resource.handlers.devcontainer.clone_repo_into_container", mock_clone, ) patcher.start() context.mock_clone_repo = mock_clone context.add_cleanup(patcher.stop) @when("DevcontainerHandler.resolve() is called for the resource") def step_call_devcontainer_handler_resolve(context: Context) -> None: """Call DevcontainerHandler.resolve() with the test resource.""" handler = DevcontainerHandler() mock_sandbox_manager = MagicMock() mock_bound_resource = MagicMock() # Patch activate_container to be a no-op (tracker already has container_id) # Patch BaseResourceHandler.resolve to return a mock BoundResource with ( patch("cleveragents.resource.handlers.devcontainer.activate_container"), patch( "cleveragents.resource.handlers._base.BaseResourceHandler.resolve", return_value=mock_bound_resource, ), ): context.handler_resolve_error = None context.handler_resolve_result = None try: context.handler_resolve_result = handler.resolve( resource=context.handler_resource, plan_id="test-plan-001", slot_name="test-slot", sandbox_manager=mock_sandbox_manager, ) except Exception as exc: context.handler_resolve_error = exc @then( 'clone_repo_into_container should have been called with container_id "{expected_container_id}" and url "{expected_url}"' ) def step_assert_clone_called( context: Context, expected_container_id: str, expected_url: str ) -> None: """Assert that clone_repo_into_container was called with the expected arguments.""" assert context.handler_resolve_error is None, ( f"Expected resolve() to succeed but got error: {context.handler_resolve_error!r}" ) mock_clone = context.mock_clone_repo assert mock_clone.called, ( "Expected clone_repo_into_container to have been called, but it was not" ) call_args = mock_clone.call_args actual_container_id = ( call_args[0][0] if call_args[0] else call_args[1].get("container_id") ) actual_url = ( call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("repo_url") ) assert actual_container_id == expected_container_id, ( f"Expected container_id '{expected_container_id}', got '{actual_container_id}'" ) assert actual_url == expected_url, ( f"Expected url '{expected_url}', got '{actual_url}'" )