Files
temp/features/steps/devcontainer_handler_steps.py
T
freemo a321fb3b37 fix(plan-lifecycle): add rollback_plan method to PlanLifecycleService
- What was implemented
  - Added PLAN_ROLLED_BACK event type to the EventType enum at src/cleveragents/infrastructure/events/types.py to properly represent successful rollbacks in the domain model.
  - Implemented rollback_plan(plan_id: str, checkpoint_id: str) -> RollbackResult in PlanLifecycleService (src/cleveragents/application/services/plan_lifecycle_service.py) with:
    - Plan state validation: rejects rollback when the plan is in terminal APPLIED or CANCELLED states.
    - Delegation to CheckpointService.selective_rollback() to perform the actual rollback logic and obtain a RollbackResult.
    - Emission of PLAN_ROLLED_BACK as a domain event to reflect the completed rollback.
    - checkpoint_service is accepted as an optional constructor parameter; if not provided, a PlanError is raised to preserve backward compatibility.
  - Updated CLI behavior in src/cleveragents/cli/commands/plan.py so agents plan rollback routes through PlanLifecycleService.rollback_plan() rather than calling CheckpointService.selective_rollback() directly.
  - Updated PlanLifecycleService module docstring to include rollback_plan in the documented API.
  - Added Behave feature file features/plan_lifecycle_rollback.feature with 11 scenarios covering state validation, domain events, and delegation.
  - Added step implementations in features/steps/plan_lifecycle_rollback_steps.py to support the new scenarios.

- Key design decisions
  - rollback_plan returns RollbackResult (the same result type produced by CheckpointService.selective_rollback) so the CLI can display rollback details consistently.
  - Terminal states APPLIED and CANCELLED are disallowed for rollback to prevent inconsistent or invalid state transitions.
  - checkpoint_service is optional in the PlanLifecycleService constructor; when omitted (None), a PlanError is raised to retain backward compatibility while signaling explicit dependency requirements.
  - CLI UI remains powered by CheckpointService for metadata enrichment (e.g., confirmation prompts), but the actual rollback action is performed via PlanLifecycleService to ensure proper domain workflow and event emission.

- Technical implications
  - All rollback logic now flows through the domain service layer (PlanLifecycleService) to preserve invariants and emit domain events, rather than allowing ad-hoc UI routes to bypass service validation.
  - The UI can still retrieve checkpoint metadata for user confirmation, but the operation that modifies state uses the new rollback_plan pathway.
  - Tests and behavior coverage were expanded via the new Behave feature and step implementations to validate state handling, events, and delegation.

- Affected modules/components
  - src/cleveragents/infrastructure/events/types.py
  - src/cleveragents/application/services/plan_lifecycle_service.py
  - src/cleveragents/cli/commands/plan.py
  - PlanLifecycleService module docstring
  - features/plan_lifecycle_rollback.feature
  - features/steps/plan_lifecycle_rollback_steps.py

ISSUES CLOSED: #3677
2026-04-06 13:15:57 +00:00

314 lines
11 KiB
Python

"""Step definitions for devcontainer_handler.feature.
Tests devcontainer resource registration, auto-discovery, handler
protocol conformance, and resource type registry integration.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.discovery import (
DevcontainerDiscoveryResult,
discover_devcontainers,
is_trigger_type,
)
from cleveragents.resource.handlers.protocol import ResourceHandler
_VALID_DC_JSON: str = json.dumps(
{"name": "test-devcontainer", "image": "ubuntu:latest"}
)
# ── Given steps ──────────────────────────────────────────────
@given("a temporary directory with a valid devcontainer.json")
def step_tmp_dir_valid_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
context.tmp_path = str(tmp)
@given("a temporary directory simulating a git checkout")
def step_tmp_dir_git(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
context.tmp_path = context.tmp_dir_obj.name
@given("a temporary directory simulating a filesystem mount")
def step_tmp_dir_fs(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
context.tmp_path = context.tmp_dir_obj.name
@given("the directory has a .devcontainer/devcontainer.json file")
def step_add_dc_subdir(context: Context) -> None:
tmp = Path(context.tmp_path)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir(exist_ok=True)
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
@given("the directory has a root .devcontainer.json file")
def step_add_dc_root(context: Context) -> None:
tmp = Path(context.tmp_path)
(tmp / ".devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
@given("a temporary directory with an invalid devcontainer.json")
def step_tmp_dir_invalid_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8")
context.tmp_path = str(tmp)
@given("a temporary directory with a non-object devcontainer.json")
def step_tmp_dir_nonobj_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(
'["not", "an", "object"]', encoding="utf-8"
)
context.tmp_path = str(tmp)
@given("a temporary directory with no devcontainer configuration")
def step_tmp_dir_no_dc(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
context.tmp_path = context.tmp_dir_obj.name
@given("the built-in resource type registry")
def step_builtin_registry(context: Context) -> None:
context.builtin_names = ResourceTypeSpec.BUILTIN_NAMES
# ── When steps ───────────────────────────────────────────────
@when("I create a devcontainer-instance resource at that path")
def step_create_dc_resource(context: Context) -> None:
# Verify the type is actually recognised by the built-in registry
# instead of just assigning a string and asserting it back (U7).
context.resource_type = "devcontainer-instance"
assert context.resource_type in ResourceTypeSpec.BUILTIN_NAMES, (
f"'{context.resource_type}' is not a built-in resource type"
)
# Verify discovery finds the devcontainer in our temp directory
results = discover_devcontainers(
resource_location=context.tmp_path,
resource_type="git-checkout",
)
assert len(results) > 0, "Expected at least one discovery result"
context.resource_id = f"01TEST{results[0].config_path.name}"
context.resource_created = True
@when('I create a container-instance resource with image "{image}"')
def step_create_ctr_resource(context: Context, image: str) -> None:
# Verify the type is actually recognised by the built-in registry
# instead of just assigning a string and asserting it back (U7).
context.container_type = "container-instance"
assert context.container_type in ResourceTypeSpec.BUILTIN_NAMES, (
f"'{context.container_type}' is not a built-in resource type"
)
context.container_image = image
context.container_created = True
@when('I run devcontainer discovery on the directory as "{rtype}"')
def step_run_discovery(context: Context, rtype: str) -> None:
context.discovery_results = discover_devcontainers(
resource_location=context.tmp_path,
resource_type=rtype,
)
@when("I list all built-in type names")
def step_list_builtin_names(context: Context) -> None:
context.type_name_list = sorted(context.builtin_names)
@when("I instantiate DevcontainerHandler")
def step_instantiate_handler(context: Context) -> None:
context.handler = DevcontainerHandler()
@when("I create a discovery result with valid parameters")
def step_create_valid_result(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_file = tmp / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
context.discovery_result = DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location=str(tmp),
)
@when("I create a discovery result with empty parent_location")
def step_create_invalid_result(context: Context) -> None:
context.tmp_dir_obj = tempfile.TemporaryDirectory()
tmp = Path(context.tmp_dir_obj.name)
dc_file = tmp / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
context.result_error = None
try:
DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location="",
)
except ValueError as exc:
context.result_error = exc
# ── Then steps ───────────────────────────────────────────────
@then('the resource should have type "{expected_type}"')
def step_check_resource_type(context: Context, expected_type: str) -> None:
assert context.resource_type == expected_type
@then("the resource should have a non-empty resource_id")
def step_check_resource_id(context: Context) -> None:
assert context.resource_id
assert len(context.resource_id) > 0
@then('the container resource should have type "{expected_type}"')
def step_check_container_type(context: Context, expected_type: str) -> None:
assert context.container_type == expected_type
@then("discovery should return {count:d} result")
def step_check_discovery_count_singular(context: Context, count: int) -> None:
assert len(context.discovery_results) == count
@then("discovery should return {count:d} results")
def step_check_discovery_count_plural(context: Context, count: int) -> None:
assert len(context.discovery_results) == count
@then('the discovered config path should end with "{suffix}"')
def step_check_config_path_suffix(context: Context, suffix: str) -> None:
assert len(context.discovery_results) > 0
result = context.discovery_results[0]
assert str(result.config_path).endswith(suffix)
@then('the type list should contain "{type_name}"')
def step_check_type_in_list(context: Context, type_name: str) -> None:
assert type_name in context.builtin_names
@then("it should satisfy the ResourceHandler protocol")
def step_check_protocol(context: Context) -> None:
assert isinstance(context.handler, ResourceHandler)
@then('its default strategy should be "{strategy}"')
def step_check_default_strategy(context: Context, strategy: str) -> None:
assert context.handler._default_strategy.value == strategy
@then('its type label should be "{label}"')
def step_check_type_label(context: Context, label: str) -> None:
assert context.handler._type_label == label
@then("the result should have a config_path attribute")
def step_check_result_attr(context: Context) -> None:
assert hasattr(context.discovery_result, "config_path")
assert context.discovery_result.config_path is not None
@then("it should raise a ValueError")
def step_check_value_error(context: Context) -> None:
assert context.result_error is not None
assert isinstance(context.result_error, ValueError)
@then('"{rtype}" should be a trigger type')
def step_check_is_trigger(context: Context, rtype: str) -> None:
assert is_trigger_type(rtype) is True
@then('"{rtype}" should not be a trigger type')
def step_check_not_trigger(context: Context, rtype: str) -> None:
assert is_trigger_type(rtype) is False
# ── Given steps for named configurations ─────────────────────
@given('the directory has a named devcontainer configuration "{name}"')
def step_add_named_dc(context: Context, name: str) -> None:
tmp = Path(context.tmp_path)
named_dir = tmp / ".devcontainer" / name
named_dir.mkdir(parents=True, exist_ok=True)
(named_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
@given(
'the directory has a named devcontainer configuration "{name}" with invalid JSON'
)
def step_add_named_dc_invalid(context: Context, name: str) -> None:
tmp = Path(context.tmp_path)
named_dir = tmp / ".devcontainer" / name
named_dir.mkdir(parents=True, exist_ok=True)
(named_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8")
@given("the directory has an empty .devcontainer directory")
def step_add_empty_dc_dir(context: Context) -> None:
tmp = Path(context.tmp_path)
dc_dir = tmp / ".devcontainer"
dc_dir.mkdir(exist_ok=True)
# ── Then steps for named configurations ──────────────────────
@then('the first result should have config name "{name}"')
def step_check_first_result_config_name(context: Context, name: str) -> None:
assert len(context.discovery_results) > 0
result = context.discovery_results[0]
assert result.config_name == name, (
f"Expected config_name={name!r}, got {result.config_name!r}"
)
@then("the first result should have no config name")
def step_check_first_result_no_config_name(context: Context) -> None:
assert len(context.discovery_results) > 0
result = context.discovery_results[0]
assert result.config_name is None, (
f"Expected config_name=None, got {result.config_name!r}"
)
@then('the result config names should include "{name}"')
def step_check_result_config_names_include(context: Context, name: str) -> None:
names = {r.config_name for r in context.discovery_results}
assert name in names, f"Expected {name!r} in config names, got {names!r}"