"""Step definitions for CLI extensions feature (plan + action).""" from __future__ import annotations from datetime import datetime from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context from typer.testing import CliRunner from cleveragents.cli.commands.action import app as action_app from cleveragents.cli.commands.plan import app as plan_app from cleveragents.cli.commands.plan import validate_namespaced_actor from cleveragents.core.exceptions import ValidationError from cleveragents.domain.models.core.action import Action, ActionState from cleveragents.domain.models.core.plan import ( AutomationProfileProvenance, AutomationProfileRef, InvariantSource, NamespacedName, Plan, PlanIdentity, PlanInvariant, PlanPhase, PlanTimestamps, ProcessingState, ProjectLink, ) _PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S" def _make_plan( *, name: str = "local/test-plan", action_name: str = "local/test-action", phase: PlanPhase = PlanPhase.STRATEGIZE, state: ProcessingState = ProcessingState.QUEUED, project_links: list[ProjectLink] | None = None, arguments: dict[str, object] | None = None, automation_profile: AutomationProfileRef | None = None, invariants: list[PlanInvariant] | None = None, strategy_actor: str | None = "openai/gpt-4", execution_actor: str | None = "openai/gpt-4", estimation_actor: str | None = None, invariant_actor: str | None = None, ) -> Plan: """Create a Plan instance for CLI extension tests.""" now = datetime.now() return Plan( identity=PlanIdentity(plan_id=_PLAN_ULID), namespaced_name=NamespacedName.parse(name), description="Test plan description", definition_of_done="All tests pass", action_name=action_name, phase=phase, processing_state=state, project_links=project_links or [], arguments=dict(arguments) if arguments else {}, arguments_order=[], automation_profile=automation_profile, invariants=invariants or [], strategy_actor=strategy_actor, execution_actor=execution_actor, estimation_actor=estimation_actor, invariant_actor=invariant_actor, reusable=True, read_only=False, created_by=None, timestamps=PlanTimestamps(created_at=now, updated_at=now), ) def _make_action( name: str = "local/test-action", *, estimation_actor: str | None = None, invariant_actor: str | None = None, invariants: list[str] | None = None, inputs_schema: dict[str, object] | None = None, automation_profile: str | None = None, ) -> Action: """Create an Action for CLI extension tests.""" return Action( namespaced_name=NamespacedName.parse(name), description="Test action description", long_description=None, definition_of_done="All tests pass", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", estimation_actor=estimation_actor, invariant_actor=invariant_actor, reusable=True, read_only=False, state=ActionState.AVAILABLE, automation_profile=automation_profile, invariants=invariants or [], inputs_schema=inputs_schema, created_by=None, created_at=datetime.now(), updated_at=datetime.now(), ) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a cli extensions test runner") def step_cli_ext_runner(context: Context) -> None: """Set up the CLI runner.""" context.runner = CliRunner() @given("a cli extensions mocked lifecycle service") def step_cli_ext_mock_service(context: Context) -> None: """Set up the mocked lifecycle service.""" context.mock_service = MagicMock() context.plan_patcher = patch( "cleveragents.cli.commands.plan._get_lifecycle_service", return_value=context.mock_service, ) context.action_patcher = patch( "cleveragents.cli.commands.action._get_lifecycle_service", return_value=context.mock_service, ) context.plan_patcher.start() context.action_patcher.start() # Store the last plan created for inspection context.last_plan = None context.last_result = None # --------------------------------------------------------------------------- # plan use: action setup # --------------------------------------------------------------------------- @given("a cli extensions action exists") def step_cli_ext_action_exists(context: Context) -> None: """Set up an action for plan use tests.""" context.mock_service.get_action_by_name.return_value = _make_action() context.mock_service.use_action.return_value = _make_plan() # --------------------------------------------------------------------------- # plan use: automation profile # --------------------------------------------------------------------------- @when('I run plan use with automation profile flag "{profile}"') def step_run_plan_use_with_profile(context: Context, profile: str) -> None: """Run plan use with --automation-profile.""" result = context.runner.invoke( plan_app, ["use", "local/test-action", "--automation-profile", profile], ) context.last_result = result context.last_profile = profile @then("the cli extensions plan use should succeed") def step_plan_use_should_succeed(context: Context) -> None: """Verify plan use succeeded.""" assert context.last_result is not None assert context.last_result.exit_code == 0, ( f"Expected exit code 0, got {context.last_result.exit_code}. " f"Output: {context.last_result.output}" ) @then("the cli extensions plan use should fail") def step_plan_use_should_fail(context: Context) -> None: """Verify plan use failed.""" assert context.last_result is not None assert context.last_result.exit_code != 0 @then('the cli extensions plan should have automation profile "{profile}"') def step_plan_has_profile(context: Context, profile: str) -> None: """Verify plan output mentions automation profile.""" output = context.last_result.output assert profile in output, f"Expected '{profile}' in output: {output}" # --------------------------------------------------------------------------- # plan use: invariant flags # --------------------------------------------------------------------------- @when('I run plan use with invariant flags "{inv1}" and "{inv2}"') def step_run_plan_use_with_invariants(context: Context, inv1: str, inv2: str) -> None: """Run plan use with two --invariant flags.""" result = context.runner.invoke( plan_app, [ "use", "local/test-action", "--invariant", inv1, "--invariant", inv2, ], ) context.last_result = result @then('the cli extensions service received invariants "{inv1}" and "{inv2}"') def step_service_received_invariants(context: Context, inv1: str, inv2: str) -> None: """Verify invariants were passed to the service.""" call_args = context.mock_service.use_action.call_args assert call_args is not None, "use_action was not called" invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants") assert invariants is not None, "No invariants passed to use_action" texts = [inv.text for inv in invariants] assert inv1 in texts, f"Expected '{inv1}' in {texts}" assert inv2 in texts, f"Expected '{inv2}' in {texts}" @when('I run plan use with single invariant "{inv_text}"') def step_run_plan_use_with_single_invariant(context: Context, inv_text: str) -> None: """Run plan use with a single --invariant flag.""" result = context.runner.invoke( plan_app, ["use", "local/test-action", "--invariant", inv_text], ) context.last_result = result @then('the cli extensions service received single invariant "{inv_text}"') def step_service_received_single_invariant(context: Context, inv_text: str) -> None: """Verify single invariant was passed.""" call_args = context.mock_service.use_action.call_args assert call_args is not None invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants") assert invariants is not None texts = [inv.text for inv in invariants] assert inv_text in texts, f"Expected '{inv_text}' in {texts}" # --------------------------------------------------------------------------- # plan use: actor override validation (valid) # --------------------------------------------------------------------------- @when('I run plan use with strategy actor flag "{actor}"') def step_run_plan_use_strategy_actor(context: Context, actor: str) -> None: """Run plan use with --strategy-actor.""" result = context.runner.invoke( plan_app, ["use", "local/test-action", "--strategy-actor", actor], ) context.last_result = result @when('I run plan use with execution actor flag "{actor}"') def step_run_plan_use_execution_actor(context: Context, actor: str) -> None: """Run plan use with --execution-actor.""" result = context.runner.invoke( plan_app, ["use", "local/test-action", "--execution-actor", actor], ) context.last_result = result @when('I run plan use with estimation actor flag "{actor}"') def step_run_plan_use_estimation_actor(context: Context, actor: str) -> None: """Run plan use with --estimation-actor.""" result = context.runner.invoke( plan_app, ["use", "local/test-action", "--estimation-actor", actor], ) context.last_result = result @when('I run plan use with invariant actor flag "{actor}"') def step_run_plan_use_invariant_actor(context: Context, actor: str) -> None: """Run plan use with --invariant-actor.""" result = context.runner.invoke( plan_app, ["use", "local/test-action", "--invariant-actor", actor], ) context.last_result = result @then('the cli extensions plan has strategy actor "{actor}"') def step_plan_has_strategy_actor(context: Context, actor: str) -> None: """Verify plan output shows the strategy actor.""" output = context.last_result.output assert actor in output, f"Expected '{actor}' in output: {output}" @then('the cli extensions plan has execution actor "{actor}"') def step_plan_has_execution_actor(context: Context, actor: str) -> None: """Verify plan output shows the execution actor.""" output = context.last_result.output assert actor in output, f"Expected '{actor}' in output: {output}" @then('the cli extensions plan has estimation actor "{actor}"') def step_plan_has_estimation_actor(context: Context, actor: str) -> None: """Verify plan output shows the estimation actor.""" output = context.last_result.output assert actor in output, f"Expected '{actor}' in output: {output}" @then('the cli extensions plan has invariant actor "{actor}"') def step_plan_has_invariant_actor(context: Context, actor: str) -> None: """Verify plan output shows the invariant actor.""" output = context.last_result.output assert actor in output, f"Expected '{actor}' in output: {output}" # --------------------------------------------------------------------------- # plan use: actor override validation (invalid format) # --------------------------------------------------------------------------- @then("the cli extensions plan use should fail with actor validation error") def step_plan_use_fail_actor_validation(context: Context) -> None: """Verify plan use failed due to actor validation.""" assert context.last_result is not None assert context.last_result.exit_code != 0, ( f"Expected non-zero exit, got {context.last_result.exit_code}. " f"Output: {context.last_result.output}" ) # --------------------------------------------------------------------------- # plan status: automation profile # --------------------------------------------------------------------------- @given('cli extensions plans exist with automation profile "{profile}"') def step_plans_with_profile(context: Context, profile: str) -> None: """Set up plans that have an automation profile.""" plan = _make_plan( automation_profile=AutomationProfileRef( profile_name=profile, provenance=AutomationProfileProvenance.PLAN, ) ) context.mock_service.list_plans.return_value = [plan] context.mock_service.get_plan.return_value = plan @when("I run cli extensions plan status") def step_run_plan_status(context: Context) -> None: """Run plan status (no plan_id = list all).""" result = context.runner.invoke(plan_app, ["status"]) context.last_result = result @then('the cli extensions status output should contain "{text}"') def step_status_contains(context: Context, text: str) -> None: """Verify status output contains text.""" assert context.last_result.exit_code == 0 assert text in context.last_result.output, ( f"Expected '{text}' in: {context.last_result.output}" ) @when('I run cli extensions plan status with format "{fmt}"') def step_run_plan_status_fmt(context: Context, fmt: str) -> None: """Run plan status with --format.""" result = context.runner.invoke(plan_app, ["status", "--format", fmt]) context.last_result = result @then( "the cli extensions status json output should contain automation profile " '"{profile}"' ) def step_status_json_profile(context: Context, profile: str) -> None: """Verify JSON output contains automation profile.""" assert context.last_result.exit_code == 0 output = context.last_result.output assert profile in output, f"Expected '{profile}' in: {output}" # --------------------------------------------------------------------------- # plan lifecycle-list: invariants # --------------------------------------------------------------------------- @given("cli extensions plans exist with invariants") def step_plans_with_invariants(context: Context) -> None: """Set up plans that have invariants.""" plan = _make_plan( invariants=[ PlanInvariant(text="No warnings", source=InvariantSource.PLAN), PlanInvariant(text="Keep compat", source=InvariantSource.ACTION), ] ) context.mock_service.list_plans.return_value = [plan] @when("I run cli extensions plan lifecycle-list") def step_run_lifecycle_list(context: Context) -> None: """Run plan lifecycle-list.""" result = context.runner.invoke(plan_app, ["lifecycle-list"]) context.last_result = result @then("the cli extensions list output should contain invariant count") def step_list_has_invariant_count(context: Context) -> None: """Verify list output contains invariant count.""" assert context.last_result.exit_code == 0 # The table should show "2" for invariant count assert "2" in context.last_result.output, ( f"Expected '2' invariant count in: {context.last_result.output}" ) @when('I run cli extensions plan lifecycle-list with format "{fmt}"') def step_run_lifecycle_list_fmt(context: Context, fmt: str) -> None: """Run plan lifecycle-list with --format.""" result = context.runner.invoke(plan_app, ["lifecycle-list", "--format", fmt]) context.last_result = result @then("the cli extensions list json output should contain invariants") def step_list_json_invariants(context: Context) -> None: """Verify JSON output contains invariants field.""" assert context.last_result.exit_code == 0 output = context.last_result.output assert "invariants" in output, f"Expected 'invariants' in: {output}" assert "No warnings" in output, f"Expected 'No warnings' in: {output}" # --------------------------------------------------------------------------- # action show: optional fields # --------------------------------------------------------------------------- @given('a cli extensions action with estimation actor "{actor}"') def step_action_with_estimation_actor(context: Context, actor: str) -> None: """Set up an action with estimation actor.""" action = _make_action(estimation_actor=actor) context.mock_service.get_action_by_name.return_value = action context.test_action = action @given('a cli extensions action with invariant actor "{actor}"') def step_action_with_invariant_actor(context: Context, actor: str) -> None: """Set up an action with invariant actor.""" action = _make_action(invariant_actor=actor) context.mock_service.get_action_by_name.return_value = action context.test_action = action @given("a cli extensions action with invariants") def step_action_with_invariants(context: Context) -> None: """Set up an action with invariants.""" action = _make_action(invariants=["No regressions", "Keep backward compat"]) context.mock_service.get_action_by_name.return_value = action context.test_action = action @given("a cli extensions action with inputs schema") def step_action_with_inputs_schema(context: Context) -> None: """Set up an action with inputs_schema.""" action = _make_action( inputs_schema={ "type": "object", "properties": {"coverage": {"type": "integer"}}, } ) context.mock_service.get_action_by_name.return_value = action context.test_action = action @when("I run cli extensions action show") def step_run_action_show(context: Context) -> None: """Run action show.""" result = context.runner.invoke(action_app, ["show", "local/test-action"]) context.last_result = result @when('I run cli extensions action show with format "{fmt}"') def step_run_action_show_fmt(context: Context, fmt: str) -> None: """Run action show with --format.""" result = context.runner.invoke( action_app, ["show", "local/test-action", "--format", fmt] ) context.last_result = result @then('the cli extensions action output should contain "{text}"') def step_action_output_contains(context: Context, text: str) -> None: """Verify action output contains text.""" assert context.last_result.exit_code == 0, ( f"Exit code: {context.last_result.exit_code}, " f"output: {context.last_result.output}" ) assert text in context.last_result.output, ( f"Expected '{text}' in: {context.last_result.output}" ) @then('the cli extensions action json should contain "{key}"') def step_action_json_contains(context: Context, key: str) -> None: """Verify JSON output contains key.""" assert context.last_result.exit_code == 0 output = context.last_result.output assert key in output, f"Expected '{key}' in: {output}" # --------------------------------------------------------------------------- # plan use: combined profile + invariant # --------------------------------------------------------------------------- @when('I run plan use with profile "{profile}" and invariant "{inv_text}"') def step_run_plan_use_profile_invariant( context: Context, profile: str, inv_text: str ) -> None: """Run plan use with both --automation-profile and --invariant.""" result = context.runner.invoke( plan_app, [ "use", "local/test-action", "--automation-profile", profile, "--invariant", inv_text, ], ) context.last_result = result # --------------------------------------------------------------------------- # validate_namespaced_actor unit tests # --------------------------------------------------------------------------- @then('validate_namespaced_actor accepts "{actor}"') def step_validate_actor_accepts(context: Context, actor: str) -> None: """Verify validate_namespaced_actor accepts a valid name.""" result = validate_namespaced_actor(actor, "--test") assert result == actor @then('validate_namespaced_actor rejects "{actor}"') def step_validate_actor_rejects(context: Context, actor: str) -> None: """Verify validate_namespaced_actor rejects an invalid name.""" try: validate_namespaced_actor(actor, "--test") raise AssertionError(f"Expected ValidationError for '{actor}'") except ValidationError: pass # Expected # --------------------------------------------------------------------------- # Cleanup # --------------------------------------------------------------------------- def after_scenario(context: Context, scenario: object) -> None: """Clean up patchers.""" for name in ("plan_patcher", "action_patcher"): patcher = getattr(context, name, None) if patcher: patcher.stop()