"""Step definitions for session cleanup, CLI stop/rebuild, and registry. Covers: session cleanup (stop_all_active_containers), CleanupService integration, CLI stop/rebuild commands (F6), CLI mock assertions (R7), DevcontainerHandler class coverage (R8), CLI state precondition validation (R12), container-instance CLI stop (F5), session-scoped cleanup (R7-F1), auto health check on activation (R7-F3), terminal tracker eviction wired to cleanup (R8-F1), session-close facade (R7-F4), NotFoundError paths, and RuntimeError paths. All mocks are in ``features/mocks/mock_devcontainer_cli.py``. """ from __future__ import annotations from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context from typer.testing import CliRunner from cleveragents.a2a.facade import A2aLocalFacade from cleveragents.a2a.models import A2aRequest from cleveragents.application.services.cleanup_service import CleanupService from cleveragents.cli.commands.resource import app as resource_app from cleveragents.core.exceptions import NotFoundError from cleveragents.domain.models.core.container_lifecycle import ( ContainerLifecycleState, ContainerLifecycleTracker, ) from cleveragents.domain.models.core.resource import SandboxStrategy from cleveragents.resource.handlers._base import BaseResourceHandler from cleveragents.resource.handlers.devcontainer import ( DevcontainerHandler, clear_lifecycle_registry, list_active_containers_for_session, set_lifecycle_tracker, stop_all_active_containers, ) # ── CLI patch targets ──────────────────────────────────────── _CLI_PATCH_SERVICE = "cleveragents.cli.commands.resource._get_registry_service" _CLI_PATCH_STOP = "cleveragents.cli.commands.resource.stop_container" _CLI_PATCH_REBUILD = "cleveragents.cli.commands.resource.rebuild_container" # ── Helpers ────────────────────────────────────────────────── def _make_mock_resource( *, resource_id: str, name: str, resource_type_name: str, location: str | None = None, properties: dict[str, object] | None = None, ) -> MagicMock: """Create a mock resource object matching the CLI expectations.""" res = MagicMock() res.resource_id = resource_id res.name = name res.resource_type_name = resource_type_name res.location = location res.properties = properties return res # ── Given steps ────────────────────────────────────────────── @given( 'a mock resource service with devcontainer "{name}" ' 'id "{resource_id}" at "{location}"' ) def step_mock_service_devcontainer( context: Context, name: str, resource_id: str, location: str ) -> None: """Set up a mock registry service returning a devcontainer resource.""" mock_service = MagicMock() loc = location if location else None mock_resource = _make_mock_resource( resource_id=resource_id, name=name, resource_type_name="devcontainer-instance", location=loc, ) mock_service.show_resource.return_value = mock_resource context.cli_mock_service = mock_service context.cli_mock_resource = mock_resource @given( 'a mock resource service with devcontainer "{name}" ' 'id "{resource_id}" with no location' ) def step_mock_service_devcontainer_no_loc( context: Context, name: str, resource_id: str ) -> None: """Set up a mock registry service returning a devcontainer with no location.""" mock_service = MagicMock() mock_resource = _make_mock_resource( resource_id=resource_id, name=name, resource_type_name="devcontainer-instance", location=None, properties=None, ) mock_service.show_resource.return_value = mock_resource context.cli_mock_service = mock_service context.cli_mock_resource = mock_resource @given('a mock resource service with git-checkout "{name}" id "{resource_id}"') def step_mock_service_git_checkout( context: Context, name: str, resource_id: str ) -> None: """Set up a mock registry service returning a git-checkout resource.""" mock_service = MagicMock() mock_resource = _make_mock_resource( resource_id=resource_id, name=name, resource_type_name="git-checkout", ) mock_service.show_resource.return_value = mock_resource context.cli_mock_service = mock_service context.cli_mock_resource = mock_resource @given( 'a mock resource service with container-instance "{name}" ' 'id "{resource_id}" at "{location}"' ) def step_mock_service_container_instance( context: Context, name: str, resource_id: str, location: str ) -> None: """Set up a mock registry service returning a container-instance resource.""" mock_service = MagicMock() mock_resource = _make_mock_resource( resource_id=resource_id, name=name, resource_type_name="container-instance", location=location if location else None, ) mock_service.show_resource.return_value = mock_resource context.cli_mock_service = mock_service context.cli_mock_resource = mock_resource @given('a mock resource service that raises NotFoundError for "{name}"') def step_mock_service_not_found(context: Context, name: str) -> None: """Set up a mock registry service that raises NotFoundError on show_resource.""" mock_service = MagicMock() mock_service.show_resource.side_effect = NotFoundError( message=f"Resource '{name}' not found" ) context.cli_mock_service = mock_service @given('a CLI-mockable running devcontainer resource "{name}"') def step_cli_mockable_running(context: Context, name: str) -> None: """Set up mock service + lifecycle tracker in running state for CLI tests.""" resource_id = "01TESTLIFECYCLE0000000400" mock_service = MagicMock() mock_resource = _make_mock_resource( resource_id=resource_id, name=name, resource_type_name="devcontainer-instance", location="/workspace/project", ) mock_service.show_resource.return_value = mock_resource context.cli_mock_service = mock_service context.cli_mock_resource = mock_resource # Set up lifecycle tracker in running state tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState.RUNNING, container_id="ctr-a2-stop", workspace_path="/workspaces/project", ) set_lifecycle_tracker(tracker) @given('a CLI-mockable stopped devcontainer resource "{name}"') def step_cli_mockable_stopped(context: Context, name: str) -> None: """Set up mock service + lifecycle tracker in stopped state for CLI tests.""" resource_id = "01TESTLIFECYCLE0000000401" mock_service = MagicMock() mock_resource = _make_mock_resource( resource_id=resource_id, name=name, resource_type_name="devcontainer-instance", location="/workspace/project", ) mock_service.show_resource.return_value = mock_resource context.cli_mock_service = mock_service context.cli_mock_resource = mock_resource # Set up lifecycle tracker in stopped state tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState.STOPPED, ) set_lifecycle_tracker(tracker) @given("CLI stop_container mock that raises RuntimeError") def step_cli_stop_mock_raises(context: Context) -> None: """Mark context so the CLI stop invocation patches stop_container to raise.""" context.cli_stop_raises = True @given("CLI rebuild_container mock that raises RuntimeError") def step_cli_rebuild_mock_raises(context: Context) -> None: """Mark context so the CLI rebuild invocation patches rebuild_container to raise.""" context.cli_rebuild_raises = True @given( 'a session-scoped active container "{resource_id}" ' 'with ID "{ctr_id}" session "{sess_id}"' ) def step_create_active_container_with_session( context: Context, resource_id: str, ctr_id: str, sess_id: str ) -> None: tracker = ContainerLifecycleTracker( resource_id=resource_id, session_id=sess_id, current_state=ContainerLifecycleState.RUNNING, container_id=ctr_id, workspace_path="/workspaces/project", ) set_lifecycle_tracker(tracker) @given( 'a session-scoped active container "{resource_id}" ' 'with no docker ID session "{sess_id}"' ) def step_create_active_container_no_docker_with_session( context: Context, resource_id: str, sess_id: str ) -> None: """Active container with no container_id -- docker stop is skipped.""" tracker = ContainerLifecycleTracker( resource_id=resource_id, session_id=sess_id, current_state=ContainerLifecycleState.RUNNING, container_id=None, workspace_path="/workspaces/project", ) set_lifecycle_tracker(tracker) @given("a facade with no session service") def step_create_facade_no_session(context: Context) -> None: context.facade = A2aLocalFacade(services={}) # ── When steps ─────────────────────────────────────────────── @when('I run session cleanup for session "{session_id}"') def step_session_cleanup(context: Context, session_id: str) -> None: context.stopped_ids = stop_all_active_containers( run_command=context.mock_runner, session_id=session_id, ) @when("I run session cleanup with no session filter") def step_session_cleanup_no_filter(context: Context) -> None: context.stopped_ids = stop_all_active_containers( run_command=context.mock_runner, session_id="", ) @when("I call CleanupService stop_active_devcontainers with no active containers") def step_call_cleanup_stop_empty(context: Context) -> None: clear_lifecycle_registry() context.cleanup_stopped = CleanupService.stop_active_devcontainers( session_id="test-session", ) @when('I invoke CLI resource stop "{name}"') def step_invoke_cli_stop(context: Context, name: str) -> None: """Invoke ``agents resource stop --yes`` via CliRunner.""" runner = CliRunner() with ( patch(_CLI_PATCH_SERVICE, return_value=context.cli_mock_service), patch(_CLI_PATCH_STOP) as mock_stop, ): # A2 fix: pass --yes to skip confirmation prompt in tests context.cli_result = runner.invoke(resource_app, ["stop", name, "--yes"]) context.cli_mock_stop = mock_stop # Set context.output for compatibility with shared CLI assertion steps context.output = context.cli_result.output @when('I invoke CLI resource rebuild "{name}"') def step_invoke_cli_rebuild(context: Context, name: str) -> None: """Invoke ``agents resource rebuild --yes`` via CliRunner.""" runner = CliRunner() with ( patch(_CLI_PATCH_SERVICE, return_value=context.cli_mock_service), patch(_CLI_PATCH_REBUILD) as mock_rebuild, ): # A2 fix: pass --yes to skip confirmation prompt in tests context.cli_result = runner.invoke(resource_app, ["rebuild", name, "--yes"]) context.cli_mock_rebuild = mock_rebuild # Set context.output for compatibility with shared CLI assertion steps context.output = context.cli_result.output @when('I invoke CLI resource stop with error mock "{name}"') def step_invoke_cli_stop_error(context: Context, name: str) -> None: """Invoke ``agents resource stop --yes`` with stop_container mocked to raise.""" runner = CliRunner() with ( patch(_CLI_PATCH_SERVICE, return_value=context.cli_mock_service), patch(_CLI_PATCH_STOP, side_effect=RuntimeError("docker daemon unavailable")), ): context.cli_result = runner.invoke(resource_app, ["stop", name, "--yes"]) context.output = context.cli_result.output @when('I invoke CLI resource rebuild with error mock "{name}"') def step_invoke_cli_rebuild_error(context: Context, name: str) -> None: """Invoke ``agents resource rebuild --yes`` with rebuild_container mocked.""" runner = CliRunner() with ( patch(_CLI_PATCH_SERVICE, return_value=context.cli_mock_service), patch(_CLI_PATCH_REBUILD, side_effect=RuntimeError("rebuild exploded")), ): context.cli_result = runner.invoke(resource_app, ["rebuild", name, "--yes"]) context.output = context.cli_result.output @when('I list active containers for session "{session_id}"') def step_list_for_session(context: Context, session_id: str) -> None: context.session_container_list = list_active_containers_for_session(session_id) @when("I list active containers for an empty session") def step_list_for_empty_session(context: Context) -> None: context.session_container_list = list_active_containers_for_session("") @when('I close session "{session_id}" via the facade') def step_close_session_via_facade(context: Context, session_id: str) -> None: request = A2aRequest( operation="session.close", params={"session_id": session_id}, ) context.facade_response = context.facade.dispatch(request) # ── Then steps ─────────────────────────────────────────────── @then("CleanupService should have a stop_active_devcontainers method") def step_check_cleanup_method(context: Context) -> None: assert hasattr(CleanupService, "stop_active_devcontainers") assert callable(CleanupService.stop_active_devcontainers) @then("the cleanup result should be an empty list") def step_check_cleanup_empty(context: Context) -> None: assert context.cleanup_stopped == [] @then("the cleanup should return 0 stopped containers") def step_check_cleanup_zero(context: Context) -> None: assert len(context.stopped_ids) == 0 @then('the cleanup stopped list should contain "{resource_id}"') def step_check_cleanup_list_contains(context: Context, resource_id: str) -> None: assert resource_id in context.stopped_ids, ( f"Expected {resource_id} in stopped list, got {context.stopped_ids}" ) @then("the devcontainer CLI exit code should be non-zero") def step_dc_cli_exit_nonzero(context: Context) -> None: assert context.cli_result.exit_code != 0, ( f"Expected non-zero exit code, got {context.cli_result.exit_code}.\n" f"Output: {context.cli_result.output}" ) @then("the devcontainer CLI exit code should be 0") def step_dc_cli_exit_zero(context: Context) -> None: assert context.cli_result.exit_code == 0, ( f"Expected exit code 0, got {context.cli_result.exit_code}.\n" f"Output: {context.cli_result.output}" ) @then('the CLI stop mock should have been called with "{resource_id}"') def step_check_cli_stop_mock_args(context: Context, resource_id: str) -> None: context.cli_mock_stop.assert_called_once_with(resource_id) @then( 'the CLI rebuild mock should have been called with "{resource_id}" and "{location}"' ) def step_check_cli_rebuild_mock_args( context: Context, resource_id: str, location: str ) -> None: context.cli_mock_rebuild.assert_called_once_with(resource_id, location) @then("the CLI stop mock should have been called once") def step_check_cli_stop_called_once(context: Context) -> None: context.cli_mock_stop.assert_called_once() @then("the CLI rebuild mock should have been called once") def step_check_cli_rebuild_called_once(context: Context) -> None: context.cli_mock_rebuild.assert_called_once() @then('DevcontainerHandler should have _default_strategy "{strategy}"') def step_check_handler_strategy(context: Context, strategy: str) -> None: assert DevcontainerHandler._default_strategy == SandboxStrategy(strategy) @then('DevcontainerHandler should have _type_label "{label}"') def step_check_handler_label(context: Context, label: str) -> None: assert DevcontainerHandler._type_label == label @then("DevcontainerHandler should be a subclass of BaseResourceHandler") def step_check_handler_inheritance(context: Context) -> None: assert issubclass(DevcontainerHandler, BaseResourceHandler) @then("DevcontainerHandler should be instantiable") def step_check_handler_instantiable(context: Context) -> None: handler = DevcontainerHandler() assert handler is not None assert isinstance(handler, DevcontainerHandler) @then("DevcontainerHandler instance should have a resolve method") def step_check_handler_resolve_method(context: Context) -> None: handler = DevcontainerHandler() assert hasattr(handler, "resolve") assert callable(handler.resolve) @then('the session container list should contain "{resource_id}"') def step_check_session_list_contains(context: Context, resource_id: str) -> None: assert resource_id in context.session_container_list, ( f"Expected {resource_id} in session list, got {context.session_container_list}" ) @then('the session container list should not contain "{resource_id}"') def step_check_session_list_not_contains(context: Context, resource_id: str) -> None: assert resource_id not in context.session_container_list, ( f"Expected {resource_id} NOT in session list, " f"got {context.session_container_list}" )