Files
cleveragents-core/features/steps/actor_cli_steps.py
T

525 lines
18 KiB
Python

"""Step definitions for the Actor CLI feature."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.core.exceptions import (
BusinessRuleViolation,
NotFoundError,
ValidationError,
)
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/test-actor",
provider: str = "default-provider",
model: str = "default-model",
config: dict[str, Any] | None = None,
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
is_default: bool = False,
is_built_in: bool = False,
) -> Actor:
blob = config or {}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=graph_descriptor,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=is_default,
)
def _register_cleanup(context, path: Path) -> None:
context._cleanup_handlers.append(lambda: path.unlink(missing_ok=True))
@given("an actor CLI runner")
def step_impl(context):
context.runner = CliRunner()
@given("I have an actor JSON config file")
def step_impl(context):
context.actor_config_data = {"temperature": 0.5, "max_tokens": 256}
handle = tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
)
json.dump(context.actor_config_data, handle)
handle.flush()
handle.close()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
@given("I have an actor list config file")
def step_impl(context):
context.actor_config_data = [1, 2, 3]
handle = tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
)
json.dump(context.actor_config_data, handle)
handle.flush()
handle.close()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
@given("I have an actor YAML config file returning empty data")
def step_impl(context):
context.actor_config_data = {}
handle = tempfile.NamedTemporaryFile(delete=False, suffix=".yaml")
handle.write(b"{}\n")
handle.flush()
handle.close()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
@given("I have an actor YAML-only config file")
def step_impl(context):
context.actor_config_data = {"yaml_only": True}
handle = tempfile.NamedTemporaryFile(
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
)
handle.write("yaml_only: true\n")
handle.flush()
handle.close()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
@given("I have an empty actor YAML config file")
def step_impl(context):
context.actor_config_data = {}
handle = tempfile.NamedTemporaryFile(
delete=False, suffix=".yaml", mode="w", encoding="utf-8"
)
handle.write("")
handle.flush()
handle.close()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
@when("I run actor add without config")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = mock_container.return_value.actor_service.return_value
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--provider",
"default-provider",
"--model",
"default-model",
],
)
context.mock_actor_service = mock_actor_service
@when("I run actor add with that config")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
if isinstance(context.actor_config_data, dict):
mock_actor = _make_actor(config=context.actor_config_data)
mock_actor_service.upsert_actor.return_value = mock_actor
else:
mock_actor_service.upsert_actor.side_effect = ValidationError(
"Config must be a JSON/YAML object"
)
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--provider",
"default-provider",
"--model",
"default-model",
"--config",
str(context.actor_config_path),
],
)
context.mock_actor_service = mock_actor_service
context.expected_config_blob = (
context.actor_config_data
if isinstance(context.actor_config_data, dict)
else None
)
context.expected_error = "Config must be a JSON/YAML object"
@when("I run actor add with missing config path")
def step_impl(context):
missing_path = Path(tempfile.gettempdir()) / "missing-actor-config.json"
with patch("cleveragents.application.container.get_container") as mock_container:
mock_container.return_value.actor_service.return_value = MagicMock()
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--provider",
"default-provider",
"--model",
"default-model",
"--config",
str(missing_path),
],
)
context.expected_error = "Config file not found"
@when("I run actor add with business rule violation")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.upsert_actor.side_effect = BusinessRuleViolation("invalid")
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--provider",
"default-provider",
"--model",
"default-model",
],
)
context.expected_error = "Error:"
@when("I run actor update with safe flag and yaml config")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
current_actor = _make_actor(
name="local/existing-actor",
provider="existing-provider",
model="existing-model",
config={"existing": True},
unsafe=True,
)
mock_actor_service.get_actor.return_value = current_actor
updated_actor = _make_actor(
name=current_actor.name,
provider=current_actor.provider,
model=current_actor.model,
config=context.actor_config_data,
unsafe=False,
)
mock_actor_service.upsert_actor.return_value = updated_actor
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(
actor_app,
[
"update",
current_actor.name,
"--config",
str(context.actor_config_path),
"--safe",
],
)
context.mock_actor_service = mock_actor_service
context.current_actor = current_actor
@when("I run actor update with unsafe flag")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
current_actor = _make_actor(
name="local/unsafe-actor",
provider="unsafe-provider",
model="unsafe-model",
config={"existing": True},
unsafe=False,
)
mock_actor_service.get_actor.return_value = current_actor
updated_actor = _make_actor(
name=current_actor.name,
provider=current_actor.provider,
model=current_actor.model,
config=current_actor.config_blob,
unsafe=True,
)
mock_actor_service.upsert_actor.return_value = updated_actor
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(
actor_app,
[
"update",
current_actor.name,
"--unsafe",
],
)
context.mock_actor_service = mock_actor_service
context.current_actor = current_actor
@when("I run actor update with validation error")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
current_actor = _make_actor(name="local/invalid-update")
mock_actor_service.get_actor.return_value = current_actor
mock_actor_service.upsert_actor.side_effect = ValidationError("bad update")
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(
actor_app,
[
"update",
current_actor.name,
],
)
context.expected_error = "Error:"
@when("I run actor update for missing actor")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.get_actor.side_effect = NotFoundError(
resource_type="actor", resource_id="local/missing"
)
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(
actor_app,
[
"update",
"missing-actor",
"--provider",
"default-provider",
],
)
context.expected_error = "Actor not found"
@when("I run actor update with conflicting flags")
def step_impl(context):
context.result = context.runner.invoke(
actor_app,
[
"update",
"local/conflict",
"--unsafe",
"--safe",
],
)
context.expected_error = "Choose only one of --unsafe or --safe"
@when("I run actor remove successfully")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["remove", "local/removable"])
context.mock_actor_service = mock_actor_service
@when("I run actor remove and it fails validation")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.remove_actor.side_effect = ValidationError("invalid")
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["remove", "local/removable"])
context.expected_error = "Error:"
@when("I run actor list with no actors")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.list_actors.return_value = []
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["list"])
@when("I run actor list with two actors")
def step_impl(context):
actors = [
_make_actor(name="local/first", provider="p1", model="m1"),
_make_actor(name="local/second", provider="p2", model="m2", unsafe=True),
]
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.list_actors.return_value = actors
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["list"])
context.actors = actors
@when("I run actor show successfully")
def step_impl(context):
actor = _make_actor(name="local/show", provider="provider", model="model")
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.get_actor.return_value = actor
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["show", actor.name])
context.actor = actor
@when("I run actor show with validation error")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.get_actor.side_effect = ValidationError("bad")
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["show", "local/show"])
context.expected_error = "Error:"
@when("I run set-default actor successfully")
def step_impl(context):
actor = _make_actor(name="provider/model", provider="provider", model="model")
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.set_default_actor.return_value = actor
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["set-default", actor.name])
context.actor = actor
@when("I run set-default actor with violation")
def step_impl(context):
with patch("cleveragents.application.container.get_container") as mock_container:
mock_actor_service = MagicMock()
mock_actor_service.set_default_actor.side_effect = BusinessRuleViolation("nope")
mock_container.return_value.actor_service.return_value = mock_actor_service
context.result = context.runner.invoke(actor_app, ["set-default", "local/fail"])
context.expected_error = "Error:"
@then("the actor add should succeed with default config")
def step_impl(context):
assert context.result.exit_code == 0
context.mock_actor_service.upsert_actor.assert_called_once()
@then("the actor add should pass the loaded config")
def step_impl(context):
assert context.result.exit_code == 0
context.mock_actor_service.upsert_actor.assert_called_once()
call_kwargs = context.mock_actor_service.upsert_actor.call_args.kwargs
assert call_kwargs.get("config_blob") == context.expected_config_blob
@then("the actor command should fail with bad parameter")
def step_impl(context):
assert context.result.exit_code != 0
if hasattr(context, "expected_error"):
assert context.expected_error in context.result.output
@then("the actor update should set safe and merge config")
def step_impl(context):
assert context.result.exit_code == 0
context.mock_actor_service.upsert_actor.assert_called_once_with(
name=context.current_actor.name,
provider=context.current_actor.provider,
model=context.current_actor.model,
config_blob=context.actor_config_data,
graph_descriptor=context.current_actor.graph_descriptor,
unsafe=False,
set_default=False,
is_built_in=context.current_actor.is_built_in,
)
@then("the actor update should set unsafe flag")
def step_impl(context):
assert context.result.exit_code == 0
context.mock_actor_service.upsert_actor.assert_called_once_with(
name=context.current_actor.name,
provider=context.current_actor.provider,
model=context.current_actor.model,
config_blob=context.current_actor.config_blob,
graph_descriptor=context.current_actor.graph_descriptor,
unsafe=True,
set_default=False,
is_built_in=context.current_actor.is_built_in,
)
@then("the actor command should abort for missing actor")
def step_impl(context):
assert context.result.exit_code != 0
assert context.expected_error in context.result.output
@then("the actor remove should succeed")
def step_impl(context):
assert context.result.exit_code == 0
context.mock_actor_service.remove_actor.assert_called_once_with("local/removable")
@then("the actor command should abort with error")
def step_impl(context):
assert context.result.exit_code != 0
if hasattr(context, "expected_error"):
assert context.expected_error in context.result.output
@then("the actor list should report empty state")
def step_impl(context):
assert context.result.exit_code == 0
assert "No actors configured." in context.result.output
@then("the actor list should render rows")
def step_impl(context):
assert context.result.exit_code == 0
for actor in context.actors:
assert actor.name in context.result.output
@then("the actor show should display details")
def step_impl(context):
assert context.result.exit_code == 0
assert context.actor.name in context.result.output
@then("the set default actor should display details")
def step_impl(context):
assert context.result.exit_code == 0
assert context.actor.name in context.result.output