"""Step definitions for plan_cli_coverage_boost.feature. Covers uncovered branches in cleveragents/cli/commands/plan.py: - _plan_spec_dict: error_message truthy branch, legacy fallback - _print_lifecycle_plan: all optional timestamps, estimation_actor, invariant_actor, non-Plan fallback - list_plans: non-rich format path - execute_plan: non-rich format path - apply_plan: non-rich format path - list_plans: regex, state, processing_state filtering - cancel_plan: rich and non-rich paths, with and without reason """ from __future__ import annotations from datetime import datetime, timedelta from io import StringIO from unittest.mock import MagicMock, patch from behave import given, then, when from typer.testing import CliRunner from cleveragents.cli.commands import plan as plan_module from cleveragents.cli.commands.plan import ( _plan_spec_dict, _print_lifecycle_plan, ) from cleveragents.cli.commands.plan import ( app as plan_app, ) from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, PlanIdentity, PlanPhase, PlanTimestamps, ProcessingState, ProjectLink, ) # Valid ULIDs for test plans _ULIDS = [ "01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW", "01ARZ3NDEKTSV4RRFFQ69G5FAX", "01ARZ3NDEKTSV4RRFFQ69G5FAY", "01ARZ3NDEKTSV4RRFFQ69G5FAZ", "01ARZ3NDEKTSV4RRFFQ69G5FB0", ] def _make_plan( *, plan_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV", name: str = "local/test-plan", description: str = "Test plan for coverage", phase: PlanPhase = PlanPhase.STRATEGIZE, processing_state: ProcessingState = ProcessingState.QUEUED, error_message: str | None = None, estimation_actor: str | None = None, invariant_actor: str | None = None, timestamps: PlanTimestamps | None = None, project_links: list[ProjectLink] | None = None, action_name: str = "local/test-action", ) -> Plan: """Build a real Plan object for testing.""" if timestamps is None: timestamps = PlanTimestamps( created_at=datetime.now(), updated_at=datetime.now(), ) return Plan( identity=PlanIdentity(plan_id=plan_id), namespaced_name=NamespacedName.parse(name), action_name=action_name, description=description, phase=phase, processing_state=processing_state, project_links=project_links or [], error_message=error_message, estimation_actor=estimation_actor, invariant_actor=invariant_actor, timestamps=timestamps, reusable=True, read_only=False, ) # -------------------------------------------------------------------------- # _plan_spec_dict helpers # -------------------------------------------------------------------------- @given('a v3 Plan with error_message set to "{error_msg}"') def step_plan_with_error_message(context, error_msg: str) -> None: context.test_plan = _make_plan(error_message=error_msg) @given("a v3 Plan with error_message set to None") def step_plan_with_no_error_message(context) -> None: context.test_plan = _make_plan(error_message=None) @given('a non-Plan object with string value "{value}"') def step_non_plan_object(context, value: str) -> None: context.test_plan = value @when("I call _plan_spec_dict on the plan") def step_call_plan_spec_dict_on_plan(context) -> None: context.spec_dict = _plan_spec_dict(context.test_plan) @when("I call _plan_spec_dict on the object") def step_call_plan_spec_dict_on_object(context) -> None: context.spec_dict = _plan_spec_dict(context.test_plan) @then('the spec dict should contain key "{key}" with value "{value}"') def step_spec_dict_contains_key_value(context, key: str, value: str) -> None: assert key in context.spec_dict, ( f"Key '{key}' not in spec dict: {context.spec_dict}" ) assert str(context.spec_dict[key]) == value, ( f"Expected '{value}', got '{context.spec_dict[key]}'" ) @then("the spec dict should equal {expected}") def step_spec_dict_equals(context, expected: str) -> None: import json expected_dict = json.loads(expected.replace("'", '"')) assert context.spec_dict == expected_dict, ( f"Expected {expected_dict}, got {context.spec_dict}" ) @then('the spec dict should not contain key "{key}"') def step_spec_dict_not_contains_key(context, key: str) -> None: assert key not in context.spec_dict, ( f"Key '{key}' should not be in spec dict: {context.spec_dict}" ) # -------------------------------------------------------------------------- # _print_lifecycle_plan helpers # -------------------------------------------------------------------------- @given("a v3 Plan with all timestamps populated") def step_plan_with_all_timestamps(context) -> None: now = datetime.now() ts = PlanTimestamps( created_at=now - timedelta(hours=2), updated_at=now, strategize_started_at=now - timedelta(hours=1, minutes=50), strategize_completed_at=now - timedelta(hours=1, minutes=40), execute_started_at=now - timedelta(hours=1, minutes=30), execute_completed_at=now - timedelta(hours=1), applied_at=now - timedelta(minutes=30), ) context.test_plan = _make_plan( timestamps=ts, phase=PlanPhase.APPLY, processing_state=ProcessingState.APPLIED, ) @given('a v3 Plan with estimation_actor set to "{actor}"') def step_plan_with_estimation_actor(context, actor: str) -> None: context.test_plan = _make_plan(estimation_actor=actor) @given('a v3 Plan with invariant_actor set to "{actor}"') def step_plan_with_invariant_actor(context, actor: str) -> None: context.test_plan = _make_plan(invariant_actor=actor) @when("I call _print_lifecycle_plan on the plan") def step_call_print_lifecycle_plan_on_plan(context) -> None: buf = StringIO() from rich.console import Console test_console = Console(file=buf, width=200, no_color=True) with patch.object(plan_module, "console", test_console): _print_lifecycle_plan(context.test_plan, title="Test Plan") context.printed_output = buf.getvalue() @when("I call _print_lifecycle_plan on the object") def step_call_print_lifecycle_plan_on_object(context) -> None: buf = StringIO() from rich.console import Console test_console = Console(file=buf, width=200, no_color=True) with patch.object(plan_module, "console", test_console): _print_lifecycle_plan(context.test_plan, title="Legacy Plan") context.printed_output = buf.getvalue() @then('the printed output should contain "{text}"') def step_printed_output_contains(context, text: str) -> None: assert text in context.printed_output, ( f"Expected '{text}' in output:\n{context.printed_output}" ) # -------------------------------------------------------------------------- # CLI runner steps for list_plans, execute, apply, list, # cancel in non-rich and rich formats # -------------------------------------------------------------------------- def _output(context) -> str: return getattr(context.result, "output", "") if hasattr(context, "result") else "" @given("a plan lifecycle CLI runner for coverage") def step_cli_runner_for_coverage(context) -> None: context.runner = CliRunner() @given("a mocked lifecycle service for plan coverage commands") def step_mocked_lifecycle_service_for_coverage(context) -> None: context.mock_lifecycle_service = MagicMock() patcher = patch( "cleveragents.cli.commands.plan._get_lifecycle_service", return_value=context.mock_lifecycle_service, ) patcher.start() if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(patcher.stop) # Widen the Rich console so table columns do not wrap during tests console_patcher = patch.object(plan_module.console, "width", 200) console_patcher.start() context._cleanup_handlers.append(console_patcher.stop) @given("the mocked plan service returns legacy plans") def step_mocked_plan_service_returns_legacy_plans(context) -> None: """Set up the container mock so list_plans returns legacy Plan objects.""" mock_plan = MagicMock() mock_plan.name = "test-plan" mock_plan.status = "active" mock_plan.created_at = datetime.now() mock_plan.current = False mock_plan.id = "plan-001" mock_project = MagicMock() mock_project.name = "test-project" mock_plan_service = MagicMock() mock_plan_service.list_plans.return_value = [mock_plan] mock_project_service = MagicMock() mock_project_service.get_current_project.return_value = mock_project mock_container = MagicMock() mock_container.plan_service.return_value = mock_plan_service mock_container.project_service.return_value = mock_project_service patcher = patch( "cleveragents.application.container.get_container", return_value=mock_container, ) patcher.start() context._cleanup_handlers.append(patcher.stop) @given("the service has a complete strategize plan for execute") def step_service_has_strategize_plan(context) -> None: plan = _make_plan( plan_id=_ULIDS[0], name="local/exec-plan", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.COMPLETE, ) context.mock_lifecycle_service.get_plan.return_value = plan context.mock_lifecycle_service.execute_plan.return_value = plan context._execute_plan_id = _ULIDS[0] # Mock the plan executor so the execute phase runs with a mock executor_patcher = patch( "cleveragents.cli.commands.plan._get_plan_executor", return_value=MagicMock(), ) executor_patcher.start() context._cleanup_handlers.append(executor_patcher.stop) @given("the service has a complete execute plan for apply") def step_service_has_execute_plan(context) -> None: pre_plan = _make_plan( plan_id=_ULIDS[1], name="local/apply-plan", phase=PlanPhase.EXECUTE, processing_state=ProcessingState.COMPLETE, ) apply_queued_plan = _make_plan( plan_id=_ULIDS[1], name="local/apply-plan", phase=PlanPhase.APPLY, processing_state=ProcessingState.QUEUED, ) applied_plan = _make_plan( plan_id=_ULIDS[1], name="local/apply-plan", phase=PlanPhase.APPLY, processing_state=ProcessingState.APPLIED, ) # apply_plan calls get_plan twice: first to check read_only # and phase, then again after apply_plan to drive _complete_apply_if_queued. context.mock_lifecycle_service.get_plan.side_effect = [ pre_plan, apply_queued_plan, ] context.mock_lifecycle_service.apply_plan.return_value = apply_queued_plan context.mock_lifecycle_service._complete_apply_if_queued.return_value = applied_plan context._apply_plan_id = _ULIDS[1] @given("the service has multiple plans for lifecycle list") def step_service_has_multiple_plans(context) -> None: plans = [ _make_plan( plan_id=_ULIDS[0], name="local/alpha-plan", action_name="local/test-action", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.QUEUED, ), _make_plan( plan_id=_ULIDS[1], name="local/beta-plan", action_name="local/other-action", phase=PlanPhase.EXECUTE, processing_state=ProcessingState.PROCESSING, ), ] context.mock_lifecycle_service.list_plans.return_value = plans @given("the service has plans in different processing states") def step_service_has_plans_different_states(context) -> None: plans = [ _make_plan( plan_id=_ULIDS[0], name="local/plan-processing", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.PROCESSING, ), _make_plan( plan_id=_ULIDS[1], name="local/plan-complete", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.COMPLETE, ), ] context.mock_lifecycle_service.list_plans.return_value = plans @given("the service can cancel a plan") def step_service_can_cancel(context) -> None: plan = _make_plan( plan_id=_ULIDS[2], name="local/cancel-me", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.CANCELLED, ) context.mock_lifecycle_service.cancel_plan.return_value = plan context._cancel_plan_id = _ULIDS[2] # ---- When steps for CLI invocations ---- @when('I invoke list_plans with "--format" "{fmt}"') def step_invoke_list_plans_format(context, fmt: str) -> None: context.result = context.runner.invoke( plan_app, ["list", "--format", fmt], ) @when('I invoke execute with "--format" "json" and plan id') def step_invoke_execute_json(context) -> None: context.result = context.runner.invoke( plan_app, ["execute", context._execute_plan_id, "--format", "json"], ) @when('I invoke apply with "--format" "json" and plan id') def step_invoke_apply_json(context) -> None: context.result = context.runner.invoke( plan_app, ["apply", "--yes", context._apply_plan_id, "--format", "json"], ) @when('I invoke list with regex "{pattern}"') def step_invoke_list_regex(context, pattern: str) -> None: context.result = context.runner.invoke( plan_app, ["list", pattern], ) @when('I invoke list with "--state" "{state}"') def step_invoke_list_state(context, state: str) -> None: context.result = context.runner.invoke( plan_app, ["list", "--state", state], ) @when('I invoke list with "--processing-state" "{state}"') def step_invoke_list_processing_state(context, state: str) -> None: context.result = context.runner.invoke( plan_app, ["list", "--processing-state", state], ) @when('I invoke list with "--action" "{action}"') def step_invoke_list_action(context, action: str) -> None: context.result = context.runner.invoke( plan_app, ["list", "--action", action], ) @when('I invoke list with "--format" "{fmt}"') def step_invoke_list_format(context, fmt: str) -> None: context.result = context.runner.invoke( plan_app, ["list", "--format", fmt], ) @when('I invoke cancel with "--format" "json" and no reason') def step_invoke_cancel_json_no_reason(context) -> None: context.result = context.runner.invoke( plan_app, ["cancel", context._cancel_plan_id, "--format", "json"], ) @when('I invoke cancel with "--format" "json" and reason "{reason}"') def step_invoke_cancel_json_with_reason(context, reason: str) -> None: context.result = context.runner.invoke( plan_app, [ "cancel", context._cancel_plan_id, "--format", "json", "--reason", reason, ], ) @when('I invoke cancel in rich format with reason "{reason}"') def step_invoke_cancel_rich_with_reason(context, reason: str) -> None: context.result = context.runner.invoke( plan_app, ["cancel", context._cancel_plan_id, "--reason", reason], ) @when("I invoke cancel in rich format without reason") def step_invoke_cancel_rich_no_reason(context) -> None: context.result = context.runner.invoke( plan_app, ["cancel", context._cancel_plan_id], ) # ---- Then steps for CLI assertions ---- @then("the plan coverage command should succeed") def step_plan_coverage_command_succeeds(context) -> None: assert context.result.exit_code == 0, ( f"Expected success but got exit code {context.result.exit_code}.\n" f"Output: {_output(context)}" ) @then("the plan coverage command should abort") def step_plan_coverage_command_aborts(context) -> None: assert context.result.exit_code != 0, ( f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}" ) @then('the plan coverage output should contain "{text}"') def step_plan_coverage_output_contains(context, text: str) -> None: output = _output(context) assert text in output, f"Expected '{text}' in output:\n{output}" @then('the plan coverage output should not contain "{text}"') def step_plan_coverage_output_not_contains(context, text: str) -> None: output = _output(context) assert text not in output, f"Did not expect '{text}' in output:\n{output}" @then("the execute JSON output has the spec-required envelope fields") def step_execute_json_envelope_fields(context) -> None: """Verify the execute JSON output has the spec-required top-level envelope.""" import json output = _output(context) # The output may have a trailing newline; strip it parsed = json.loads(output.strip()) required_fields = {"command", "status", "exit_code", "data", "timing", "messages"} missing = required_fields - set(parsed.keys()) assert not missing, ( f"Missing envelope fields {missing} in execute JSON output:\n{output}" ) assert parsed["command"] == "plan execute", ( f"Expected command='plan execute', got '{parsed['command']}'" ) assert parsed["status"] == "ok", f"Expected status='ok', got '{parsed['status']}'" assert parsed["exit_code"] == 0, ( f"Expected exit_code=0, got '{parsed['exit_code']}'" ) assert isinstance(parsed["data"], dict), ( f"Expected data to be a dict, got {type(parsed['data'])}" ) assert isinstance(parsed["messages"], list), ( f"Expected messages to be a list, got {type(parsed['messages'])}" ) # Verify data has required fields data = parsed["data"] required_data_fields = { "plan_id", "phase", "sandbox", "worker", "attempt", "strategy_summary", "progress", } missing_data = required_data_fields - set(data.keys()) assert not missing_data, ( f"Missing data fields {missing_data} in execute JSON output data:\n{output}" ) @then("the execute JSON output data has sandbox with strategy field") def step_execute_json_sandbox_strategy(context) -> None: """Verify the sandbox dict in execute JSON output has a strategy field.""" import json output = _output(context) parsed = json.loads(output.strip()) data = parsed["data"] sandbox = data.get("sandbox") assert isinstance(sandbox, dict), ( f"Expected sandbox to be a dict, got {type(sandbox)}: {sandbox}" ) assert "strategy" in sandbox, f"Expected 'strategy' key in sandbox dict: {sandbox}" @then("the execute JSON output data has progress list with label and status") def step_execute_json_progress_list(context) -> None: """Verify the progress list in execute JSON output has label and status fields.""" import json output = _output(context) parsed = json.loads(output.strip()) data = parsed["data"] progress = data.get("progress") assert isinstance(progress, list), ( f"Expected progress to be a list, got {type(progress)}: {progress}" ) assert len(progress) > 0, "Expected at least one progress step" for step in progress: assert isinstance(step, dict), ( f"Expected each progress step to be a dict, got {type(step)}: {step}" ) assert "label" in step, f"Expected 'label' key in progress step: {step}" assert "status" in step, f"Expected 'status' key in progress step: {step}"