Files
cleveragents-core/features/steps/devcontainer_cleanup_steps.py
T
freemo ec0b7631d0
CI / lint (push) Successful in 12s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 23s
CI / typecheck (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 46s
CI / unit_tests (push) Successful in 3m3s
CI / integration_tests (push) Successful in 3m31s
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 5m34s
CI / benchmark-publish (push) Successful in 19m15s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 13s
CI / build (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 17s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
refactor(a2a): rename ACP module and symbols to A2A standard
Renamed src/cleveragents/acp/ to src/cleveragents/a2a/ and all 13
Acp* classes to A2a* per ADR-047 (A2A Standard Adoption). Updated
all imports, structlog event names (acp.* → a2a.*), field names
(acp_version → a2a_version), and test references across the entire
codebase. This is a cosmetic rename only — no behavioral changes.

ISSUES CLOSED: #688
2026-03-12 14:38:57 +00:00

466 lines
17 KiB
Python

"""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 <name> --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 <name> --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 <name> --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 <name> --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}"
)