"""Step definitions for devcontainer lifecycle state model, registry, and JSON parsing. Covers: lifecycle state enum, state transitions, tracker creation and retrieval, registry operations (get/set/clear/evict), argument validation for validate_transition and transition_state, the transition history cap, and devcontainer-up JSON output parsing. All mocks are in ``features/mocks/mock_devcontainer_cli.py``. """ from __future__ import annotations import json 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, transition_state, validate_transition, ) from cleveragents.resource.handlers.devcontainer import ( _lifecycle_registry, _parse_devcontainer_up_output, _registry_lock, clear_lifecycle_registry, evict_terminal_trackers, get_lifecycle_tracker, list_active_containers, set_lifecycle_tracker, ) # ── Shared helper ──────────────────────────────────────────── # R21 fix: shared _OneShotEvent helper — extracted from duplicated # definitions in step_single_health_probe and step_run_health_check_loop_once. # # F8 fix: documented the exact call sequence the counter relies on. class _OneShotEvent: """Stop event that allows exactly one probe iteration. Simulates ``threading.Event`` for health check loop testing, running exactly one probe cycle before signaling stop. The counter relies on the following call sequence inside ``_health_check_loop``: 1. ``while not stop_event.is_set()`` → count=1, returns False 2. ``stop_event.wait(interval)`` → overridden to no-op 3. ``if stop_event.is_set()`` → count=2, returns False 4. (probe executes) 5. ``while not stop_event.is_set()`` → count=3, returns True → exit **WARNING**: If the loop's ``is_set()`` call pattern changes, this counter threshold must be updated to match. """ def __init__(self) -> None: self._count = 0 def is_set(self) -> bool: self._count += 1 return self._count > 2 def set(self) -> None: self._count = 999 def wait(self, timeout: float = 0) -> bool: return False # ── Given steps ────────────────────────────────────────────── # Registry cleanup is handled by the before_scenario hook in # features/environment.py so that all scenarios start with a # clean lifecycle registry regardless of execution order. @given('a lifecycle tracker for resource "{resource_id}"') def step_create_tracker(context: Context, resource_id: str) -> None: tracker = ContainerLifecycleTracker(resource_id=resource_id) set_lifecycle_tracker(tracker) context.tracker = tracker context.resource_id = resource_id @given('a lifecycle tracker in "{state}" state for resource "{resource_id}"') def step_create_tracker_in_state( context: Context, state: str, resource_id: str ) -> None: tracker = ContainerLifecycleTracker( resource_id=resource_id, current_state=ContainerLifecycleState(state), ) set_lifecycle_tracker(tracker) context.tracker = tracker context.resource_id = resource_id @given("{count:d} stopped container trackers in the registry") def step_create_many_stopped(context: Context, count: int) -> None: with _registry_lock: for i in range(count): rid = f"01EVICT{i:020d}" _lifecycle_registry[rid] = ContainerLifecycleTracker( resource_id=rid, current_state=ContainerLifecycleState.STOPPED, ) # ── When steps ─────────────────────────────────────────────── @when("I inspect ContainerLifecycleState enum values") def step_inspect_enum(context: Context) -> None: context.enum_values = [s.value for s in ContainerLifecycleState] @when('I transition from "{from_state}" to "{to_state}"') def step_transition(context: Context, from_state: str, to_state: str) -> None: context.transition_error = None try: context.tracker = transition_state( context.tracker, ContainerLifecycleState(to_state), reason="test transition", ) set_lifecycle_tracker(context.tracker) except (ValueError, TypeError) as exc: context.transition_error = exc @when('I attempt to transition from "{from_state}" to "{to_state}"') def step_attempt_transition(context: Context, from_state: str, to_state: str) -> None: context.transition_error = None try: context.tracker = transition_state( context.tracker, ContainerLifecycleState(to_state), reason="test transition", ) set_lifecycle_tracker(context.tracker) except (ValueError, TypeError) as exc: context.transition_error = exc @when("I call validate_transition with non-enum arguments") def step_validate_transition_bad_types(context: Context) -> None: context.type_error = None try: validate_transition( cast(ContainerLifecycleState, "inactive"), cast(ContainerLifecycleState, "starting"), ) except TypeError as exc: context.type_error = exc @when("I call validate_transition with non-enum to_state") def step_validate_transition_bad_to_state(context: Context) -> None: context.type_error = None try: validate_transition( ContainerLifecycleState.DISCOVERED, cast(ContainerLifecycleState, "starting"), ) except TypeError as exc: context.type_error = exc @when("I call transition_state with non-tracker argument") def step_transition_state_bad_tracker(context: Context) -> None: context.type_error = None try: transition_state( cast(ContainerLifecycleTracker, "not-a-tracker"), ContainerLifecycleState.BUILDING, ) except TypeError as exc: context.type_error = exc @when("I call transition_state with non-enum to_state argument") def step_transition_state_bad_to_state(context: Context) -> None: context.type_error = None try: tracker = ContainerLifecycleTracker(resource_id="01TESTLIFECYCLE0099000001") transition_state(tracker, cast(ContainerLifecycleState, "starting")) except TypeError as exc: context.type_error = exc @when('I retrieve the tracker for "{resource_id}"') def step_retrieve_tracker(context: Context, resource_id: str) -> None: context.retrieved_tracker = get_lifecycle_tracker(resource_id) @when("I list active containers") def step_list_active(context: Context) -> None: context.active_list = list_active_containers() @when("I perform 105 transitions cycling through valid states") def step_perform_105_transitions(context: Context) -> None: """Cycle discovered->building->running->stopping->stopped->building->... 105 times.""" tracker = context.tracker # First transition: discovered -> building tracker = transition_state( tracker, ContainerLifecycleState.BUILDING, reason="t1-init" ) cycle = [ ContainerLifecycleState.RUNNING, ContainerLifecycleState.STOPPING, ContainerLifecycleState.STOPPED, ContainerLifecycleState.BUILDING, ] # We've done 1 transition, need 104 more for 105 total for i in range(104): state = cycle[i % len(cycle)] tracker = transition_state(tracker, state, reason=f"t1-{i}") set_lifecycle_tracker(tracker) context.tracker = tracker @when("I attempt to get tracker with empty resource_id") def step_get_tracker_empty(context: Context) -> None: context.get_error = None try: get_lifecycle_tracker("") except ValueError as exc: context.get_error = exc @when("I attempt to set tracker with wrong type") def step_set_tracker_wrong_type(context: Context) -> None: context.set_error = None try: set_lifecycle_tracker(cast(ContainerLifecycleTracker, "not-a-tracker")) except TypeError as exc: context.set_error = exc @when("I run terminal tracker eviction") def step_run_eviction(context: Context) -> None: context.evicted_count = evict_terminal_trackers() @when('I get tracker for new resource "{resource_id}"') def step_get_new_tracker(context: Context, resource_id: str) -> None: context.auto_tracker = get_lifecycle_tracker(resource_id) @when("I clear the lifecycle registry") def step_clear_registry(context: Context) -> None: clear_lifecycle_registry() # ── Then steps ─────────────────────────────────────────────── @then('the lifecycle enum should contain "{value}"') def step_check_lifecycle_enum_value(context: Context, value: str) -> None: assert value in context.enum_values, f"'{value}' not in {context.enum_values}" @then("the transition should succeed") def step_transition_ok(context: Context) -> None: assert context.transition_error is None, ( f"Unexpected error: {context.transition_error}" ) @then('the tracker state should be "{state}"') def step_check_tracker_state(context: Context, state: str) -> None: assert context.tracker.current_state == ContainerLifecycleState(state) @then("the transition should raise ValueError") def step_transition_error(context: Context) -> None: assert context.transition_error is not None assert isinstance(context.transition_error, ValueError) @then("the tracker should have {count:d} transition in history") def step_check_history_count(context: Context, count: int) -> None: assert len(context.tracker.transitions) == count @then('the retrieved tracker state should be "{state}"') def step_check_retrieved_state(context: Context, state: str) -> None: assert context.retrieved_tracker.current_state == ContainerLifecycleState(state) @then('the active list should contain "{resource_id}"') def step_check_active_contains(context: Context, resource_id: str) -> None: assert resource_id in context.active_list @then('the active list should not contain "{resource_id}"') def step_check_active_not_contains(context: Context, resource_id: str) -> None: assert resource_id not in context.active_list @then("it should raise TypeError") def step_check_type_error(context: Context) -> None: assert context.type_error is not None assert isinstance(context.type_error, TypeError) @then("the tracker should have at most 100 transitions") def step_check_max_transitions(context: Context) -> None: assert len(context.tracker.transitions) <= 100, ( f"Expected at most 100 transitions, got {len(context.tracker.transitions)}" ) @then("the get tracker should raise ValueError") def step_check_get_error(context: Context) -> None: assert context.get_error is not None assert isinstance(context.get_error, ValueError) @then("the set tracker should raise TypeError") def step_check_set_error(context: Context) -> None: assert context.set_error is not None assert isinstance(context.set_error, TypeError) @then("the registry should have at most {max_count:d} terminal trackers") def step_check_registry_cap(context: Context, max_count: int) -> None: with _registry_lock: terminal = [ rid for rid, t in _lifecycle_registry.items() if t.current_state in (ContainerLifecycleState.STOPPED, ContainerLifecycleState.FAILED) ] assert len(terminal) <= max_count, ( f"Expected at most {max_count} terminal trackers, got {len(terminal)}" ) @then("the eviction count should be {count:d}") def step_check_eviction_count(context: Context, count: int) -> None: assert context.evicted_count == count, ( f"Expected eviction count {count}, got {context.evicted_count}" ) @then('the auto-created tracker state should be "{state}"') def step_check_auto_tracker_state(context: Context, state: str) -> None: assert context.auto_tracker.current_state == ContainerLifecycleState(state) @then('the auto-created tracker resource_id should be "{resource_id}"') def step_check_auto_tracker_id(context: Context, resource_id: str) -> None: assert context.auto_tracker.resource_id == resource_id @then("the registry should be empty") def step_check_registry_empty(context: Context) -> None: with _registry_lock: assert len(_lifecycle_registry) == 0, ( f"Expected empty registry, got {len(_lifecycle_registry)} entries" ) # ── JSON output parsing steps ──────────────────────────────── @when('I parse devcontainer up output with containerId "{ctr_id}" and workspace "{ws}"') def step_parse_json(context: Context, ctr_id: str, ws: str) -> None: stdout = json.dumps( { "outcome": "success", "containerId": ctr_id, "remoteWorkspaceFolder": ws, } ) context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout) @when("I parse devcontainer up output with invalid JSON") def step_parse_invalid_json(context: Context) -> None: context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output( "not valid json {{" ) @when("I parse devcontainer up output with log lines before JSON") def step_parse_multiline_json(context: Context) -> None: stdout = ( "[2025-01-01T00:00:00Z] Starting container...\n" "Some log line\n" + json.dumps( { "outcome": "success", "containerId": "aabbccddee0099887766", "remoteWorkspaceFolder": "/workspaces/multi", } ) ) context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout) @when('I parse devcontainer up output with invalid container ID "{bad_id}"') def step_parse_invalid_container_id(context: Context, bad_id: str) -> None: stdout = json.dumps( { "outcome": "success", "containerId": bad_id, "remoteWorkspaceFolder": "/workspaces/project", } ) context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout) @when('I parse devcontainer up output with relative workspace "{ws}"') def step_parse_relative_ws(context: Context, ws: str) -> None: stdout = json.dumps( { "outcome": "success", "containerId": "aabbccddee0011223344", "remoteWorkspaceFolder": ws, } ) context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout) @then('the parsed container ID should be "{ctr_id}"') def step_check_parsed_ctr_id(context: Context, ctr_id: str) -> None: assert context.parsed_ctr_id == ctr_id @then("the parsed container ID should be None") def step_check_parsed_ctr_id_none(context: Context) -> None: assert context.parsed_ctr_id is None @then('the parsed workspace path should be "{ws}"') def step_check_parsed_ws(context: Context, ws: str) -> None: assert context.parsed_ws == ws @then("the parsed workspace path should be None") def step_check_parsed_ws_none(context: Context) -> None: assert context.parsed_ws is None @then("the parsed container ID should be empty") def step_check_empty_container_id(context: Context) -> None: assert context.parsed_ctr_id is None, ( f"Expected None container ID, got '{context.parsed_ctr_id}'" ) @then("the runner should not have received a devcontainer up call") def step_check_no_up_call(context: Context) -> None: assert len(context.mock_runner.up_calls) == 0, ( f"Expected 0 devcontainer up calls, got {len(context.mock_runner.up_calls)}" )