"""Step definitions for the Automation Profile CLI feature.""" from __future__ import annotations import os import re import tempfile 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.automation_profile import ( _InMemoryProfileRepository, ) from cleveragents.cli.commands.automation_profile import ( app as profile_app, ) from cleveragents.domain.models.core.automation_profile import AutomationProfile # Regex to strip ANSI escape sequences that Rich may emit. _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") def _normalize_cli_output(text: str) -> str: """Strip ANSI codes and collapse whitespace so assertions are immune to Rich panel wrapping caused by varying terminal widths (CI vs local).""" text = _ANSI_RE.sub("", text) return " ".join(text.split()) _VALID_YAML = """\ name: acme/strict description: Strict review for production schema_version: "1.0" auto_strategize: 1.0 auto_execute: 1.0 auto_apply: 1.0 auto_decisions_strategize: 1.0 auto_decisions_execute: 1.0 auto_validation_fix: 1.0 auto_strategy_revision: 1.0 auto_reversion_from_apply: 1.0 auto_child_plans: 1.0 auto_retry_transient: 1.0 auto_checkpoint_restore: 1.0 require_sandbox: true require_checkpoints: true allow_unsafe_tools: false """ _INVALID_NAME_YAML = """\ name: "invalid name with spaces!" description: Bad profile schema_version: "1.0" """ _INVALID_THRESHOLD_YAML = """\ name: acme/bad-threshold description: Bad threshold schema_version: "1.0" auto_strategize: 2.0 """ _INVALID_YAML_CONTENT = """\ name: [this is not valid yaml because: it has bad syntax """ def _make_custom_profile( name: str = "acme/strict", description: str = "Strict review for production", ) -> AutomationProfile: """Create a custom AutomationProfile for testing.""" return AutomationProfile( name=name, description=description, schema_version="1.0", auto_strategize=1.0, auto_execute=1.0, auto_apply=1.0, auto_decisions_strategize=1.0, auto_decisions_execute=1.0, auto_validation_fix=1.0, auto_strategy_revision=1.0, auto_reversion_from_apply=1.0, auto_child_plans=1.0, auto_retry_transient=1.0, auto_checkpoint_restore=1.0, require_sandbox=True, require_checkpoints=True, allow_unsafe_tools=False, ) def _write_temp_yaml(context: Context, content: str) -> str: """Write YAML content to a temporary file and register cleanup.""" fd, path = tempfile.mkstemp(suffix=".yaml") with os.fdopen(fd, "w") as fh: fh.write(content) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append( lambda p=path: os.unlink(p) if os.path.exists(p) else None ) return path @given("an automation profile CLI runner with mocks") def step_automation_profile_cli_runner(context: Context) -> None: """Set up the CLI runner for testing.""" context.runner = CliRunner() context.result = None # Reset the module-level in-memory repo for each scenario import cleveragents.cli.commands.automation_profile as ap_mod ap_mod._repo = _InMemoryProfileRepository() @given("a valid automation profile config YAML file") def step_valid_profile_config(context: Context) -> None: """Write a valid profile config to a temp file.""" context.yaml_path = _write_temp_yaml(context, _VALID_YAML) @given('the custom profile "{name}" already exists') def step_custom_profile_exists(context: Context, name: str) -> None: """Pre-register a custom profile in the in-memory repo.""" import cleveragents.cli.commands.automation_profile as ap_mod profile = _make_custom_profile(name=name) ap_mod._repo.upsert(profile) @given("an invalid YAML automation profile config file") def step_invalid_yaml_config(context: Context) -> None: """Write an invalid YAML to a temp file.""" context.invalid_yaml_path = _write_temp_yaml(context, _INVALID_YAML_CONTENT) @given("an automation profile config YAML file with invalid name") def step_invalid_name_config(context: Context) -> None: """Write a profile config with invalid name to a temp file.""" context.invalid_name_yaml_path = _write_temp_yaml(context, _INVALID_NAME_YAML) @given("an automation profile config YAML file with invalid threshold") def step_invalid_threshold_config(context: Context) -> None: """Write a profile config with invalid threshold to a temp file.""" context.invalid_threshold_yaml_path = _write_temp_yaml( context, _INVALID_THRESHOLD_YAML ) @given('an automation profile config YAML file with built-in name "{name}"') def step_builtin_name_config(context: Context, name: str) -> None: """Write a profile config with a built-in name to a temp file.""" yaml_content = f"""\ name: {name} description: Attempt to overwrite built-in schema_version: "1.0" auto_strategize: 0.5 """ context.builtin_name_yaml_path = _write_temp_yaml(context, yaml_content) @given('an automation profile config YAML file with schema_version "{version}"') def step_unsupported_schema_config(context: Context, version: str) -> None: """Write a profile config with unsupported schema version.""" yaml_content = f"""\ name: acme/unsupported description: Unsupported schema schema_version: "{version}" """ context.unsupported_schema_yaml_path = _write_temp_yaml(context, yaml_content) @given('a custom profile "{name}" has been added') def step_custom_profile_added(context: Context, name: str) -> None: """Add a custom profile via the in-memory repo.""" import cleveragents.cli.commands.automation_profile as ap_mod profile = _make_custom_profile(name=name) ap_mod._repo.upsert(profile) # ---- When steps ---- # @when("I run automation-profile add with --config pointing to the YAML file") def step_run_add_config(context: Context) -> None: """Run the add command with --config.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.yaml_path] ) @when("I run automation-profile add with --config and --update") def step_run_add_config_update(context: Context) -> None: """Run the add command with --config and --update.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.yaml_path, "--update"] ) @when("I run automation-profile add with --config without --update") def step_run_add_config_no_update(context: Context) -> None: """Run the add command with --config but without --update.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.yaml_path] ) @when("I run automation-profile add with --config pointing to a missing file") def step_run_add_missing_file(context: Context) -> None: """Run the add command with a missing config file.""" context.result = context.runner.invoke( profile_app, ["add", "--config", "/nonexistent/path/profile.yaml"] ) @when("I run automation-profile add with --config pointing to the invalid YAML file") def step_run_add_invalid_yaml(context: Context) -> None: """Run the add command with invalid YAML.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.invalid_yaml_path] ) @when( "I run automation-profile add with --config pointing to the invalid name YAML file" ) def step_run_add_invalid_name(context: Context) -> None: """Run the add command with invalid name.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.invalid_name_yaml_path] ) @when( "I run automation-profile add with --config pointing to the invalid threshold " "YAML file" ) def step_run_add_invalid_threshold(context: Context) -> None: """Run the add command with invalid threshold.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.invalid_threshold_yaml_path] ) @when( "I run automation-profile add with --config pointing to the built-in name YAML file" ) def step_run_add_builtin_name(context: Context) -> None: """Run the add command with a built-in name.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.builtin_name_yaml_path] ) @when( "I run automation-profile add with --config pointing to the unsupported " "schema YAML file" ) def step_run_add_unsupported_schema(context: Context) -> None: """Run the add command with unsupported schema version.""" context.result = context.runner.invoke( profile_app, ["add", "--config", context.unsupported_schema_yaml_path] ) @when("I run automation-profile list") def step_run_list(context: Context) -> None: """Run the list command.""" context.result = context.runner.invoke(profile_app, ["list"]) @when('I run automation-profile list with namespace filter "{namespace}"') def step_run_list_namespace(context: Context, namespace: str) -> None: """Run the list command with a namespace filter.""" context.result = context.runner.invoke( profile_app, ["list", "--namespace", namespace] ) @when('I run automation-profile list with regex "{regex}"') def step_run_list_regex(context: Context, regex: str) -> None: """Run the list command with a regex filter.""" context.result = context.runner.invoke(profile_app, ["list", regex]) @when("I run automation-profile list with --format json") def step_run_list_format_json(context: Context) -> None: """Run the list command with --format json.""" context.result = context.runner.invoke(profile_app, ["list", "--format", "json"]) @when('I run automation-profile show "{name}"') def step_run_show(context: Context, name: str) -> None: """Run the show command.""" context.result = context.runner.invoke(profile_app, ["show", name]) @when('I run automation-profile show "{name}" with --format yaml') def step_run_show_format_yaml(context: Context, name: str) -> None: """Run the show command with --format yaml.""" context.result = context.runner.invoke( profile_app, ["show", name, "--format", "yaml"] ) @when('I run automation-profile show "{name}" with --format json') def step_run_show_format_json(context: Context, name: str) -> None: """Run the show command with --format json.""" context.result = context.runner.invoke( profile_app, ["show", name, "--format", "json"] ) @when('I run automation-profile show "{name}" with --format plain') def step_run_show_format_plain(context: Context, name: str) -> None: """Run the show command with --format plain.""" context.result = context.runner.invoke( profile_app, ["show", name, "--format", "plain"] ) @when('I run automation-profile show "{name}" with --format table') def step_run_show_format_table(context: Context, name: str) -> None: """Run the show command with --format table.""" context.result = context.runner.invoke( profile_app, ["show", name, "--format", "table"] ) @when('I run automation-profile remove "{name}" with --yes') def step_run_remove_yes(context: Context, name: str) -> None: """Run the remove command with --yes.""" context.result = context.runner.invoke(profile_app, ["remove", name, "--yes"]) @when('I run automation-profile remove "{name}" with --yes and --format json') def step_run_remove_yes_json(context: Context, name: str) -> None: """Run the remove command with --yes and --format json.""" context.result = context.runner.invoke( profile_app, ["remove", name, "--yes", "--format", "json"] ) @when('I invoke plan use with --automation-level "{level}"') def step_invoke_plan_use_automation_level(context: Context, level: str) -> None: """Invoke plan use with the deprecated --automation-level flag.""" from cleveragents.cli.commands.plan import app as plan_app # Mock the lifecycle service so we don't need real infra mock_service = MagicMock() mock_plan = MagicMock() mock_plan.identity.plan_id = "test-plan-id" mock_plan.namespaced_name = "test/plan" mock_plan.phase.value = "strategize" mock_plan.processing_state.value = "queued" mock_plan.state.value = "queued" mock_plan.project_links = [] mock_plan.arguments = {} mock_plan.automation_profile = None mock_plan.action_name = "test/action" mock_plan.description = "test" mock_plan.definition_of_done = "test" mock_plan.strategy_actor = "test" mock_plan.execution_actor = "test" mock_plan.estimation_actor = None mock_plan.invariant_actor = None mock_plan.arguments_order = None mock_plan.timestamps.created_at.isoformat.return_value = "2024-01-01T00:00:00" mock_plan.timestamps.updated_at.isoformat.return_value = "2024-01-01T00:00:00" mock_plan.timestamps.strategize_started_at = None mock_plan.timestamps.strategize_completed_at = None mock_plan.timestamps.execute_started_at = None mock_plan.timestamps.execute_completed_at = None mock_plan.timestamps.applied_at = None mock_plan.error_message = None mock_plan.is_terminal = False mock_service.get_action_by_name.return_value = MagicMock() mock_service.get_action_by_name.return_value.namespaced_name = "test/action" mock_service.use_action.return_value = mock_plan # Widen the virtual terminal so Rich does not wrap text inside error # panels. Without this, narrow terminals (e.g. CI containers with # capture_output=True) cause the error message to be split across # lines, breaking substring assertions. prev_columns = os.environ.get("COLUMNS") os.environ["COLUMNS"] = "200" try: with patch( "cleveragents.cli.commands.plan._get_lifecycle_service", return_value=mock_service, ): context.result = context.runner.invoke( plan_app, [ "use", "test/action", "--automation-level", level, "--format", "json", ], ) finally: if prev_columns is None: os.environ.pop("COLUMNS", None) else: os.environ["COLUMNS"] = prev_columns # ---- Then steps ---- # @then("the automation-profile add should succeed") def step_add_should_succeed(context: Context) -> None: """Assert the add command succeeded.""" assert context.result is not None assert context.result.exit_code == 0, ( f"Expected exit code 0, got {context.result.exit_code}. " f"Output: {context.result.output}" ) @then('the automation-profile output should contain "{text}"') def step_output_contains(context: Context, text: str) -> None: """Assert the command output contains specific text.""" assert context.result is not None assert text.lower() in context.result.output.lower(), ( f"Expected '{text}' in output. Got: {context.result.output}" ) @then("the automation-profile command should abort") def step_command_should_abort(context: Context) -> None: """Assert the command aborted with non-zero exit code.""" assert context.result is not None assert context.result.exit_code != 0, ( f"Expected non-zero exit code, got {context.result.exit_code}. " f"Output: {context.result.output}" ) @then("the automation-profile list should succeed") def step_list_should_succeed(context: Context) -> None: """Assert the list command succeeded.""" assert context.result is not None assert context.result.exit_code == 0, ( f"Expected exit code 0, got {context.result.exit_code}. " f"Output: {context.result.output}" ) @then('the automation-profile json output should contain "{text}"') def step_json_output_contains(context: Context, text: str) -> None: """Assert the JSON output contains specific text.""" assert context.result is not None assert text in context.result.output, ( f"Expected '{text}' in JSON output. Got: {context.result.output}" ) @then('the automation-profile yaml output should contain "{text}"') def step_yaml_output_contains(context: Context, text: str) -> None: """Assert the YAML output contains specific text.""" assert context.result is not None assert text in context.result.output, ( f"Expected '{text}' in YAML output. Got: {context.result.output}" ) @then("the automation-profile show should succeed") def step_show_should_succeed(context: Context) -> None: """Assert the show command succeeded.""" assert context.result is not None assert context.result.exit_code == 0, ( f"Expected exit code 0, got {context.result.exit_code}. " f"Output: {context.result.output}" ) @then("the automation-profile remove should succeed") def step_remove_should_succeed(context: Context) -> None: """Assert the remove command succeeded.""" assert context.result is not None assert context.result.exit_code == 0, ( f"Expected exit code 0, got {context.result.exit_code}. " f"Output: {context.result.output}" ) @then("the plan output should contain deprecation warning for automation-level") def step_plan_output_deprecation_warning(context: Context) -> None: """Assert the plan output contains deprecation warning.""" assert context.result is not None output = context.result.output.lower() assert "deprecated" in output, ( f"Expected 'deprecated' in output. Got: {context.result.output}" ) @then('the plan output should contain "{text}"') def step_plan_output_should_contain(context: Context, text: str) -> None: """Assert the plan output contains the given text.""" assert context.result is not None output = _normalize_cli_output(context.result.output) assert text in output, f"Expected '{text}' in output. Got: {context.result.output}"