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
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
466 lines
17 KiB
Python
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}"
|
|
)
|