Files
cleveragents-core/features/steps/automation_profile_cli_steps.py

514 lines
18 KiB
Python

"""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}"