"""Step definitions for devcontainer_handler_protocol_methods.feature. Exercises the four new protocol methods added to DevcontainerHandler by issue #1242: - delete() — devcontainer exec rm - list_children() — devcontainer exec ls (returns list[str]) - diff() — content hash comparison - create_sandbox() — container clone/snapshot via base-class delegation All subprocess.run calls are mocked via unittest.mock.patch. Step prefix: dcproto (devcontainer protocol methods). """ from __future__ import annotations import os import tempfile from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context from cleveragents.domain.models.core.container_lifecycle import ( ContainerLifecycleState, ContainerLifecycleTracker, ) from cleveragents.domain.models.core.resource import PhysVirt, Resource from cleveragents.resource.handlers.devcontainer import DevcontainerHandler # Valid 26-char Crockford Base32 ULID for test resources. # Crockford Base32 excludes I, L, O, U — use only [0-9A-HJKMNP-TV-Z]. _TEST_ULID = "01JTESTDCPRTM000000000000A" def _make_resource( location: str | None = "/ws/project", resource_id: str = _TEST_ULID, ) -> Resource: """Create a minimal devcontainer-instance Resource for testing.""" return Resource( resource_id=resource_id, name="test-devcontainer", resource_type_name="devcontainer-instance", classification=PhysVirt.PHYSICAL, description="Test devcontainer resource", location=location, ) # ── Given steps ────────────────────────────────────────────── @given("dcproto a devcontainer handler") def step_dcproto_create_handler(context: Context) -> None: context.dcproto_handler = DevcontainerHandler() @given('dcproto a devcontainer-instance resource with location "{loc}"') def step_dcproto_resource_with_location(context: Context, loc: str) -> None: context.dcproto_resource = _make_resource(location=loc) @given("dcproto a devcontainer-instance resource with no location") def step_dcproto_resource_no_location(context: Context) -> None: context.dcproto_resource = _make_resource(location=None) @given("dcproto subprocess returns successful rm output") def step_dcproto_subprocess_rm_success(context: Context) -> None: mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = b"" mock_result.stderr = b"" context.dcproto_mock_result = mock_result context.dcproto_mock_mode = "rm_success" @given('dcproto subprocess returns failed rm output with stderr "{stderr}"') def step_dcproto_subprocess_rm_failure(context: Context, stderr: str) -> None: mock_result = MagicMock() mock_result.returncode = 1 mock_result.stdout = b"" mock_result.stderr = stderr.encode("utf-8") context.dcproto_mock_result = mock_result context.dcproto_mock_mode = "rm_failure" @given("dcproto subprocess returns failed ls output") def step_dcproto_subprocess_ls_failure(context: Context) -> None: mock_result = MagicMock() mock_result.returncode = 1 mock_result.stdout = "" mock_result.stderr = "ls: cannot access" context.dcproto_mock_result = mock_result context.dcproto_mock_mode = "ls_failure" @given('dcproto subprocess returns successful ls listing "{text}"') def step_dcproto_subprocess_ls_listing(context: Context, text: str) -> None: """Configure mock for list_children which uses text=True.""" mock_result = MagicMock() mock_result.returncode = 0 raw = text.replace("\\n", "\n") mock_result.stdout = raw mock_result.stderr = "" context.dcproto_mock_result = mock_result context.dcproto_mock_mode = "ls_listing" @given('dcproto the container content hash is "{fake_hash}"') def step_dcproto_container_hash(context: Context, fake_hash: str) -> None: """Patch content_hash to return a fixed value for the container side.""" context.dcproto_container_hash = fake_hash @given("dcproto a temporary directory with a different file") def step_dcproto_temp_dir_different(context: Context) -> None: """Create a temp dir with a file so its hash differs from the container.""" tmpdir = tempfile.mkdtemp() context.add_cleanup(lambda: __import__("shutil").rmtree(tmpdir, ignore_errors=True)) with open(os.path.join(tmpdir, "different.txt"), "w") as f: f.write("different content that will produce a unique hash") context.dcproto_other_location = tmpdir @given("dcproto the container content hash matches the other location") def step_dcproto_hash_matches(context: Context) -> None: """Signal that we want the hashes to match (same location used for both).""" context.dcproto_hash_should_match = True @given("dcproto I diff the resource against the same location") def step_dcproto_same_location_setup(context: Context) -> None: """Use the resource's own location as other_location for a no-diff scenario.""" context.dcproto_other_location = context.dcproto_resource.location @given("dcproto the container is in RUNNING state") def step_dcproto_container_running(context: Context) -> None: from cleveragents.resource.handlers._devcontainer_internals import ( set_lifecycle_tracker, ) # Use model_copy to bypass transition validation for test setup. tracker = ContainerLifecycleTracker( resource_id=context.dcproto_resource.resource_id, ) tracker = tracker.model_copy( update={"current_state": ContainerLifecycleState.RUNNING} ) set_lifecycle_tracker(tracker) context.dcproto_container_state = ContainerLifecycleState.RUNNING @given("dcproto the container is in DETECTED state") def step_dcproto_container_detected(context: Context) -> None: from cleveragents.resource.handlers._devcontainer_internals import ( set_lifecycle_tracker, ) # DETECTED is the initial state — no transition needed. tracker = ContainerLifecycleTracker( resource_id=context.dcproto_resource.resource_id, ) set_lifecycle_tracker(tracker) context.dcproto_container_state = ContainerLifecycleState.DETECTED @given("dcproto the container is in STOPPED state") def step_dcproto_container_stopped(context: Context) -> None: from cleveragents.resource.handlers._devcontainer_internals import ( set_lifecycle_tracker, ) # Use model_copy to bypass transition validation for test setup. tracker = ContainerLifecycleTracker( resource_id=context.dcproto_resource.resource_id, ) tracker = tracker.model_copy( update={"current_state": ContainerLifecycleState.STOPPED} ) set_lifecycle_tracker(tracker) context.dcproto_container_state = ContainerLifecycleState.STOPPED @given("dcproto a mock sandbox manager that returns a valid sandbox") def step_dcproto_mock_sandbox_manager(context: Context) -> None: """Create a mock SandboxManager that returns a valid sandbox.""" mock_context = MagicMock() mock_context.sandbox_path = "/tmp/sandbox/test" mock_sandbox = MagicMock() mock_sandbox.sandbox_id = "sandbox-001" mock_sandbox.context = mock_context mock_manager = MagicMock() mock_manager.get_sandbox.return_value = None mock_manager.get_or_create_sandbox.return_value = mock_sandbox context.dcproto_sandbox_manager = mock_manager @given("dcproto activate_container is mocked to succeed") def step_dcproto_mock_activate(context: Context) -> None: """Patch activate_container to avoid real subprocess calls.""" context.dcproto_activate_calls: list[tuple[object, ...]] = [] def _fake_activate(resource_id: str, location: str, *, session_id: str) -> None: context.dcproto_activate_calls.append((resource_id, location, session_id)) patcher = patch( "cleveragents.resource.handlers.devcontainer.activate_container", side_effect=_fake_activate, ) patcher.start() context.add_cleanup(patcher.stop) # ── When steps ─────────────────────────────────────────────── def _do_delete(context: Context, path: str) -> None: """Shared implementation for delete steps.""" context.dcproto_error = None context.dcproto_delete_result = None context.dcproto_subprocess_calls = [] if not hasattr(context, "dcproto_mock_result"): try: context.dcproto_delete_result = context.dcproto_handler.delete( resource=context.dcproto_resource, path=path, ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc return def _capture_run(*args: object, **kwargs: object) -> object: context.dcproto_subprocess_calls.append((args, kwargs)) return context.dcproto_mock_result patcher = patch( "cleveragents.resource.handlers.devcontainer.subprocess.run", side_effect=_capture_run, ) patcher.start() context.add_cleanup(patcher.stop) try: context.dcproto_delete_result = context.dcproto_handler.delete( resource=context.dcproto_resource, path=path, ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc @when('dcproto I delete path "{path}" from the resource') def step_dcproto_delete_path(context: Context, path: str) -> None: _do_delete(context, path) @when("dcproto I delete with empty path from the resource") def step_dcproto_delete_empty_path(context: Context) -> None: _do_delete(context, "") @when("dcproto I list children of the resource") def step_dcproto_list_children(context: Context) -> None: context.dcproto_error = None context.dcproto_list_result = None context.dcproto_subprocess_calls = [] if not hasattr(context, "dcproto_mock_result"): try: context.dcproto_list_result = context.dcproto_handler.list_children( resource=context.dcproto_resource, ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc return def _capture_run(*args: object, **kwargs: object) -> object: context.dcproto_subprocess_calls.append((args, kwargs)) return context.dcproto_mock_result patcher = patch( "cleveragents.resource.handlers.devcontainer.subprocess.run", side_effect=_capture_run, ) patcher.start() context.add_cleanup(patcher.stop) try: context.dcproto_list_result = context.dcproto_handler.list_children( resource=context.dcproto_resource, ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc @when("dcproto I diff the resource against the temporary directory") def step_dcproto_diff_against_temp(context: Context) -> None: context.dcproto_error = None context.dcproto_diff_result = None # Patch content_hash to return the configured fake hash for the container if hasattr(context, "dcproto_container_hash"): fake_hash = context.dcproto_container_hash def _fake_content_hash(resource: Resource, *, algorithm: str = "sha256") -> str: # Only intercept for the resource under test (not the other_location) if resource.location == context.dcproto_resource.location: return fake_hash # Fall through to real implementation for other_location from cleveragents.resource.handlers._base import BaseResourceHandler return BaseResourceHandler.content_hash( context.dcproto_handler, resource, algorithm=algorithm ) patcher = patch.object( context.dcproto_handler, "content_hash", side_effect=_fake_content_hash, ) patcher.start() context.add_cleanup(patcher.stop) try: context.dcproto_diff_result = context.dcproto_handler.diff( resource=context.dcproto_resource, other_location=context.dcproto_other_location, ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc @when('dcproto I diff the resource against "/another/nonexistent/path"') def step_dcproto_diff_both_absent(context: Context) -> None: context.dcproto_error = None context.dcproto_diff_result = None try: context.dcproto_diff_result = context.dcproto_handler.diff( resource=context.dcproto_resource, other_location="/another/nonexistent/path", ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc @when('dcproto I diff the resource against "/some/path"') def step_dcproto_diff_no_location(context: Context) -> None: context.dcproto_error = None context.dcproto_diff_result = None try: context.dcproto_diff_result = context.dcproto_handler.diff( resource=context.dcproto_resource, other_location="/some/path", ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc @when("dcproto I diff the resource against the same location") def step_dcproto_diff_same_location(context: Context) -> None: context.dcproto_error = None context.dcproto_diff_result = None # Use the resource's own location as other_location other = context.dcproto_resource.location or "/ws/project" try: context.dcproto_diff_result = context.dcproto_handler.diff( resource=context.dcproto_resource, other_location=other, ) except (ValueError, NotImplementedError) as exc: context.dcproto_error = exc @when('dcproto I create a sandbox for the resource with plan "{plan_id}"') def step_dcproto_create_sandbox(context: Context, plan_id: str) -> None: context.dcproto_error = None context.dcproto_sandbox_result = None try: context.dcproto_sandbox_result = context.dcproto_handler.create_sandbox( resource=context.dcproto_resource, plan_id=plan_id, sandbox_manager=context.dcproto_sandbox_manager, ) except (ValueError, RuntimeError, NotImplementedError) as exc: context.dcproto_error = exc # ── Then steps ─────────────────────────────────────────────── @then("dcproto the delete should succeed") def step_dcproto_delete_success(context: Context) -> None: assert context.dcproto_error is None, ( f"Expected no error, got {context.dcproto_error!r}" ) assert context.dcproto_delete_result is not None, "No delete result returned" assert context.dcproto_delete_result.success is True, ( f"Expected success, got failure: {context.dcproto_delete_result.message}" ) @then('dcproto the delete message should contain "{text}"') def step_dcproto_delete_msg_contains(context: Context, text: str) -> None: assert context.dcproto_delete_result is not None assert text in context.dcproto_delete_result.message, ( f"Expected '{text}' in message: {context.dcproto_delete_result.message}" ) @then("dcproto the delete should fail") def step_dcproto_delete_failure(context: Context) -> None: assert context.dcproto_error is None, ( f"Unexpected exception: {context.dcproto_error!r}" ) assert context.dcproto_delete_result is not None, "No delete result returned" assert context.dcproto_delete_result.success is False, ( "Expected delete to fail but it succeeded" ) @then('dcproto the delete failure message should contain "{text}"') def step_dcproto_delete_fail_msg(context: Context, text: str) -> None: assert context.dcproto_delete_result is not None assert context.dcproto_delete_result.success is False assert text in context.dcproto_delete_result.message, ( f"Expected '{text}' in failure message: {context.dcproto_delete_result.message}" ) @then('dcproto the delete should raise ValueError containing "{text}"') def step_dcproto_delete_value_error(context: Context, text: str) -> None: assert context.dcproto_error is not None, "Expected ValueError" assert isinstance(context.dcproto_error, ValueError), ( f"Expected ValueError, got {type(context.dcproto_error).__name__}" ) assert text in str(context.dcproto_error), ( f"Expected '{text}' in error message: {context.dcproto_error}" ) @then('dcproto the subprocess should have been called with "{cmd}" and "{arg}"') def step_dcproto_check_subprocess_args(context: Context, cmd: str, arg: str) -> None: assert len(context.dcproto_subprocess_calls) > 0, "No subprocess calls recorded" call_args, _call_kwargs = context.dcproto_subprocess_calls[0] cmd_list = call_args[0] assert cmd in cmd_list, f"Expected '{cmd}' in command {cmd_list}" assert arg in cmd_list, f"Expected '{arg}' in command {cmd_list}" @then("dcproto the children list should have {count:d} entries") def step_dcproto_children_count(context: Context, count: int) -> None: assert context.dcproto_error is None, ( f"Expected no error, got {context.dcproto_error!r}" ) assert context.dcproto_list_result is not None, "No list_children result" assert len(context.dcproto_list_result) == count, ( f"Expected {count} children, got {len(context.dcproto_list_result)}: " f"{context.dcproto_list_result}" ) @then('dcproto the children list should be "{expected_csv}"') def step_dcproto_children_list_values(context: Context, expected_csv: str) -> None: expected = expected_csv.split(",") assert context.dcproto_list_result == expected, ( f"Expected {expected}, got {context.dcproto_list_result}" ) @then('dcproto the list children should raise ValueError containing "{text}"') def step_dcproto_list_value_error(context: Context, text: str) -> None: assert context.dcproto_error is not None, "Expected ValueError" assert isinstance(context.dcproto_error, ValueError), ( f"Expected ValueError, got {type(context.dcproto_error).__name__}" ) assert text in str(context.dcproto_error), ( f"Expected '{text}' in error message: {context.dcproto_error}" ) @then("dcproto the diff should report has_changes true") def step_dcproto_diff_has_changes_true(context: Context) -> None: assert context.dcproto_error is None, ( f"Expected no error, got {context.dcproto_error!r}" ) assert context.dcproto_diff_result is not None, "No diff result returned" assert context.dcproto_diff_result.has_changes is True, ( "Expected has_changes=True but got False" ) @then("dcproto the diff should report has_changes false") def step_dcproto_diff_has_changes_false(context: Context) -> None: assert context.dcproto_error is None, ( f"Expected no error, got {context.dcproto_error!r}" ) assert context.dcproto_diff_result is not None, "No diff result returned" assert context.dcproto_diff_result.has_changes is False, ( "Expected has_changes=False but got True" ) @then("dcproto the diff files_changed should be {count:d}") def step_dcproto_diff_files_changed(context: Context, count: int) -> None: assert context.dcproto_diff_result is not None assert context.dcproto_diff_result.files_changed == count, ( f"Expected files_changed={count}, got {context.dcproto_diff_result.files_changed}" ) @then("dcproto the diff unified_diff should be empty") def step_dcproto_diff_unified_empty(context: Context) -> None: assert context.dcproto_diff_result is not None assert context.dcproto_diff_result.unified_diff == "", ( f"Expected empty unified_diff, got: {context.dcproto_diff_result.unified_diff!r}" ) @then('dcproto the diff should raise ValueError containing "{text}"') def step_dcproto_diff_value_error(context: Context, text: str) -> None: assert context.dcproto_error is not None, "Expected ValueError" assert isinstance(context.dcproto_error, ValueError), ( f"Expected ValueError, got {type(context.dcproto_error).__name__}" ) assert text in str(context.dcproto_error), ( f"Expected '{text}' in error message: {context.dcproto_error}" ) @then('dcproto the sandbox result should have strategy "{strategy}"') def step_dcproto_sandbox_strategy(context: Context, strategy: str) -> None: assert context.dcproto_error is None, ( f"Expected no error, got {context.dcproto_error!r}" ) assert context.dcproto_sandbox_result is not None, "No sandbox result returned" assert context.dcproto_sandbox_result.strategy == strategy, ( f"Expected strategy '{strategy}', got '{context.dcproto_sandbox_result.strategy}'" ) @then("dcproto the sandbox result should have a sandbox path") def step_dcproto_sandbox_has_path(context: Context) -> None: assert context.dcproto_sandbox_result is not None assert context.dcproto_sandbox_result.sandbox_path, ( "Expected a non-empty sandbox_path" ) @then("dcproto activate_container should have been called") def step_dcproto_activate_was_called(context: Context) -> None: assert context.dcproto_error is None, ( f"Expected no error, got {context.dcproto_error!r}" ) assert hasattr(context, "dcproto_activate_calls"), ( "activate_container mock was not set up" ) assert len(context.dcproto_activate_calls) > 0, ( "Expected activate_container to be called but it was not" ) @then('dcproto the create_sandbox should raise ValueError containing "{text}"') def step_dcproto_sandbox_value_error(context: Context, text: str) -> None: assert context.dcproto_error is not None, "Expected ValueError" assert isinstance(context.dcproto_error, ValueError), ( f"Expected ValueError, got {type(context.dcproto_error).__name__}" ) assert text in str(context.dcproto_error), ( f"Expected '{text}' in error message: {context.dcproto_error}" )