Files
temp/features/steps/automation_profile_cli_coverage_boost_steps.py
T
Luis Mendes 2acb957d83 fix(cli): replace in-memory automation-profile repository with database-backed persistence
The automation-profile CLI commands used an _InMemoryProfileRepository
(a Python dict) that lost all data between CLI process invocations.
Profiles created with "automation-profile add" were invisible to
subsequent "automation-profile show" or "list" calls because each CLI
command is a separate process with a fresh empty dict.

Changes:
- Replaced _InMemoryProfileRepository with the real
  AutomationProfileRepository from the infrastructure layer, wired
  via the DI container following the same pattern as tool.py and
  session.py.
- Added auto_commit support to AutomationProfileRepository (matching
  the existing SessionRepository pattern) so that CLI commands running
  outside a UnitOfWork commit each operation automatically.
- Added safety_json and guards_json Text columns to the
  automation_profiles table (Alembic migration m6_005) for full-fidelity
  round-trip of the AutomationGuard and SafetyProfile sub-models.
  Previously, guards and several safety fields (max_cost_per_plan,
  max_retries_per_step, etc.) were silently dropped on persistence.
- Updated _from_domain, _to_domain, and _update_row to serialize and
  deserialize the full guard and safety sub-models via JSON, with
  backward-compatible fallback to legacy scalar columns.

Refs: #746
2026-03-17 09:53:53 +00:00

371 lines
13 KiB
Python

"""Step definitions for Automation Profile CLI guards coverage boost.
Targets uncovered lines 91-97 (_guards_dict non-None branch) and
lines 194-204 (_print_profile rich guards display block).
"""
from __future__ import annotations
import os
import tempfile
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.automation_profile import (
_guards_dict,
_profile_spec_dict,
)
from cleveragents.cli.commands.automation_profile import (
app as profile_app,
)
from cleveragents.domain.models.core.automation_guard import AutomationGuard
from cleveragents.domain.models.core.automation_profile import AutomationProfile
def _create_in_memory_profile_service():
"""Create an AutomationProfileService backed by an in-memory SQLite DB."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.automation_profile_service import (
AutomationProfileService,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
AutomationProfileRepository,
)
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
repo = AutomationProfileRepository(session_factory=factory, auto_commit=True)
return AutomationProfileService(repo=repo)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_guarded_profile(
name: str = "acme/guarded",
description: str = "Profile with guards",
max_tool_calls: int = 5,
max_total_cost: float = 100.0,
tool_allowlist: list[str] | None = None,
tool_denylist: list[str] | None = None,
require_approval_for_writes: bool = True,
require_approval_for_apply: bool = False,
) -> AutomationProfile:
"""Create an AutomationProfile with guards attached."""
guards = AutomationGuard(
max_tool_calls_per_step=max_tool_calls,
max_total_cost=max_total_cost,
tool_allowlist=tool_allowlist,
tool_denylist=tool_denylist,
require_approval_for_writes=require_approval_for_writes,
require_approval_for_apply=require_approval_for_apply,
)
return AutomationProfile(
name=name,
description=description,
schema_version="1.0",
auto_strategize=0.5,
auto_execute=0.4,
auto_apply=0.3,
guards=guards,
)
def _write_temp_yaml(context: Context, content: str) -> str:
"""Write content to a temporary YAML file, tracked for cleanup."""
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(content)
if not hasattr(context, "_temp_files"):
context._temp_files = []
context._temp_files.append(path)
return path
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a fresh automation profile CLI runner for guards coverage")
def step_fresh_guards_runner(context: Context) -> None:
from unittest.mock import patch
context.runner = CliRunner()
context.result = None
context.guards_dict_result = None
context.profile_spec_result = None
context.guard_obj = None
context.guarded_profile = None
# Create an in-memory service and patch _get_service for this scenario
context._ap_service = _create_in_memory_profile_service()
context._ap_patcher = patch(
"cleveragents.cli.commands.automation_profile._get_service",
return_value=context._ap_service,
)
context._ap_patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(context._ap_patcher.stop)
# ---------------------------------------------------------------------------
# _guards_dict direct tests (lines 91-97)
# ---------------------------------------------------------------------------
@given(
"an AutomationGuard with max_tool_calls_per_step {calls:d} and max_total_cost {cost:g}"
)
def step_create_guard_with_calls_and_cost(
context: Context,
calls: int,
cost: float,
) -> None:
context.guard_obj = AutomationGuard(
max_tool_calls_per_step=calls,
max_total_cost=cost,
tool_allowlist=None,
tool_denylist=None,
require_approval_for_writes=False,
require_approval_for_apply=False,
)
@given(
'an AutomationGuard with tool_allowlist "{allowlist}" and tool_denylist "{denylist}"'
)
def step_create_guard_with_lists(
context: Context,
allowlist: str,
denylist: str,
) -> None:
context.guard_obj = AutomationGuard(
max_tool_calls_per_step=None,
max_total_cost=None,
tool_allowlist=allowlist.split(","),
tool_denylist=denylist.split(","),
require_approval_for_writes=False,
require_approval_for_apply=False,
)
@when("I call _guards_dict with the guard object")
def step_call_guards_dict(context: Context) -> None:
context.guards_dict_result = _guards_dict(context.guard_obj)
@when("I call _guards_dict with None")
def step_call_guards_dict_none(context: Context) -> None:
context.guards_dict_result = _guards_dict(None)
@then('the guards dict should contain key "{key}" with value {value:d}')
def step_guards_dict_int_value(context: Context, key: str, value: int) -> None:
result = context.guards_dict_result
assert result is not None, "Expected non-None result from _guards_dict"
assert key in result, f"Key '{key}' not found in guards dict: {result}"
assert result[key] == value, f"Expected {key}={value}, got {result[key]}"
@then('the guards dict should contain key "{key}" with value {value:g}')
def step_guards_dict_float_value(context: Context, key: str, value: float) -> None:
result = context.guards_dict_result
assert result is not None, "Expected non-None result from _guards_dict"
assert key in result, f"Key '{key}' not found in guards dict: {result}"
assert result[key] == value, f"Expected {key}={value}, got {result[key]}"
@then('the guards dict should contain key "{key}"')
def step_guards_dict_has_key(context: Context, key: str) -> None:
result = context.guards_dict_result
assert result is not None, "Expected non-None result from _guards_dict"
assert key in result, f"Key '{key}' not found in guards dict: {result}"
@then("the guards dict result should be None")
def step_guards_dict_is_none(context: Context) -> None:
assert context.guards_dict_result is None, (
f"Expected None, got {context.guards_dict_result}"
)
@then('the guards dict tool_allowlist should be "{expected}"')
def step_guards_dict_allowlist(context: Context, expected: str) -> None:
result = context.guards_dict_result
assert result is not None
expected_list = expected.split(",")
assert result["tool_allowlist"] == expected_list, (
f"Expected {expected_list}, got {result['tool_allowlist']}"
)
@then('the guards dict tool_denylist should be "{expected}"')
def step_guards_dict_denylist(context: Context, expected: str) -> None:
result = context.guards_dict_result
assert result is not None
expected_list = expected.split(",")
assert result["tool_denylist"] == expected_list, (
f"Expected {expected_list}, got {result['tool_denylist']}"
)
# ---------------------------------------------------------------------------
# _profile_spec_dict with guards (lines 91-97 indirect)
# ---------------------------------------------------------------------------
@given("an AutomationProfile with guards having require_approval_for_writes true")
def step_create_profile_with_guards(context: Context) -> None:
context.guarded_profile = _make_guarded_profile(
name="acme/spectest",
require_approval_for_writes=True,
)
@when("I call _profile_spec_dict with the guarded profile")
def step_call_profile_spec_dict(context: Context) -> None:
context.profile_spec_result = _profile_spec_dict(context.guarded_profile)
@then("the profile spec dict guards should not be None")
def step_spec_dict_guards_not_none(context: Context) -> None:
result = context.profile_spec_result
assert result is not None
assert result.get("guards") is not None, (
f"Expected guards to be non-None, got: {result.get('guards')}"
)
@then('the profile spec dict guards should have "{key}" as true')
def step_spec_dict_guards_key_true(context: Context, key: str) -> None:
result = context.profile_spec_result
assert result is not None
guards = result["guards"]
assert guards is not None
assert guards[key] is True, f"Expected {key}=True, got {guards[key]}"
# ---------------------------------------------------------------------------
# CLI show with guards - rich format (lines 194-204)
# ---------------------------------------------------------------------------
@given('a custom guarded profile "{name}" is stored in the repo')
def step_store_guarded_profile(context: Context, name: str) -> None:
profile = _make_guarded_profile(
name=name,
max_tool_calls=5,
max_total_cost=100.0,
tool_allowlist=["read_file", "search"],
tool_denylist=["rm_rf"],
require_approval_for_writes=True,
require_approval_for_apply=True,
)
context._ap_service._repo.upsert(profile)
@when('I run automation-profile show "{name}" in rich format')
def step_show_guarded_rich(context: Context, name: str) -> None:
context.result = context.runner.invoke(profile_app, ["show", name])
@when('I run automation-profile show "{name}" with format "{fmt}"')
def step_show_guarded_fmt(context: Context, name: str, fmt: str) -> None:
context.result = context.runner.invoke(
profile_app,
["show", name, "--format", fmt],
)
@then("the guards coverage show command should succeed")
def step_guards_coverage_show_succeed(context: Context) -> None:
assert context.result is not None
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('the guards coverage output should contain "{text}"')
def step_guards_coverage_output_contains(context: Context, text: str) -> None:
assert context.result is not None
assert text in context.result.output, (
f"Expected '{text}' in output. Got:\n{context.result.output}"
)
# ---------------------------------------------------------------------------
# CLI add with guards config (lines 194-204 via add path)
# ---------------------------------------------------------------------------
@given('a YAML config file for a guarded profile "{name}"')
def step_guarded_yaml_config(context: Context, name: str) -> None:
yaml_content = f"""\
name: {name}
description: Guarded profile for testing
schema_version: "1.0"
auto_strategize: 0.5
auto_execute: 0.4
auto_apply: 0.3
guards:
max_tool_calls_per_step: 8
max_total_cost: 200.0
tool_allowlist:
- read_file
- search
tool_denylist:
- rm_rf
require_approval_for_writes: true
require_approval_for_apply: false
"""
context.guarded_yaml_path = _write_temp_yaml(context, yaml_content)
@when("I run automation-profile add with the guarded config file")
def step_add_guarded_config(context: Context) -> None:
context.result = context.runner.invoke(
profile_app,
["add", "--config", context.guarded_yaml_path],
)
@then("the guards coverage add command should succeed")
def step_guards_coverage_add_succeed(context: Context) -> None:
assert context.result is not None
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
# ---------------------------------------------------------------------------
# CLI remove guarded profile with --format json (lines 91-97 via remove)
# ---------------------------------------------------------------------------
@when('I run automation-profile remove "{name}" with --yes and format "{fmt}"')
def step_remove_guarded_fmt(context: Context, name: str, fmt: str) -> None:
context.result = context.runner.invoke(
profile_app,
["remove", name, "--yes", "--format", fmt],
)
@then("the guards coverage remove command should succeed")
def step_guards_coverage_remove_succeed(context: Context) -> None:
assert context.result is not None
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)