1a0a501f66
- extract BaseResourceHandler to eliminate ~90% duplication between GitCheckoutHandler and FsDirectoryHandler - raise RuntimeError when sandbox.context is None instead of silent empty string fallback - add threading.Lock to resolver handler cache for thread safety - type resource_lookup/type_lookup as Callable instead of Any - log original HandlerResolutionError at DEBUG before fallback - use behave.runner.Context in step definitions per repo convention
463 lines
16 KiB
Python
463 lines
16 KiB
Python
"""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
|