"""Behave step definitions for Custom Sandbox Strategy Registration. Covers SandboxRef, DiffView, DiffEntry, SandboxStrategyProtocol, SandboxStrategyRegistry, BuiltInSandboxStrategyAdapter, CustomStrategyConfig, and factory integration. Based on issue #586. """ from __future__ import annotations import os import tempfile from datetime import datetime from typing import Any from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from pydantic import ValidationError from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ResourceCapabilities, ) from cleveragents.domain.models.core.resource import ( SandboxStrategy as SandboxStrategyEnum, ) from cleveragents.domain.models.core.sandbox_strategy import ( DiffEntry, DiffView, SandboxRef, SandboxStrategyProtocol, ) from cleveragents.infrastructure.plugins.exceptions import ( ProtocolMismatchError, ) from cleveragents.infrastructure.sandbox.factory import SandboxFactory from cleveragents.infrastructure.sandbox.strategy_adapter import ( BuiltInSandboxStrategyAdapter, ) from cleveragents.infrastructure.sandbox.strategy_registry import ( CustomStrategyConfig, SandboxStrategyRegistry, ) # --------------------------------------------------------------------------- # ULID helper # --------------------------------------------------------------------------- _COUNTER = 0 def _ulid(name: str) -> str: global _COUNTER _COUNTER += 1 base = abs(hash(name)) % (10**20) raw = f"{_COUNTER:06d}{base:020d}" charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" result = "" for ch in raw: result += charset[int(ch)] return (result + "0" * 26)[:26] def _make_resource(name: str, location: str | None = None) -> Resource: return Resource( resource_id=_ulid(name), name=name, resource_type_name="fs-directory", classification=PhysVirt.PHYSICAL, sandbox_strategy=SandboxStrategyEnum.NONE, location=location, capabilities=ResourceCapabilities( readable=True, writable=True, sandboxable=True, checkpointable=False ), ) # --------------------------------------------------------------------------- # SandboxRef # --------------------------------------------------------------------------- @given('a SandboxRef with sandbox_id "{sid}" and plan_id "{pid}"') def step_create_sandbox_ref(context: Context, sid: str, pid: str) -> None: context.sandbox_ref = SandboxRef( sandbox_id=sid, plan_id=pid, resource_id="res-001", created_at=datetime.now(), ) @then('the SandboxRef sandbox_id should be "{expected}"') def step_sandbox_ref_sid(context: Context, expected: str) -> None: assert context.sandbox_ref.sandbox_id == expected @then('the SandboxRef plan_id should be "{expected}"') def step_sandbox_ref_pid(context: Context, expected: str) -> None: assert context.sandbox_ref.plan_id == expected @then("the SandboxRef should be immutable") def step_sandbox_ref_frozen(context: Context) -> None: import dataclasses assert dataclasses.is_dataclass(context.sandbox_ref) try: context.sandbox_ref.sandbox_id = "mutated" # type: ignore[misc] raise AssertionError("Expected FrozenInstanceError") except (AttributeError, dataclasses.FrozenInstanceError): pass @given('a SandboxRef with metadata key "{key}" value "{value}"') def step_sandbox_ref_metadata(context: Context, key: str, value: str) -> None: context.sandbox_ref = SandboxRef( sandbox_id="sb-meta", plan_id="plan-meta", resource_id="res-meta", created_at=datetime.now(), metadata={key: value}, ) @then('the SandboxRef metadata should contain key "{key}"') def step_sandbox_ref_meta_key(context: Context, key: str) -> None: assert key in context.sandbox_ref.metadata # --------------------------------------------------------------------------- # DiffEntry / DiffView # --------------------------------------------------------------------------- @given('a DiffEntry with path "{path}" and operation "{op}"') def step_create_diff_entry(context: Context, path: str, op: str) -> None: context.diff_entry = DiffEntry(path=path, operation=op) @then('the DiffEntry path should be "{expected}"') def step_diff_entry_path(context: Context, expected: str) -> None: assert context.diff_entry.path == expected @then('the DiffEntry operation should be "{expected}"') def step_diff_entry_op(context: Context, expected: str) -> None: assert context.diff_entry.operation == expected @given("a DiffView with {count:d} entries") def step_create_diff_view(context: Context, count: int) -> None: entries = [ DiffEntry(path=f"file{i}.txt", operation="modified") for i in range(count) ] context.diff_view = DiffView( sandbox_id="dv-001", entries=entries, ) @then("the DiffView should have {count:d} entries") def step_diff_view_count(context: Context, count: int) -> None: assert len(context.diff_view.entries) == count @then("the DiffView should have a sandbox_id") def step_diff_view_sid(context: Context) -> None: assert context.diff_view.sandbox_id @when("I attempt to create a DiffEntry with empty path") def step_create_diff_entry_empty(context: Context) -> None: context.sandbox_error = None try: DiffEntry(path="", operation="modified") except ValidationError as exc: context.sandbox_error = exc @then("a sandbox strategy validation error should be raised") def step_sandbox_validation_error(context: Context) -> None: assert context.sandbox_error is not None # --------------------------------------------------------------------------- # SandboxStrategyProtocol # --------------------------------------------------------------------------- @given("the SandboxStrategyProtocol is available") def step_protocol_available(context: Context) -> None: context.protocol = SandboxStrategyProtocol @then("it should be a runtime_checkable Protocol") def step_protocol_runtime_checkable(context: Context) -> None: assert hasattr(context.protocol, "__protocol_attrs__") or hasattr( context.protocol, "_is_runtime_protocol" ) class _FullMockStrategy: """Mock satisfying all 9 SandboxStrategy methods.""" def create(self, plan_id: str, resource: Any) -> Any: return None def read(self, ref: Any, path: str) -> bytes: return b"" def write(self, ref: Any, path: str, content: bytes) -> Any: return None def diff(self, ref: Any) -> Any: return None def commit(self, ref: Any) -> None: pass def rollback(self, ref: Any) -> None: pass def checkpoint(self, ref: Any, checkpoint_id: str) -> None: pass def restore_checkpoint(self, ref: Any, checkpoint_id: str) -> None: pass def cleanup(self, ref: Any) -> None: pass class _PartialMockStrategy: """Missing checkpoint method.""" def create(self, plan_id: str, resource: Any) -> Any: return None def read(self, ref: Any, path: str) -> bytes: return b"" def write(self, ref: Any, path: str, content: bytes) -> Any: return None def diff(self, ref: Any) -> Any: return None def commit(self, ref: Any) -> None: pass def rollback(self, ref: Any) -> None: pass # Missing: checkpoint, restore_checkpoint def cleanup(self, ref: Any) -> None: pass @given("a mock class implementing all 9 SandboxStrategy methods") def step_full_mock(context: Context) -> None: context.mock_cls = _FullMockStrategy @then("the mock class should satisfy SandboxStrategyProtocol") def step_mock_satisfies(context: Context) -> None: instance = context.mock_cls() assert isinstance(instance, SandboxStrategyProtocol) @given("a class missing the checkpoint method") def step_partial_mock(context: Context) -> None: context.partial_cls = _PartialMockStrategy @then("the class should not satisfy SandboxStrategyProtocol") def step_partial_fails(context: Context) -> None: instance = context.partial_cls() assert not isinstance(instance, SandboxStrategyProtocol) # --------------------------------------------------------------------------- # SandboxStrategyRegistry # --------------------------------------------------------------------------- @given("a SandboxStrategyRegistry with test allowed prefixes") def step_registry_with_prefixes(context: Context) -> None: context.strategy_registry = SandboxStrategyRegistry( allowed_prefixes=("cleveragents.",) ) @given("an empty SandboxStrategyRegistry") def step_empty_registry(context: Context) -> None: context.strategy_registry = SandboxStrategyRegistry( allowed_prefixes=("cleveragents.",) ) @given( "a valid custom strategy class in " '"cleveragents.infrastructure.sandbox.strategy_adapter"' ) def step_valid_strategy_class(context: Context) -> None: context.strategy_module = "cleveragents.infrastructure.sandbox.strategy_adapter" context.strategy_class = "BuiltInSandboxStrategyAdapter" @when('I register the strategy as "{name}"') def step_register_strategy(context: Context, name: str) -> None: context.strategy_registry.register( name, context.strategy_module, context.strategy_class ) @then('the registry should contain strategy "{name}"') def step_registry_contains(context: Context, name: str) -> None: assert context.strategy_registry.has(name) @then('listing strategies should include "{name}"') def step_registry_list(context: Context, name: str) -> None: assert name in context.strategy_registry.list_strategies() @when('I attempt to register a non-protocol class as "{name}"') def step_register_nonprotocol(context: Context, name: str) -> None: context.sandbox_error = None try: context.strategy_registry.register( name, "cleveragents.infrastructure.sandbox.protocol", "SandboxError", ) except ProtocolMismatchError as exc: context.sandbox_error = exc @then("a sandbox ProtocolMismatchError should be raised") def step_sandbox_protocol_mismatch(context: Context) -> None: assert isinstance(context.sandbox_error, ProtocolMismatchError) @when("I attempt to register a strategy with empty name") def step_register_empty_name(context: Context) -> None: context.sandbox_error = None try: context.strategy_registry.register( "", "cleveragents.infrastructure.sandbox.strategy_adapter", "BuiltInSandboxStrategyAdapter", ) except ValueError as exc: context.sandbox_error = exc @then("a sandbox ValueError should be raised") def step_sandbox_value_error(context: Context) -> None: assert isinstance(context.sandbox_error, (ValueError, type(None))) is False or ( context.sandbox_error is not None ) assert context.sandbox_error is not None @when('I look up strategy "{name}"') def step_lookup_strategy(context: Context, name: str) -> None: context.strategy_lookup_result = context.strategy_registry.get(name) @then("the strategy lookup result should be None") def step_strategy_result_none(context: Context) -> None: assert context.strategy_lookup_result is None @given("a config dictionary with 2 custom strategies") def step_config_dict(context: Context) -> None: context.config_dict = { "adapter1": { "module": "cleveragents.infrastructure.sandbox.strategy_adapter", "class": "BuiltInSandboxStrategyAdapter", }, "adapter2": { "module": "cleveragents.infrastructure.sandbox.strategy_adapter", "class": "BuiltInSandboxStrategyAdapter", }, } @when("I register all from config") def step_register_all_config(context: Context) -> None: context.registered = context.strategy_registry.register_all_from_config( context.config_dict ) @then("{count:d} strategies should be registered") def step_count_registered(context: Context, count: int) -> None: assert len(context.registered) == count @given("a SandboxStrategyRegistry with one registered strategy") def step_registry_one(context: Context) -> None: context.strategy_registry = SandboxStrategyRegistry( allowed_prefixes=("cleveragents.",) ) context.strategy_registry.register( "single", "cleveragents.infrastructure.sandbox.strategy_adapter", "BuiltInSandboxStrategyAdapter", ) @when("I clear the sandbox strategy registry") def step_clear_sandbox_registry(context: Context) -> None: context.strategy_registry.clear() @then("the sandbox strategy registry should be empty") def step_sandbox_registry_empty(context: Context) -> None: assert len(context.strategy_registry.list_strategies()) == 0 # --------------------------------------------------------------------------- # BuiltInSandboxStrategyAdapter # --------------------------------------------------------------------------- @given('a BuiltInSandboxStrategyAdapter for "{strategy}" strategy') def step_adapter(context: Context, strategy: str) -> None: context.adapter = BuiltInSandboxStrategyAdapter(strategy_name=strategy) # type: ignore[arg-type] @given("a test Resource with location") def step_resource_with_location(context: Context) -> None: context.tmpdir = tempfile.mkdtemp(prefix="ca-test-") context.test_resource = _make_resource("test-res", location=context.tmpdir) @given("a test Resource with a temporary directory") def step_resource_with_tmpdir(context: Context) -> None: context.tmpdir = tempfile.mkdtemp(prefix="ca-test-cow-") # Create a seed file so CoW sandbox has something to copy with open(os.path.join(context.tmpdir, "seed.txt"), "w") as f: f.write("seed") context.test_resource = Resource( resource_id=_ulid("cow-res"), name="cow-res", resource_type_name="fs-directory", classification=PhysVirt.PHYSICAL, sandbox_strategy=SandboxStrategyEnum.COPY_ON_WRITE, location=context.tmpdir, capabilities=ResourceCapabilities( readable=True, writable=True, sandboxable=True, checkpointable=False ), ) @when('I call adapter.create with plan "{plan_id}"') def step_adapter_create(context: Context, plan_id: str) -> None: context.adapter_ref = context.adapter.create(plan_id, context.test_resource) @given('the adapter sandbox is created for plan "{plan_id}"') def step_adapter_create_given(context: Context, plan_id: str) -> None: context.adapter_ref = context.adapter.create(plan_id, context.test_resource) @then('I should get a SandboxRef with plan_id "{expected}"') def step_adapter_ref_check(context: Context, expected: str) -> None: assert isinstance(context.adapter_ref, SandboxRef) assert context.adapter_ref.plan_id == expected @when('I call adapter.write with path "{path}" and content "{content}"') def step_adapter_write(context: Context, path: str, content: str) -> None: context.write_result = context.adapter.write( context.adapter_ref, path, content.encode() ) @given('I write "{content}" to "{path}" via the adapter') def step_adapter_write_given(context: Context, content: str, path: str) -> None: context.adapter.write(context.adapter_ref, path, content.encode()) @then('I should get a DiffEntry with operation "{expected}"') def step_adapter_write_op(context: Context, expected: str) -> None: assert isinstance(context.write_result, DiffEntry) assert context.write_result.operation == expected @when('I call adapter.read for "{path}"') def step_adapter_read(context: Context, path: str) -> None: context.read_result = context.adapter.read(context.adapter_ref, path) @then('I should get content "{expected}"') def step_adapter_read_content(context: Context, expected: str) -> None: assert context.read_result == expected.encode() @when("I call adapter.diff") def step_adapter_diff(context: Context) -> None: context.diff_result = context.adapter.diff(context.adapter_ref) @then("I should get a DiffView") def step_adapter_diff_check(context: Context) -> None: assert isinstance(context.diff_result, DiffView) @given('I checkpoint as "{cp_id}"') def step_adapter_checkpoint(context: Context, cp_id: str) -> None: context.adapter.checkpoint(context.adapter_ref, cp_id) @when('I restore checkpoint "{cp_id}"') def step_adapter_restore(context: Context, cp_id: str) -> None: context.adapter.restore_checkpoint(context.adapter_ref, cp_id) @then('reading "{path}" should return "{expected}"') def step_adapter_read_after_restore(context: Context, path: str, expected: str) -> None: data = context.adapter.read(context.adapter_ref, path) assert data == expected.encode(), f"Expected {expected!r}, got {data!r}" @when("I call adapter.cleanup") def step_adapter_cleanup(context: Context) -> None: context.adapter.cleanup(context.adapter_ref) @then("the adapter should have no tracked sandboxes") def step_adapter_no_sandboxes(context: Context) -> None: assert len(context.adapter._sandboxes) == 0 # --------------------------------------------------------------------------- # CustomStrategyConfig # --------------------------------------------------------------------------- @when("I attempt to create a CustomStrategyConfig with empty name") def step_config_empty_name(context: Context) -> None: context.sandbox_error = None try: CustomStrategyConfig(name="", module="mod", class_name="Cls") except ValueError as exc: context.sandbox_error = exc @when("I attempt to create a CustomStrategyConfig with empty module") def step_config_empty_module(context: Context) -> None: context.sandbox_error = None try: CustomStrategyConfig(name="x", module="", class_name="Cls") except ValueError as exc: context.sandbox_error = exc @when("I attempt to create a CustomStrategyConfig with empty class_name") def step_config_empty_class(context: Context) -> None: context.sandbox_error = None try: CustomStrategyConfig(name="x", module="mod", class_name="") except ValueError as exc: context.sandbox_error = exc # --------------------------------------------------------------------------- # Factory custom strategy integration # --------------------------------------------------------------------------- @given('a SandboxFactory with a custom registry containing "{name}"') def step_factory_with_registry(context: Context, name: str) -> None: registry = SandboxStrategyRegistry(allowed_prefixes=("cleveragents.",)) registry.register( name, "cleveragents.infrastructure.sandbox.strategy_adapter", "BuiltInSandboxStrategyAdapter", ) context.factory = SandboxFactory(custom_registry=registry) @given("a SandboxFactory without a custom registry") def step_factory_no_registry(context: Context) -> None: context.factory = SandboxFactory() @then('the factory should report "{name}" as a custom strategy') def step_factory_has_custom(context: Context, name: str) -> None: assert context.factory.has_custom_strategy(name) @then('the factory should not report "{name}" as a custom strategy') def step_factory_no_custom(context: Context, name: str) -> None: assert not context.factory.has_custom_strategy(name)