"""Step definitions for Resource CLI feature tests.""" from __future__ import annotations import json import tempfile from io import StringIO from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from rich.console import Console from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from cleveragents.application.services.resource_registry_service import ( ResourceRegistryService, ) from cleveragents.core.exceptions import ( CleverAgentsError, NotFoundError, ValidationError, ) from cleveragents.infrastructure.database.models import Base def _make_service(context: Context) -> ResourceRegistryService: """Create or return a cached ResourceRegistryService.""" if not hasattr(context, "resource_cli_service"): engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) context.resource_cli_service = ResourceRegistryService(session_factory=factory) return context.resource_cli_service def _capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]: """Run a CLI function capturing its console output and success status.""" import contextlib buf = StringIO() # force_terminal=False and highlight=False prevent Rich from emitting # ANSI escape codes (bold, syntax highlighting) when FORCE_COLOR is set # or a real colour terminal is detected. console = Console( file=buf, width=200, no_color=True, highlight=False, force_terminal=False ) import cleveragents.cli.commands.resource as resource_mod orig_console = resource_mod.console resource_mod.console = console failed = False try: with contextlib.redirect_stdout(buf): func(*args, **kwargs) except SystemExit: failed = True except Exception: failed = True finally: resource_mod.console = orig_console return buf.getvalue(), failed @given("a fresh in-memory resource registry") def step_fresh_registry(context: Context) -> None: """Set up a fresh in-memory database for resource tests.""" engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) context.resource_cli_service = ResourceRegistryService(session_factory=factory) context.resource_cli_output = "" context.resource_cli_failed = False @given("built-in types are bootstrapped") def step_bootstrap_builtins(context: Context) -> None: """Bootstrap built-in resource types.""" service = _make_service(context) service.bootstrap_builtin_types() @given("a valid custom resource type YAML file") def step_create_custom_yaml(context: Context) -> None: """Create a temporary YAML file for a custom resource type.""" yaml_content = """ name: test/custom-type description: A test custom resource type resource_kind: physical sandbox_strategy: copy_on_write user_addable: true cli_args: - name: path type: path required: true description: Path to the resource """ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: tmp.write(yaml_content) tmp.flush() context.resource_cli_yaml_path = tmp.name def _patch_service(context: Context) -> Any: """Monkey-patch the container to return our in-memory service.""" import cleveragents.cli.commands.resource as resource_mod orig_fn = resource_mod._get_registry_service def _mock_get() -> ResourceRegistryService: return _make_service(context) resource_mod._get_registry_service = _mock_get return orig_fn def _unpatch_service(orig_fn: Any) -> None: """Restore original service getter.""" import cleveragents.cli.commands.resource as resource_mod resource_mod._get_registry_service = orig_fn # ---- Resource Type List ---- @when("I run resource type list") def step_run_type_list(context: Context) -> None: """Run resource type list command.""" from cleveragents.cli.commands.resource import type_list orig = _patch_service(context) try: output, failed = _capture_output(type_list, fmt="rich") context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource type list with format "{fmt}"') def step_run_type_list_fmt(context: Context, fmt: str) -> None: """Run resource type list with specific format.""" from cleveragents.cli.commands.resource import type_list orig = _patch_service(context) try: output, failed = _capture_output(type_list, fmt=fmt) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Resource Type Show ---- @when('I run resource type show "{name}" using default format') def step_run_type_show(context: Context, name: str) -> None: """Run resource type show command.""" from cleveragents.cli.commands.resource import type_show orig = _patch_service(context) try: output, failed = _capture_output(type_show, name=name, fmt="rich") context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource type show "{name}" using format "{fmt}"') def step_run_type_show_fmt(context: Context, name: str, fmt: str) -> None: """Run resource type show with specific format.""" from cleveragents.cli.commands.resource import type_show orig = _patch_service(context) try: output, failed = _capture_output(type_show, name=name, fmt=fmt) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Resource Type Add ---- def _do_type_add(context: Context) -> None: """Run resource type add with the YAML config file (shared impl).""" from cleveragents.cli.commands.resource import type_add config_path = Path(context.resource_cli_yaml_path) orig = _patch_service(context) try: output, failed = _capture_output( type_add, config=config_path, update=False, fmt="rich" ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when("I run resource type add with the config file") def step_run_type_add(context: Context) -> None: """Run resource type add with the YAML config file.""" _do_type_add(context) @given("I run resource type add with the config file") def step_given_type_add(context: Context) -> None: """Run resource type add in Given context.""" _do_type_add(context) @when("I run resource type add with a nonexistent config file") def step_run_type_add_nonexistent(context: Context) -> None: """Run resource type add with a nonexistent file.""" from cleveragents.cli.commands.resource import type_add orig = _patch_service(context) try: output, failed = _capture_output( type_add, config=Path("/tmp/nonexistent_resource_type.yaml"), update=False, fmt="rich", ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Resource Type Remove ---- @when('I run resource type remove "{name}" with yes flag') def step_run_type_remove(context: Context, name: str) -> None: """Run resource type remove with --yes flag.""" from cleveragents.cli.commands.resource import type_remove orig = _patch_service(context) try: output, failed = _capture_output(type_remove, name=name, yes=True) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Resource Add ---- def _do_resource_add( context: Context, type_name: str, name: str, path: str | None, *, branch: str | None = None, description: str | None = None, image: str | None = None, container_id: str | None = None, mount: list[str] | None = None, url: str | None = None, clone_into: str | None = None, read_only: bool = False, update: bool = False, fmt: str | None = None, ) -> None: """Run resource add command (shared impl).""" from cleveragents.cli.commands.resource import resource_add orig = _patch_service(context) try: resolved_fmt = fmt or getattr(context, "resource_cli_format", "rich") output, failed = _capture_output( resource_add, type_name=type_name, name=name, path=path, branch=branch, description=description, image=image, container_id=container_id, mount=mount, url=url, clone_into=clone_into, read_only=read_only, update=update, fmt=resolved_fmt, ) context.resource_cli_output = output context.resource_cli_failed = failed finally: if fmt is None and hasattr(context, "resource_cli_format"): delattr(context, "resource_cli_format") _unpatch_service(orig) @when('I run resource add "{type_name}" "{name}" with path "{path}"') def step_run_resource_add( context: Context, type_name: str, name: str, path: str ) -> None: """Run resource add command.""" _do_resource_add(context, type_name, name, path) @given('I run resource add "{type_name}" "{name}" with path "{path}"') def step_given_resource_add( context: Context, type_name: str, name: str, path: str ) -> None: """Run resource add in Given context.""" _do_resource_add(context, type_name, name, path) @when('I run resource add "{type_name}" "{name}" with url "{url_value}"') def step_run_resource_add_url( context: Context, type_name: str, name: str, url_value: str ) -> None: """Run resource add for a resource using only --url.""" _do_resource_add(context, type_name, name, None, url=url_value) @when( 'I run resource add type "{type_name}" name "{name}" path "{path}" url "{url_value}"' ) def step_run_resource_add_path_url( context: Context, type_name: str, name: str, path: str, url_value: str ) -> None: """Run resource add providing both path and url flags.""" _do_resource_add(context, type_name, name, path, url=url_value) @given('the resource CLI output format is "{fmt}"') def step_set_resource_cli_format(context: Context, fmt: str) -> None: """Set the desired output format for the next resource CLI command.""" context.resource_cli_format = fmt @given("auto discovery will fail during resource registration") def step_auto_discovery_failure(context: Context) -> None: """Force ResourceRepository.auto_discover_children to raise during the next call.""" patcher = patch( "cleveragents.infrastructure.database.repositories.ResourceRepository.auto_discover_children", side_effect=RuntimeError("Simulated auto-discovery failure"), ) patcher.start() context.add_cleanup(patcher.stop) # ---- Resource List ---- @when("I run resource list") def step_run_resource_list(context: Context) -> None: """Run resource list command.""" from cleveragents.cli.commands.resource import resource_list orig = _patch_service(context) try: output, failed = _capture_output(resource_list, type_filter=None, fmt="rich") context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource list with type filter "{type_name}"') def step_run_resource_list_type(context: Context, type_name: str) -> None: """Run resource list with type filter.""" from cleveragents.cli.commands.resource import resource_list orig = _patch_service(context) try: output, failed = _capture_output( resource_list, type_filter=type_name, fmt="rich" ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource list with format "{fmt}"') def step_run_resource_list_fmt(context: Context, fmt: str) -> None: """Run resource list with specific format.""" from cleveragents.cli.commands.resource import resource_list orig = _patch_service(context) try: output, failed = _capture_output(resource_list, type_filter=None, fmt=fmt) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Resource Show ---- @when('I run resource show "{name}" using default format') def step_run_resource_show(context: Context, name: str) -> None: """Run resource show command.""" from cleveragents.cli.commands.resource import resource_show orig = _patch_service(context) try: output, failed = _capture_output(resource_show, resource=name, fmt="rich") context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource show "{name}" using format "{fmt}"') def step_run_resource_show_fmt(context: Context, name: str, fmt: str) -> None: """Run resource show with specific format.""" from cleveragents.cli.commands.resource import resource_show orig = _patch_service(context) try: output, failed = _capture_output(resource_show, resource=name, fmt=fmt) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Resource Remove ---- @when('I run resource remove "{name}" with yes flag') def step_run_resource_remove(context: Context, name: str) -> None: """Run resource remove with --yes flag.""" from cleveragents.cli.commands.resource import resource_remove orig = _patch_service(context) try: output, failed = _capture_output(resource_remove, resource=name, yes=True) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # ---- Assertions ---- @then('the resource output should contain "{text}"') def step_resource_output_contains(context: Context, text: str) -> None: """Check that CLI output contains the expected text.""" output = context.resource_cli_output assert text.lower() in output.lower(), ( f"Expected '{text}' in output, got:\n{output}" ) @then("the resource command should succeed") def step_resource_command_succeeded(context: Context) -> None: """Ensure the resource CLI command completed successfully.""" failed = getattr(context, "resource_cli_failed", False) assert not failed, "Expected command to succeed but it failed" @then("the resource command should fail") def step_resource_command_failed(context: Context) -> None: """Check that the command failed.""" assert context.resource_cli_failed, "Expected command to fail but it succeeded" @then( 'the resource registry resource "{resource_name}" should have property ' '"{property_name}" equal to "{expected_value}"' ) def step_resource_property_equals( context: Context, resource_name: str, property_name: str, expected_value: str ) -> None: """Assert that the stored resource has the expected property value.""" service = _make_service(context) resource = service.show_resource(resource_name) properties = resource.properties or {} actual_value = properties.get(property_name) assert actual_value == expected_value, ( f"Expected property '{property_name}' to be '{expected_value}', got '{actual_value}'" ) @then("the resource output should be valid JSON") def step_resource_output_valid_json(context: Context) -> None: """Check that the output is valid JSON.""" output = context.resource_cli_output.strip() try: json.loads(output) except json.JSONDecodeError as exc: raise AssertionError(f"Output is not valid JSON: {exc}\n{output}") from exc @then('the resource JSON output should contain key "{key}"') def step_resource_json_has_key(context: Context, key: str) -> None: """Check that the JSON output contains a specific key.""" output = context.resource_cli_output.strip() data = json.loads(output) if isinstance(data, list): assert len(data) > 0, "JSON output is an empty list" assert key in data[0], f"Key '{key}' not found in first item of JSON list" else: if key in data: return payload = data.get("data") if isinstance(data, dict) else None if isinstance(payload, dict) and key in payload: return raise AssertionError(f"Key '{key}' not found in JSON output") @then('the resource registry should not contain resource "{name}"') def step_registry_missing_resource(context: Context, name: str) -> None: """Ensure that a resource was not persisted in the registry.""" service = _make_service(context) try: service.show_resource(name) except NotFoundError: return raise AssertionError(f"Resource '{name}' was persisted despite failure") # --------------------------------------------------------------------------- # Additional steps for resource_cli_coverage.feature # --------------------------------------------------------------------------- @when("I run resource type add with the config file using update flag") def step_run_type_add_update(context: Context) -> None: """Run resource type add with --update flag.""" from cleveragents.cli.commands.resource import type_add config_path = Path(context.resource_cli_yaml_path) orig = _patch_service(context) try: output, failed = _capture_output( type_add, config=config_path, update=True, fmt="rich" ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @given("a custom resource type YAML file with invalid schema") def step_create_invalid_yaml(context: Context) -> None: """Create a temp YAML file with invalid schema (missing required fields).""" yaml_content = """ name: 123 description: Missing resource_kind and other required fields """ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: tmp.write(yaml_content) tmp.flush() context.resource_cli_yaml_path = tmp.name @when('I run resource type add with the config file and format "{fmt}"') def step_run_type_add_fmt(context: Context, fmt: str) -> None: """Run resource type add with a specific output format.""" from cleveragents.cli.commands.resource import type_add config_path = Path(context.resource_cli_yaml_path) orig = _patch_service(context) try: output, failed = _capture_output( type_add, config=config_path, update=False, fmt=fmt ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource type remove "{name}" without yes flag') def step_run_type_remove_no_yes(context: Context, name: str) -> None: """Run resource type remove without --yes (confirmation will be denied).""" from unittest.mock import patch from cleveragents.cli.commands.resource import type_remove orig = _patch_service(context) try: # Mock typer.confirm to return False (user declines) with patch( "cleveragents.cli.commands.resource.typer.confirm", return_value=False ): output, failed = _capture_output(type_remove, name=name, yes=False) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when( 'I run resource add formatted as "{fmt}" type "{type_name}"' ' name "{name}" path "{path}"' ) def step_run_resource_add_fmt( context: Context, fmt: str, type_name: str, name: str, path: str ) -> None: """Run resource add with a specific output format.""" from cleveragents.cli.commands.resource import resource_add orig = _patch_service(context) try: output, failed = _capture_output( resource_add, type_name=type_name, name=name, path=path, branch=None, description=None, read_only=False, clone_into=None, update=False, fmt=fmt, ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when( 'I run resource add with extras type "{type_name}" name "{name}"' ' path "{path}" branch "{branch}" description "{desc}"' ) def step_run_resource_add_branch_desc( context: Context, type_name: str, name: str, path: str, branch: str, desc: str, ) -> None: """Run resource add with branch and description flags.""" from cleveragents.cli.commands.resource import resource_add orig = _patch_service(context) try: output, failed = _capture_output( resource_add, type_name=type_name, name=name, path=path, branch=branch if branch else None, description=desc, read_only=False, clone_into=None, update=False, fmt="rich", ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @given('I add a resource referencing custom type "{type_name}"') def step_add_resource_referencing_type(context: Context, type_name: str) -> None: """Add a resource instance that references the given type.""" service = _make_service(context) service.register_resource( type_name=type_name, name="local/ref-test", location="/tmp/ref", description="Test resource for type removal test", read_only=False, properties={"path": "/tmp/ref"}, ) @given("I add a resource with a long description") def step_add_resource_long_desc(context: Context) -> None: """Add a resource with a description exceeding the 40-char truncation limit.""" from cleveragents.cli.commands.resource import resource_add long_desc = ( "This is a very long description that exceeds " "the forty character limit for display" ) orig = _patch_service(context) try: output, failed = _capture_output( resource_add, type_name="git-checkout", name="local/long-desc", path="/tmp/ld", branch=None, description=long_desc, read_only=False, clone_into=None, update=False, fmt="rich", ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when('I run resource remove "{name}" without yes flag') def step_run_resource_remove_no_yes(context: Context, name: str) -> None: """Run resource remove without --yes (confirmation will be denied).""" from cleveragents.cli.commands.resource import resource_remove orig = _patch_service(context) try: with patch( "cleveragents.cli.commands.resource.typer.confirm", return_value=False ): output, failed = _capture_output(resource_remove, resource=name, yes=False) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) # --------------------------------------------------------------------------- # Error-mocking steps for CleverAgentsError / ValidationError catch-all paths # --------------------------------------------------------------------------- def _make_error_service( method_name: str, error_cls: type = CleverAgentsError ) -> MagicMock: """Create a mock service where one method raises an error.""" svc = MagicMock(spec=ResourceRegistryService) err = error_cls(message="Simulated error") getattr(svc, method_name).side_effect = err return svc def _patch_error_service( context: Context, method_name: str, error_cls: type = CleverAgentsError ) -> None: """Store a mock service on the context that errors on a specific method.""" context.resource_cli_service = _make_error_service(method_name, error_cls) @given("a service that raises CleverAgentsError on list_types") def step_service_error_list_types(context: Context) -> None: _patch_error_service(context, "list_types") @given("a service that raises CleverAgentsError on show_type") def step_service_error_show_type(context: Context) -> None: _patch_error_service(context, "show_type") @given("a service that raises CleverAgentsError on register_type") def step_service_error_register_type(context: Context) -> None: _patch_error_service(context, "register_type") @given("a service that raises CleverAgentsError on register_resource") def step_service_error_register_resource(context: Context) -> None: _patch_error_service(context, "register_resource") @given("a service that raises ValidationError on register_resource") def step_service_validation_register_resource(context: Context) -> None: _patch_error_service(context, "register_resource", ValidationError) @given("a service that raises CleverAgentsError on list_resources") def step_service_error_list_resources(context: Context) -> None: _patch_error_service(context, "list_resources") @given("a service that raises CleverAgentsError on show_resource") def step_service_error_show_resource(context: Context) -> None: _patch_error_service(context, "show_resource") @given("a type without cli_args is registered") def step_type_without_cli_args(context: Context) -> None: """Register a custom type that has no cli_args.""" service = _make_service(context) yaml_content = """ name: test/no-args description: A type with no CLI arguments resource_kind: physical sandbox_strategy: copy_on_write user_addable: true cli_args: [] """ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: tmp.write(yaml_content) tmp.flush() service.register_type(Path(tmp.name)) @given("I add a resource with empty properties") def step_add_resource_empty_props(context: Context) -> None: """Add a resource with no properties at all.""" service = _make_service(context) service.register_resource( type_name="git-checkout", name="local/empty-props", location=None, description="No properties resource", read_only=False, properties=None, ) @when('I run resource add "{type_name}" "{name}" with clone-into "{clone_into_val}"') def step_run_resource_add_clone_into( context: Context, type_name: str, name: str, clone_into_val: str ) -> None: """Run resource add with --clone-into flag.""" from cleveragents.cli.commands.resource import resource_add orig = _patch_service(context) try: output, failed = _capture_output( resource_add, type_name=type_name, name=name, path=None, branch=None, description=None, image=None, clone_into=clone_into_val, read_only=False, fmt="rich", ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig) @when("I run resource type add with path to missing file") def step_run_type_add_missing_file(context: Context) -> None: """Run type_add directly with a path that triggers FileNotFoundError.""" from cleveragents.cli.commands.resource import type_add orig = _patch_service(context) try: output, failed = _capture_output( type_add, config=Path("/tmp/absolutely_does_not_exist_xyz.yaml"), update=False, fmt="rich", ) context.resource_cli_output = output context.resource_cli_failed = failed finally: _unpatch_service(orig)