forked from HAL9000/cleveragents-core
d98666651f
- 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
453 lines
16 KiB
Python
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()
|