Files
placeholder/features/steps/action_cli_edge_cases_coverage_steps.py
Jeff (CTO) d98666651f feat(cli): align action commands to spec
- Enforce config-only 'action create --config <file>' (remove legacy
  inline flags: --name, --strategy-actor, --execution-actor,
  --definition-of-done, --arg)
- Load config via ActionConfigSchema.from_yaml_file() then
  Action.from_config() with clear validation error surfacing
- Remove 'action available' subcommand (no draft state; actions are
  available by default)
- Add REGEX positional arg to 'action list' for name filtering
- Add Short Name and Definition of Done summary to all CLI outputs
- Add 'action list --namespace/-n' and '--state/-s' filters
- Update existing tests to match new config-only create flow
- Add features/action_cli_spec_alignment.feature (19 scenarios)
- Add robot/action_cli_spec.robot smoke tests
- Add benchmarks/action_cli_bench.py ASV benchmarks
- Add docs/reference/action_cli.md
2026-02-14 12:16:39 -05:00

453 lines
16 KiB
Python

"""Step definitions for action CLI edge cases coverage."""
from __future__ import annotations
import os
import tempfile
from datetime import datetime
from io import StringIO
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from rich.console import Console
from typer.testing import CliRunner
from cleveragents.cli.commands.action import _print_action
from cleveragents.cli.commands.action import app as action_app
from cleveragents.core.exceptions import (
NotFoundError,
)
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import NamespacedName
runner = CliRunner()
def _make_edge_action(
*,
name: str = "local/test-action",
state: ActionState = ActionState.AVAILABLE,
definition_of_done: str = "Test passes",
strategy_actor: str = "openai/gpt-4",
execution_actor: str = "openai/gpt-4",
arguments: list[ActionArgument] | None = None,
reusable: bool = True,
read_only: bool = False,
created_by: str | None = None,
description: str = "Test action",
long_description: str | None = "A test action for testing",
) -> Action:
"""Create an Action instance for edge case testing."""
return Action(
namespaced_name=NamespacedName.parse(name),
description=description,
long_description=long_description,
definition_of_done=definition_of_done,
strategy_actor=strategy_actor,
execution_actor=execution_actor,
arguments=arguments or [],
reusable=reusable,
read_only=read_only,
created_by=created_by,
state=state,
created_at=datetime.now(),
updated_at=datetime.now(),
)
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
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("an action edge case CLI runner")
def step_action_edge_case_cli_runner(context: Context) -> None:
"""Set up the CLI runner for edge case testing."""
context.runner = CliRunner()
@given("an action edge case mocked lifecycle service")
def step_action_edge_case_mocked_service(context: Context) -> None:
"""Set up a mock PlanLifecycleService for edge cases."""
context.mock_service = MagicMock()
context.service_patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=context.mock_service,
)
context.service_patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(context.service_patcher.stop)
# ---------------------------------------------------------------------------
# _print_action: Given steps
# ---------------------------------------------------------------------------
@given("an action edge case action with no arguments")
def step_edge_action_no_arguments(context: Context) -> None:
"""Create an action with an empty arguments list."""
context.edge_action = _make_edge_action(arguments=[])
@given("an action edge case action with multiple arguments")
def step_edge_action_multiple_arguments(context: Context) -> None:
"""Create an action with multiple arguments, some with descriptions."""
args = [
ActionArgument(
name="target_coverage",
arg_type=ArgumentType.INTEGER,
requirement=ArgumentRequirement.REQUIRED,
description="Target coverage percentage",
),
ActionArgument(
name="test_framework",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.OPTIONAL,
description="",
),
]
context.edge_action = _make_edge_action(arguments=args)
@given("an action edge case action without short description")
def step_edge_action_no_short_description(context: Context) -> None:
"""Create an action without a short_description (use minimal description)."""
context.edge_action = _make_edge_action(description="Minimal")
# ---------------------------------------------------------------------------
# _print_action: When steps
# ---------------------------------------------------------------------------
@when("I call action edge case print action")
def step_call_edge_print_action(context: Context) -> None:
"""Call _print_action and capture the console output."""
buf = StringIO()
test_console = Console(file=buf, force_terminal=False, width=200)
# Temporarily replace the module-level console so _print_action writes to buf
with patch("cleveragents.cli.commands.action.console", test_console):
_print_action(context.edge_action)
context.print_output = buf.getvalue()
# ---------------------------------------------------------------------------
# _print_action: Then steps
# ---------------------------------------------------------------------------
@then('the action edge case output should contain "(none)"')
def step_edge_output_contains_none(context: Context) -> None:
"""Verify output contains the (none) indicator for empty arguments."""
assert "(none)" in context.print_output, (
f"Expected '(none)' in output but got:\n{context.print_output}"
)
@then('the action edge case output should contain argument "{arg_name}"')
def step_edge_output_contains_argument(context: Context, arg_name: str) -> None:
"""Verify the output contains the named argument."""
assert arg_name in context.print_output, (
f"Expected '{arg_name}' in output but got:\n{context.print_output}"
)
@then('the action edge case output should contain "{text}"')
def step_edge_output_contains_text(context: Context, text: str) -> None:
"""Verify the output contains the given text."""
assert text in context.print_output, (
f"Expected '{text}' in output but got:\n{context.print_output}"
)
# ---------------------------------------------------------------------------
# Create command (config-only): Given / When / Then
# ---------------------------------------------------------------------------
_MULTI_ARG_YAML = """\
name: local/multi-arg
description: Multi arg action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Done
arguments:
- name: target
type: integer
required: true
description: Target value
- name: mode
type: string
required: false
description: Execution mode
"""
_READONLY_YAML = """\
name: local/readonly
description: Read-only action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Done
read_only: true
"""
_LONGDESC_YAML = """\
name: local/longdesc
description: Long desc action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Done
long_description: Detailed documentation text
"""
@given("an action edge case config with multiple args")
def step_edge_config_multi_args(context: Context) -> None:
"""Write config with multiple arguments."""
context.edge_config_path = _write_temp_yaml(context, _MULTI_ARG_YAML)
created = _make_edge_action(
name="local/multi-arg",
arguments=[
ActionArgument(
name="target",
arg_type=ArgumentType.INTEGER,
requirement=ArgumentRequirement.REQUIRED,
description="Target value",
),
ActionArgument(
name="mode",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.OPTIONAL,
description="Execution mode",
),
],
)
context.mock_service.create_action.return_value = created
@given("an action edge case config with read-only")
def step_edge_config_readonly(context: Context) -> None:
"""Write config with read_only flag."""
context.edge_config_path = _write_temp_yaml(context, _READONLY_YAML)
created = _make_edge_action(name="local/readonly", read_only=True)
context.mock_service.create_action.return_value = created
@given("an action edge case config with long description")
def step_edge_config_longdesc(context: Context) -> None:
"""Write config with long_description."""
context.edge_config_path = _write_temp_yaml(context, _LONGDESC_YAML)
created = _make_edge_action(
name="local/longdesc",
long_description="Detailed documentation text",
)
context.mock_service.create_action.return_value = created
@when("I run action edge case create with config")
def step_edge_create_with_config(context: Context) -> None:
"""Run create with --config flag."""
context.result = context.runner.invoke(
action_app,
["create", "--config", context.edge_config_path],
)
# ---------------------------------------------------------------------------
# Create command: Then steps
# ---------------------------------------------------------------------------
@then("the action edge case create should succeed")
def step_edge_create_succeeds(context: Context) -> None:
"""Verify the create command succeeded."""
assert context.result.exit_code == 0, (
f"CLI failed with exit code {context.result.exit_code}: {context.result.output}"
)
context.mock_service.create_action.assert_called_once()
@then("the action edge case service should receive 2 arguments")
def step_edge_service_receives_two_args(context: Context) -> None:
"""Verify the service was called with exactly 2 arguments."""
call_kwargs = context.mock_service.create_action.call_args[1]
assert len(call_kwargs["arguments"]) == 2, (
f"Expected 2 arguments, got {len(call_kwargs['arguments'])}"
)
@then("the action edge case service should receive read_only true")
def step_edge_service_receives_read_only(context: Context) -> None:
"""Verify the service was called with read_only=True."""
call_kwargs = context.mock_service.create_action.call_args[1]
assert call_kwargs["read_only"] is True
@then('the action edge case service should receive long description "{desc}"')
def step_edge_service_receives_long_desc(context: Context, desc: str) -> None:
"""Verify the service was called with the given long_description."""
call_kwargs = context.mock_service.create_action.call_args[1]
assert call_kwargs["long_description"] == desc
# ---------------------------------------------------------------------------
# List command: Given / When / Then
# ---------------------------------------------------------------------------
@given("there are action edge case existing actions")
def step_edge_existing_actions(context: Context) -> None:
"""Set up actions for list testing."""
context.edge_actions = [
_make_edge_action(
name="local/action-a",
state=ActionState.AVAILABLE,
),
_make_edge_action(
name="local/action-b",
state=ActionState.AVAILABLE,
),
]
context.mock_service.list_actions.return_value = context.edge_actions
@when('I run action edge case list with state "{state_value}"')
def step_edge_list_with_state(context: Context, state_value: str) -> None:
"""Run the list command with a --state filter."""
context.result = context.runner.invoke(action_app, ["list", "--state", state_value])
context.edge_state_value = state_value
@then("the action edge case list should succeed")
def step_edge_list_succeeds(context: Context) -> None:
"""Verify list command succeeded."""
assert context.result.exit_code == 0, f"CLI failed: {context.result.output}"
@then("the action edge case service list should be called with available state")
def step_edge_list_called_with_available(context: Context) -> None:
"""Verify list_actions was called with ActionState.AVAILABLE."""
call_kwargs = context.mock_service.list_actions.call_args[1]
assert call_kwargs["state"] == ActionState.AVAILABLE
@then("the action edge case service list should be called with archived state")
def step_edge_list_called_with_archived(context: Context) -> None:
"""Verify list_actions was called with ActionState.ARCHIVED."""
call_kwargs = context.mock_service.list_actions.call_args[1]
assert call_kwargs["state"] == ActionState.ARCHIVED
# ---------------------------------------------------------------------------
# Show command: Given / When / Then
# ---------------------------------------------------------------------------
@given("an action edge case that is findable by name")
def step_edge_show_by_name(context: Context) -> None:
"""Set up service so get_action_by_name returns the action."""
context.edge_action = _make_edge_action(name="local/name-only-action")
context.mock_service.get_action_by_name.return_value = context.edge_action
@given("an action edge case where name lookup fails")
def step_edge_show_name_fails(context: Context) -> None:
"""Set up service so get_action_by_name raises NotFoundError."""
context.mock_service.get_action_by_name.side_effect = NotFoundError(
resource_type="action", resource_id="nonexistent"
)
@when('I run action edge case show with name "{name}"')
def step_edge_show_by_name_cmd(context: Context, name: str) -> None:
"""Run the show command with the given name."""
context.result = context.runner.invoke(action_app, ["show", name])
@then("the action edge case show should succeed")
def step_edge_show_succeeds(context: Context) -> None:
"""Verify show command succeeded."""
assert context.result.exit_code == 0, f"CLI failed: {context.result.output}"
@then("the action edge case service should have used get_action_by_name")
def step_edge_service_used_get_action_by_name(context: Context) -> None:
"""Verify get_action_by_name was called."""
context.mock_service.get_action_by_name.assert_called_once()
@then("the action edge case show should abort with not found")
def step_edge_show_aborts_not_found(context: Context) -> None:
"""Verify show aborted with a not found message."""
assert context.result.exit_code != 0
assert "not found" in context.result.output.lower()
# ---------------------------------------------------------------------------
# Archive command: Given / When / Then
# ---------------------------------------------------------------------------
@given("an action edge case action findable by name for archive")
def step_edge_archive_by_name_setup(context: Context) -> None:
"""Set up an available action that can be found by name for archive."""
context.edge_action = _make_edge_action(
name="local/name-only-available", state=ActionState.AVAILABLE
)
context.mock_service.get_action_by_name.return_value = context.edge_action
context.mock_service.archive_action.return_value = _make_edge_action(
name="local/name-only-available", state=ActionState.ARCHIVED
)
@given("an action edge case where name lookup fails for archive")
def step_edge_archive_name_fails(context: Context) -> None:
"""Set up service so get_action_by_name fails for archive."""
context.mock_service.get_action_by_name.side_effect = NotFoundError(
resource_type="action", resource_id="nonexistent"
)
@when('I run action edge case archive with name "{name}"')
def step_edge_archive_by_name(context: Context, name: str) -> None:
"""Run the archive command with a namespaced name."""
context.result = context.runner.invoke(action_app, ["archive", name])
@then("the action edge case archive should succeed")
def step_edge_archive_succeeds(context: Context) -> None:
"""Verify archive command succeeded."""
assert context.result.exit_code == 0, f"CLI failed: {context.result.output}"
context.mock_service.archive_action.assert_called_once()
@then("the action edge case archive should abort with not found")
def step_edge_archive_aborts_not_found(context: Context) -> None:
"""Verify archive aborted with a not found message."""
assert context.result.exit_code != 0
assert "not found" in context.result.output.lower()