"""Step definitions for devcontainer activation, stop, and rebuild. Covers: lazy activation (success/failure/timeout/orphan), manual stop, rebuild, concurrent activation and stop, argument validation for activate/stop/rebuild, container_id clearing, and early-return on terminal state. All mocks are in ``features/mocks/mock_devcontainer_cli.py``. """ from __future__ import annotations import json import subprocess from typing import cast from behave import given, then, when from behave.runner import Context from cleveragents.domain.models.core.container_lifecycle import ( ContainerLifecycleState, ContainerLifecycleTracker, ) from cleveragents.resource.handlers.devcontainer import ( activate_container, get_lifecycle_tracker, rebuild_container, set_lifecycle_tracker, stop_container, ) from features.mocks.mock_devcontainer_cli import ( MockDevcontainerRunner, MockProcessResult, ) # ── Given steps ────────────────────────────────────────────── @given("a mock devcontainer CLI runner") def step_create_mock_runner(context: Context) -> None: context.mock_runner = MockDevcontainerRunner() @given("the runner configured for successful activation") def step_configure_success(context: Context) -> None: context.mock_runner.set_up_result( container_id="aabbccddee0011223344", workspace="/workspaces/project", ) @given("the runner configured for failed activation") def step_configure_failure(context: Context) -> None: context.mock_runner.set_up_failure(stderr="container build failed") @given('an active container "{resource_id}" with container ID "{ctr_id}"') def step_create_active_container( context: Context, resource_id: str, ctr_id: str ) -> None: tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState.RUNNING, container_id=ctr_id, workspace_path="/workspaces/project", ) set_lifecycle_tracker(tracker) @given('a stopped container "{resource_id}"') def step_create_stopped_container(context: Context, resource_id: str) -> None: tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState.STOPPED, ) set_lifecycle_tracker(tracker) @given('an errored container "{resource_id}"') def step_create_errored_container(context: Context, resource_id: str) -> None: tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState.FAILED, ) set_lifecycle_tracker(tracker) @given('an active container with no container_id "{resource_id}"') def step_create_active_no_ctr_id(context: Context, resource_id: str) -> None: tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState.RUNNING, container_id=None, workspace_path="/workspaces/project", ) set_lifecycle_tracker(tracker) @given("the runner configured for activation with no container_id") def step_configure_activation_no_ctr_id(context: Context) -> None: """Configure devcontainer up to succeed but return no containerId.""" context.mock_runner.up_result = MockProcessResult( returncode=0, stdout=json.dumps({"outcome": "success"}), stderr="", ) @given("the runner configured for failed docker stop") def step_configure_stop_failure_rc(context: Context) -> None: """Configure docker stop to return a non-zero exit code (not raise).""" context.mock_runner.set_stop_result(returncode=1) @given("the runner configured to raise exception on stop") def step_configure_stop_exception(context: Context) -> None: """Replace mock_runner with a wrapper that raises on docker stop.""" inner = context.mock_runner def raising_runner(args: list[str], **kwargs: object) -> object: inner.calls.append((list(args), dict(kwargs))) if len(args) >= 2 and args[0] == "docker" and args[1] == "stop": raise OSError("docker daemon not running") return inner(args, **kwargs) context.mock_runner = cast(MockDevcontainerRunner, raising_runner) @given("the runner configured to raise generic exception on up") def step_configure_up_generic_exception(context: Context) -> None: """Replace mock_runner with a wrapper that raises on devcontainer up.""" inner = context.mock_runner def raising_runner(args: list[str], **kwargs: object) -> object: inner.calls.append((list(args), dict(kwargs))) if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up": raise OSError("network unreachable") return inner(args, **kwargs) context.mock_runner = cast(MockDevcontainerRunner, raising_runner) @given("a mock devcontainer CLI runner that times out") def step_create_timeout_runner(context: Context) -> None: """Create a runner that raises subprocess.TimeoutExpired.""" def _timeout_runner(args: list[str], **kwargs: object) -> None: raise subprocess.TimeoutExpired(cmd=args, timeout=300) context.mock_runner = _timeout_runner @given("a mock devcontainer CLI runner that times out with orphan containers") def step_create_timeout_orphan_runner(context: Context) -> None: """Create a runner that times out on devcontainer up but returns orphan container IDs on docker ps. """ call_log: list[tuple[list[str], dict[str, object]]] = [] def _runner(args: list[str], **kwargs: object) -> object: call_log.append((list(args), dict(kwargs))) if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up": raise subprocess.TimeoutExpired(cmd=args, timeout=300) if len(args) >= 2 and args[0] == "docker" and args[1] == "ps": return MockProcessResult(returncode=0, stdout="orphan123abc\n", stderr="") if len(args) >= 2 and args[0] == "docker" and args[1] == "stop": return MockProcessResult(returncode=0) return MockProcessResult(returncode=0) context.mock_runner = _runner context.orphan_call_log = call_log # ── When steps ─────────────────────────────────────────────── @when('I activate container "{resource_id}" at "{workspace}"') def step_activate(context: Context, resource_id: str, workspace: str) -> None: context.activation_error = None try: context.activated_tracker = activate_container( resource_id, workspace, run_command=context.mock_runner, ) except (RuntimeError, ValueError) as exc: context.activation_error = exc @when('I attempt to activate container "{resource_id}" at "{workspace}"') def step_attempt_activate(context: Context, resource_id: str, workspace: str) -> None: context.activation_error = None try: context.activated_tracker = activate_container( resource_id, workspace, run_command=getattr(context, "mock_runner", None), ) except (RuntimeError, ValueError) as exc: context.activation_error = exc @when('I stop container "{resource_id}"') def step_stop(context: Context, resource_id: str) -> None: context.stop_error = None try: stop_container( resource_id, run_command=context.mock_runner, ) except (RuntimeError, ValueError) as exc: context.stop_error = exc @when('I attempt to stop container "{resource_id}"') def step_attempt_stop(context: Context, resource_id: str) -> None: context.stop_error = None try: stop_container( resource_id, run_command=getattr(context, "mock_runner", None), ) except (RuntimeError, ValueError) as exc: context.stop_error = exc @when('I rebuild container "{resource_id}" at "{workspace}"') def step_rebuild(context: Context, resource_id: str, workspace: str) -> None: context.rebuild_error = None try: rebuild_container( resource_id, workspace, run_command=context.mock_runner, ) except (RuntimeError, ValueError) as exc: context.rebuild_error = exc @when('I call rebuild_container for "{resource_id}" at "{workspace}"') def step_rebuild_direct(context: Context, resource_id: str, workspace: str) -> None: context.rebuild_error = None try: rebuild_container( resource_id, workspace, run_command=context.mock_runner, ) except (RuntimeError, ValueError) as exc: context.rebuild_error = exc @when("I call rebuild_container with empty resource_id") def step_rebuild_empty_resource_id(context: Context) -> None: context.rebuild_error = None try: rebuild_container("", "/workspace") except (RuntimeError, ValueError) as exc: context.rebuild_error = exc @when("I call rebuild_container with empty workspace_folder") def step_rebuild_empty_workspace(context: Context) -> None: context.rebuild_error = None try: rebuild_container("01TESTLIFECYCLE0000000091", "") except (RuntimeError, ValueError) as exc: context.rebuild_error = exc @when("I attempt to activate a container with empty resource_id") def step_activate_empty_resource_id(context: Context) -> None: context.activation_error = None try: activate_container("", "/workspace") except (RuntimeError, ValueError) as exc: context.activation_error = exc @when("I attempt to activate a container with empty workspace_folder") def step_activate_empty_workspace(context: Context) -> None: context.activation_error = None try: activate_container("01TESTLIFECYCLE0000000080", "") except (RuntimeError, ValueError) as exc: context.activation_error = exc @when("I attempt to stop a container with empty resource_id") def step_stop_empty_resource_id(context: Context) -> None: context.stop_error = None try: stop_container("") except (RuntimeError, ValueError) as exc: context.stop_error = exc @when("I attempt to activate a container with relative workspace_folder") def step_activate_relative_workspace(context: Context) -> None: context.activation_error = None try: activate_container("01TESTLIFECYCLE0000000080", "relative/path") except (RuntimeError, ValueError) as exc: context.activation_error = exc @when("I attempt to activate the timed-out container") def step_activate_timeout(context: Context) -> None: context.activation_error = None try: activate_container( context.resource_id, "/workspace", run_command=context.mock_runner, ) except (RuntimeError, ValueError) as exc: context.activation_error = exc @when("I attempt to activate the timed-out container with orphan cleanup") def step_activate_timeout_orphan(context: Context) -> None: context.activation_error = None try: activate_container( context.resource_id, "/workspace", run_command=context.mock_runner, ) except (RuntimeError, ValueError) as exc: context.activation_error = exc @when('I stop container "{resource_id}" expecting early return') def step_stop_container_early_return(context: Context, resource_id: str) -> None: """Stop a container that is already in a terminal state.""" context.stop_result = stop_container( resource_id, run_command=context.mock_runner, ) # ── Then steps ─────────────────────────────────────────────── @then('the container state should be "{state}"') def step_check_container_state(context: Context, state: str) -> None: assert context.activated_tracker.current_state == ContainerLifecycleState(state) @then('the container ID should be "{ctr_id}"') def step_check_container_id(context: Context, ctr_id: str) -> None: assert context.activated_tracker.container_id == ctr_id @then('the workspace path should be "{ws_path}"') def step_check_workspace_path(context: Context, ws_path: str) -> None: assert context.activated_tracker.workspace_path == ws_path @then("the runner should have received a devcontainer up call") def step_check_up_call(context: Context) -> None: assert len(context.mock_runner.up_calls) > 0 @then("the activation should raise RuntimeError") def step_check_activation_runtime_error(context: Context) -> None: assert context.activation_error is not None assert isinstance(context.activation_error, RuntimeError) @then('the container state for "{resource_id}" should be "{state}"') def step_check_specific_state(context: Context, resource_id: str, state: str) -> None: tracker = get_lifecycle_tracker(resource_id) assert tracker.current_state == ContainerLifecycleState(state), ( f"Expected {state}, got {tracker.current_state.value}" ) @then("the runner should have received a docker stop call") def step_check_stop_call(context: Context) -> None: assert len(context.mock_runner.stop_calls) > 0 @then("the runner should not have received a docker stop call") def step_check_no_stop_call(context: Context) -> None: assert len(context.mock_runner.stop_calls) == 0, ( f"Expected 0 docker stop calls, got {len(context.mock_runner.stop_calls)}" ) @then('the activation should raise ValueError with "{keyword}"') def step_check_activation_value_error(context: Context, keyword: str) -> None: assert context.activation_error is not None assert isinstance(context.activation_error, ValueError) assert keyword in str(context.activation_error) @then('the stop should raise ValueError with "{keyword}"') def step_check_stop_value_error(context: Context, keyword: str) -> None: assert context.stop_error is not None assert isinstance(context.stop_error, ValueError) assert keyword in str(context.stop_error) @then("the stop should raise RuntimeError") def step_check_stop_runtime_error(context: Context) -> None: assert context.stop_error is not None assert isinstance(context.stop_error, RuntimeError) @then('the rebuild should raise ValueError with "{keyword}"') def step_check_rebuild_value_error(context: Context, keyword: str) -> None: assert context.rebuild_error is not None assert isinstance(context.rebuild_error, ValueError) assert keyword in str(context.rebuild_error) @then('the activation should raise RuntimeError with "{text}"') def step_check_runtime_error(context: Context, text: str) -> None: assert context.activation_error is not None, "Expected an error but none was raised" assert isinstance(context.activation_error, RuntimeError), ( f"Expected RuntimeError, got {type(context.activation_error).__name__}" ) assert text in str(context.activation_error), ( f"Expected '{text}' in error message, got '{context.activation_error}'" ) @then('the tracker "{resource_id}" should be in "{state}" state') def step_check_named_tracker_state( context: Context, resource_id: str, state: str ) -> None: tracker = get_lifecycle_tracker(resource_id) assert tracker.current_state == ContainerLifecycleState(state), ( f"Expected state '{state}', got '{tracker.current_state.value}'" ) @then('the container_id for "{resource_id}" should be None') def step_check_container_id_none(context: Context, resource_id: str) -> None: tracker = get_lifecycle_tracker(resource_id) assert tracker.container_id is None, ( f"Expected container_id None, got '{tracker.container_id}'" ) @then('the workspace_path for "{resource_id}" should be None') def step_check_workspace_path_none(context: Context, resource_id: str) -> None: tracker = get_lifecycle_tracker(resource_id) assert tracker.workspace_path is None, ( f"Expected workspace_path None, got '{tracker.workspace_path}'" ) @then('the host_workspace_path for "{resource_id}" should be "{path}"') def step_check_host_workspace_path( context: Context, resource_id: str, path: str ) -> None: tracker = get_lifecycle_tracker(resource_id) assert tracker.host_workspace_path == path, ( f"Expected host_workspace_path '{path}', got '{tracker.host_workspace_path}'" ) @then('the devcontainer up call should include "{flag}"') def step_check_up_call_includes_flag(context: Context, flag: str) -> None: up_calls = context.mock_runner.up_calls assert len(up_calls) > 0, "No devcontainer up calls recorded" args, _ = up_calls[-1] assert flag in args, f"Expected '{flag}' in {args}" @then('the devcontainer up call should not include "{flag}"') def step_check_up_call_excludes_flag(context: Context, flag: str) -> None: up_calls = context.mock_runner.up_calls assert len(up_calls) > 0, "No devcontainer up calls recorded" args, _ = up_calls[-1] assert flag not in args, f"Did not expect '{flag}' in {args}" @then("the runner should have attempted orphan container cleanup") def step_check_orphan_cleanup(context: Context) -> None: ps_calls = [ (a, k) for a, k in context.orphan_call_log if len(a) >= 2 and a[0] == "docker" and a[1] == "ps" ] assert len(ps_calls) >= 1, "Expected docker ps call for orphan lookup" stop_calls = [ (a, k) for a, k in context.orphan_call_log if len(a) >= 2 and a[0] == "docker" and a[1] == "stop" ] assert len(stop_calls) >= 1, "Expected docker stop call for orphan cleanup (F1 fix)"