"""Step definitions for resource_handlers.feature. Tests the resource handler protocol, GitCheckoutHandler, FsDirectoryHandler, handler resolver, and ResourceHandlerService orchestration bridge. """ from __future__ import annotations from datetime import datetime from unittest.mock import MagicMock, PropertyMock from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ResourceCapabilities, SandboxStrategy, ) from cleveragents.domain.models.core.resource_slot import BindingResult from cleveragents.domain.models.core.resource_type import ( ResourceKind, ResourceTypeSpec, ) from cleveragents.domain.models.core.resource_type import ( SandboxStrategy as TypeSandboxStrategy, ) from cleveragents.infrastructure.sandbox.factory import SandboxFactory from cleveragents.infrastructure.sandbox.manager import SandboxManager from cleveragents.infrastructure.sandbox.protocol import ( SandboxContext, SandboxStatus, ) from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler from cleveragents.resource.handlers.protocol import ResourceHandler from cleveragents.resource.handlers.resolver import ( HandlerResolutionError, clear_handler_cache, resolve_handler, ) from cleveragents.tool.context import BoundResource # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_resource( resource_type_name: str, location: str | None = None, sandbox_strategy: SandboxStrategy | None = None, resource_id: str = "01KJ5C5TPMP8GGX3QC83E2MAQS", ) -> Resource: """Create a test Resource domain object.""" return Resource( resource_id=resource_id, resource_type_name=resource_type_name, classification=PhysVirt.PHYSICAL, location=location, sandbox_strategy=sandbox_strategy, capabilities=ResourceCapabilities( readable=True, writable=True, sandboxable=True ), ) def _make_mock_sandbox_manager() -> tuple[SandboxManager, MagicMock]: """Create a SandboxManager with a mock factory that tracks calls.""" mock_factory = MagicMock(spec=SandboxFactory) mock_sandbox = MagicMock() mock_sandbox.sandbox_id = "sb-test-001" type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED) mock_sandbox.context = SandboxContext( sandbox_id="sb-test-001", sandbox_path="/tmp/sandbox/sb-test-001", original_path="/tmp/original", resource_id="res-test", plan_id="plan-test", created_at=datetime.now(), ) mock_sandbox.create.return_value = mock_sandbox.context mock_factory.create_sandbox.return_value = mock_sandbox manager = SandboxManager(factory=mock_factory, cleanup_on_exit=False) return manager, mock_factory def _make_type_spec( name: str = "git-checkout", sandbox_strategy: str = "git_worktree", handler: str | None = "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler", ) -> ResourceTypeSpec: """Create a minimal ResourceTypeSpec for testing.""" return ResourceTypeSpec( name=name, description=f"Test {name} type", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=TypeSandboxStrategy(sandbox_strategy), user_addable=True, built_in=True, handler=handler, ) # --------------------------------------------------------------------------- # Protocol conformance # --------------------------------------------------------------------------- @then("GitCheckoutHandler should satisfy the ResourceHandler protocol") def step_git_handler_protocol(context: Context) -> None: handler = GitCheckoutHandler() assert isinstance(handler, ResourceHandler), ( "GitCheckoutHandler does not satisfy ResourceHandler protocol" ) @then("FsDirectoryHandler should satisfy the ResourceHandler protocol") def step_fs_handler_protocol(context: Context) -> None: handler = FsDirectoryHandler() assert isinstance(handler, ResourceHandler), ( "FsDirectoryHandler does not satisfy ResourceHandler protocol" ) # --------------------------------------------------------------------------- # GitCheckoutHandler # --------------------------------------------------------------------------- @given('a git-checkout resource with location "{location}"') def step_git_resource(context: Context, location: str) -> None: context.handler_resource = _make_resource("git-checkout", location=location) @given('a git-checkout resource at "{location}" using strategy "{strategy}"') def step_git_resource_with_strategy( context: Context, location: str, strategy: str ) -> None: context.handler_resource = _make_resource( "git-checkout", location=location, sandbox_strategy=SandboxStrategy(strategy), ) @given("a git-checkout resource without location") def step_git_resource_no_location(context: Context) -> None: context.handler_resource = _make_resource("git-checkout", location=None) @given("a sandbox manager with a mock factory") def step_mock_sandbox_manager(context: Context) -> None: context.handler_sandbox_manager, context.handler_mock_factory = ( _make_mock_sandbox_manager() ) @when('I resolve the resource with GitCheckoutHandler for plan "{plan_id}"') def step_resolve_git(context: Context, plan_id: str) -> None: handler = GitCheckoutHandler() context.handler_bound = handler.resolve( resource=context.handler_resource, plan_id=plan_id, slot_name="repo", sandbox_manager=context.handler_sandbox_manager, ) @when("I try to resolve the resource with GitCheckoutHandler") def step_try_resolve_git(context: Context) -> None: handler = GitCheckoutHandler() try: handler.resolve( resource=context.handler_resource, plan_id="PLAN_ERR", slot_name="repo", sandbox_manager=context.handler_sandbox_manager, ) context.handler_error = None except ValueError as exc: context.handler_error = exc # --------------------------------------------------------------------------- # FsDirectoryHandler # --------------------------------------------------------------------------- @given('an fs-directory resource with location "{location}"') def step_fs_resource(context: Context, location: str) -> None: context.handler_resource = _make_resource("fs-directory", location=location) @given("an fs-directory resource without location") def step_fs_resource_no_location(context: Context) -> None: context.handler_resource = _make_resource("fs-directory", location=None) @when('I resolve the resource with FsDirectoryHandler for plan "{plan_id}"') def step_resolve_fs(context: Context, plan_id: str) -> None: handler = FsDirectoryHandler() context.handler_bound = handler.resolve( resource=context.handler_resource, plan_id=plan_id, slot_name="workdir", sandbox_manager=context.handler_sandbox_manager, ) @when("I try to resolve the resource with FsDirectoryHandler") def step_try_resolve_fs(context: Context) -> None: handler = FsDirectoryHandler() try: handler.resolve( resource=context.handler_resource, plan_id="PLAN_ERR", slot_name="workdir", sandbox_manager=context.handler_sandbox_manager, ) context.handler_error = None except ValueError as exc: context.handler_error = exc # --------------------------------------------------------------------------- # Shared handler assertions # --------------------------------------------------------------------------- @then('the bound resource should have slot name "{name}"') def step_bound_slot(context: Context, name: str) -> None: assert context.handler_bound.slot_name == name @then('the bound resource should have resource type "{rtype}"') def step_bound_type(context: Context, rtype: str) -> None: assert context.handler_bound.resource_type == rtype @then("the bound resource sandbox path should not be empty") def step_bound_sandbox_not_empty(context: Context) -> None: assert context.handler_bound.sandbox_path, ( f"sandbox_path is empty: {context.handler_bound.sandbox_path!r}" ) @then('the sandbox was created with strategy "{strategy}"') def step_sandbox_strategy(context: Context, strategy: str) -> None: call_args = context.handler_mock_factory.create_sandbox.call_args assert call_args is not None, "create_sandbox was not called" assert call_args.kwargs.get("sandbox_strategy") == strategy or ( len(call_args.args) >= 3 and call_args.args[2] == strategy ), f"Expected strategy {strategy}, got {call_args}" @then("a handler ValueError should be raised") def step_handler_value_error(context: Context) -> None: assert context.handler_error is not None assert isinstance(context.handler_error, ValueError) @then('the handler error should mention "{text}"') def step_handler_error_text(context: Context, text: str) -> None: assert text.lower() in str(context.handler_error).lower(), ( f"Expected '{text}' in error: {context.handler_error}" ) # --------------------------------------------------------------------------- # Handler Resolver # --------------------------------------------------------------------------- @when('I resolve handler "{ref}"') def step_resolve_handler(context: Context, ref: str) -> None: clear_handler_cache() context.handler_resolved = resolve_handler(ref) context.handler_ref_used = ref @when("I resolve the same handler reference again") def step_resolve_handler_again(context: Context) -> None: context.handler_resolved_second = resolve_handler(context.handler_ref_used) @then("the resolved handler should be a GitCheckoutHandler instance") def step_resolved_is_git(context: Context) -> None: assert isinstance(context.handler_resolved, GitCheckoutHandler) @then("the resolved handler should be a FsDirectoryHandler instance") def step_resolved_is_fs(context: Context) -> None: assert isinstance(context.handler_resolved, FsDirectoryHandler) @then("both resolved handlers should be the same object") def step_same_object(context: Context) -> None: assert context.handler_resolved is context.handler_resolved_second @when("I try to resolve an empty handler reference") def step_try_resolve_empty_handler(context: Context) -> None: clear_handler_cache() try: resolve_handler("") context.handler_resolution_error = None except HandlerResolutionError as exc: context.handler_resolution_error = exc @when('I try to resolve handler "{ref}"') def step_try_resolve_handler(context: Context, ref: str) -> None: clear_handler_cache() try: resolve_handler(ref) context.handler_resolution_error = None except HandlerResolutionError as exc: context.handler_resolution_error = exc @then("a HandlerResolutionError should be raised") def step_resolution_error(context: Context) -> None: assert context.handler_resolution_error is not None assert isinstance(context.handler_resolution_error, HandlerResolutionError) @then('the resolution error should mention "{text}"') def step_resolution_error_text(context: Context, text: str) -> None: assert text.lower() in str(context.handler_resolution_error).lower(), ( f"Expected '{text}' in error: {context.handler_resolution_error}" ) # --------------------------------------------------------------------------- # ResourceHandlerService # --------------------------------------------------------------------------- @given("a resource handler service with mock lookups") def step_handler_service(context: Context) -> None: from cleveragents.application.services.resource_handler_service import ( ResourceHandlerService, ) manager, _mock_factory = _make_mock_sandbox_manager() resource = _make_resource("git-checkout", location="/tmp/test-repo") type_spec = _make_type_spec() context.handler_svc = ResourceHandlerService( sandbox_manager=manager, resource_lookup=lambda _name_or_id: resource, type_lookup=lambda _name: type_spec, ) context.handler_svc_manager = manager @given("a resource handler service with mock lookups and no handler string") def step_handler_service_no_handler(context: Context) -> None: from cleveragents.application.services.resource_handler_service import ( ResourceHandlerService, ) manager, _mock_factory = _make_mock_sandbox_manager() resource = _make_resource("fs-directory", location="/tmp/test-dir") type_spec = _make_type_spec( name="fs-directory", sandbox_strategy="copy_on_write", handler=None, ) context.handler_svc = ResourceHandlerService( sandbox_manager=manager, resource_lookup=lambda _name_or_id: resource, type_lookup=lambda _name: type_spec, ) @given('a binding result for slot "{slot}" with resource id "{res_id}"') def step_binding_result(context: Context, slot: str, res_id: str) -> None: if not hasattr(context, "handler_bindings"): context.handler_bindings = [] context.handler_binding = BindingResult( slot_name=slot, resource_id=res_id, resource_name=f"test-{res_id}", binding_mode="contextual", ) context.handler_bindings.append(context.handler_binding) @given('a deferred binding result for slot "{slot}"') def step_deferred_binding(context: Context, slot: str) -> None: if not hasattr(context, "handler_bindings"): context.handler_bindings = [] context.handler_deferred_binding = BindingResult( slot_name=slot, resource_id=None, binding_mode="parameter", deferred=True, ) context.handler_bindings.append(context.handler_deferred_binding) @when('I resolve the binding via resource handler service for plan "{plan_id}"') def step_svc_resolve_binding(context: Context, plan_id: str) -> None: context.handler_svc_bound = context.handler_svc.resolve_binding( binding=context.handler_binding, plan_id=plan_id, ) @when('I resolve all bindings via resource handler service for plan "{plan_id}"') def step_svc_resolve_all(context: Context, plan_id: str) -> None: context.handler_svc_all = context.handler_svc.resolve_bindings( bindings=context.handler_bindings, plan_id=plan_id, ) @when("I try to resolve the single deferred binding via resource handler service") def step_svc_try_resolve_deferred(context: Context) -> None: try: context.handler_svc.resolve_binding( binding=context.handler_deferred_binding, plan_id="PLAN_ERR", ) context.handler_error = None except ValueError as exc: context.handler_error = exc @then("the handler service should return a BoundResource") def step_svc_is_bound(context: Context) -> None: assert isinstance(context.handler_svc_bound, BoundResource) @then('the handler service BoundResource slot should be "{slot}"') def step_svc_bound_slot(context: Context, slot: str) -> None: assert context.handler_svc_bound.slot_name == slot @then("the handler service BoundResource sandbox path should not be empty") def step_svc_bound_path(context: Context) -> None: assert context.handler_svc_bound.sandbox_path, ( f"sandbox_path is empty: {context.handler_svc_bound.sandbox_path!r}" ) @then("the handler service should return {count:d} bound resource") def step_svc_count(context: Context, count: int) -> None: assert len(context.handler_svc_all) == count @then('the handler service should have resolved slot "{slot}"') def step_svc_has_slot(context: Context, slot: str) -> None: assert slot in context.handler_svc_all