Files
cleveragents-core/features/steps/devcontainer_cleanup_steps.py
freemo 02250473ad fix(ci): restore all CI quality gates to passing on master
Fix all failing CI quality gates (lint, unit_tests, format) without
suppressing any quality enforcement.

Root causes and fixes:

1. Format: features/steps/plan_namespaced_name_tdd_steps.py had trailing
   whitespace; fixed by running ruff format.

2. Unit tests - A2A JSON-RPC 2.0 migration (commit 9c6d6915) renamed
   A2aRequest fields (operation→method, request_id→id, a2a_version→jsonrpc)
   and A2aResponse fields (status+data→result, request_id→id) but did not
   update all step files and feature files:
   - a2a_jsonrpc_wire_format_steps.py: added use_step_matcher('re') and
     reset to 'parse' at end to prevent parallel test interference
   - a2a_facade_wiring_steps.py: updated operation= to method=, .status/.data
     to .result
   - a2a_facade_steps.py: updated request_id→id, a2a_version→jsonrpc,
     A2aResponse(request_id=..., status=...) to new API
   - m6_facade_steps.py: updated all old API usage
   - devcontainer_cleanup_steps.py: updated A2aRequest(operation=...)
   - plan_prompt_command_steps.py: updated A2aRequest(operation=...)
   - wf03_plan_prompt_confidence_steps.py: updated A2aRequest(operation=...)
   - consolidated_misc.feature: updated old A2aRequest/A2aResponse scenarios

3. Unit tests - Session CLI output changed (commit 0d5d9cf0 and others):
   - 'Session Created' → 'Session created' (lowercase)
   - 'Session Details' → 'Session Summary'
   - 'Sessions (N total)' → 'Sessions'
   - session list JSON: top-level 'total' → nested 'summary.total'
   - Fixed in: session_cli.feature, session_cli_coverage_boost.feature,
     session_cli_uncovered_branches.feature, session_list_error.feature,
     tdd_session_create_persist_steps.py

4. Unit tests - Plan list output changed (commit 1a07a891):
   - 'V3 Lifecycle Plans' → 'Plans'
   - 'Lifecycle Plans' → 'Plans'
   - Name column removed (restored in source)
   - Invariants column removed (restored in source)
   - Project truncation removed (restored in source)
   - Fixed in: plan_cli_cancel_revert_coverage.feature,
     plan_lifecycle_cli_coverage.feature, plan_cli_coverage_boost_steps.py,
     plan.py (source code restored)

5. Unit tests - Plan apply command now requires ULID (commit 300a5d6d):
   - plan_cli_coverage_r3.feature: updated 'PLAN-001' to valid ULID
   - plan_cli_coverage_r3_steps.py: added --yes flag, added new step for
     no-eligible-plans path

6. Unit tests - Various source code bugs:
   - ThoughtBlock: converted from @dataclass to Pydantic BaseModel
     (architecture test requires all dataclasses to use Pydantic)
   - session.py: added DatabaseError handling to export, import, tell commands
   - database.py: fixed rollback_to() to reuse checkpoint connection for writes
   - database.py: added _get_checkpoint_conn() helper
   - check-tls-cert.py: fixed SSLCertVerificationError.reason AttributeError

7. Unit tests - Test step bugs:
   - error_recovery_coverage_boost_steps.py: fixed invalid ULID _PLAN_ID
   - session_service_coverage_steps.py: fixed 'sha256:' prefix bug in checksum
   - database_models_new_coverage_steps.py: added 'name' field to session mock
   - async_audit_recording_steps.py: fixed Settings(audit_async=False) via env var
   - coverage_threshold_config_steps.py: added --coverage-min pattern support
   - m5_acms_smoke_steps.py: updated usage hint text
   - actor_cli_yaml_steps.py: updated 'Removed actor' → 'Actor removed'
   - aimodelscredentials_steps.py: set context.imported_class in import step
   - domain_base_model.feature: added missing 'When I examine model_config' step
   - tui_first_run_steps.py: fixed module reload to restore cleveragents.tui.*
     modules after test (prevented patch interference in subsequent tests)
   - tui_first_run_steps.py: added set_search('') step for empty string
   - resource_handler_base_coverage_r3_steps.py: use _MinimalHandler instead
     of DatabaseResourceHandler for NotImplementedError tests
   - resource_handler_crud.feature: updated to test new DatabaseHandler behavior
   - resource_handler_sandbox.feature: updated to test new DatabaseHandler behavior
   - tdd_json_decode_crash_persistence.feature: fixed @tdd_bug → @tdd_issue tags

8. Parallel test interference:
   - All step files using use_step_matcher('re') now reset to 'parse' at end
     to prevent global matcher state leaking to subsequent step files
2026-04-04 20:38:16 +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(
method="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}"
)