"""Step definitions for sandbox checkpoint and rollback BDD tests.""" from __future__ import annotations import os import tempfile from typing import Any from unittest.mock import MagicMock from behave import given, then, when from cleveragents.infrastructure.sandbox.checkpoint import ( CheckpointManager, SandboxCheckpoint, ) from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _create_temp_sandbox(context: Any) -> None: """Create a temporary directory with sample files for sandbox testing.""" tmpdir = tempfile.mkdtemp(prefix="ca-cp-test-") context._tmpdir = tmpdir # Write sample files os.makedirs(os.path.join(tmpdir, "src"), exist_ok=True) with open(os.path.join(tmpdir, "src", "main.py"), "w") as f: f.write("print('hello')\n") with open(os.path.join(tmpdir, "README.md"), "w") as f: f.write("# Test project\n") sandbox = NoSandbox(resource_id="res-cp-test", original_path=tmpdir) sandbox.create(plan_id="plan-cp-test") context.sandbox = sandbox context.sandbox_path = tmpdir # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given("a checkpoint manager") def step_given_checkpoint_manager(context: Any) -> None: context.checkpoint_manager = CheckpointManager() context.checkpoint = None context.checkpoints = [] context.rollback_result = None context.delete_result = None context.error = None @given("a sandbox with files in a temporary directory") def step_given_sandbox_with_files(context: Any) -> None: _create_temp_sandbox(context) @given('a checkpoint exists for the sandbox with phase "{phase}" and plan "{plan_id}"') def step_given_checkpoint_exists(context: Any, phase: str, plan_id: str) -> None: meta: dict[str, str] = {} if hasattr(context, "sandbox_path") and context.sandbox_path: meta["sandbox_path"] = context.sandbox_path context.checkpoint = context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id=plan_id, phase=phase, metadata=meta, ) @given("a checkpoint with an invalid snapshot path") def step_given_invalid_checkpoint(context: Any) -> None: context.invalid_checkpoint = SandboxCheckpoint( checkpoint_id="invalid-cp-001", sandbox_id="sb-invalid", plan_id="plan-invalid", phase="pre_execute", created_at=__import__("datetime").datetime.now(), metadata={}, snapshot_path="/nonexistent/path/snapshot", ) @given("a plan lifecycle service") def step_given_lifecycle_service(context: Any) -> None: mock_settings = MagicMock() mock_settings.cleveragents_home = "/tmp/test" mock_settings.default_automation_profile = None context.lifecycle_service = MagicMock() context.lifecycle_service.settings = mock_settings @given("a plan executor without checkpoint manager") def step_given_executor_no_checkpoint(context: Any) -> None: from cleveragents.application.services.plan_executor import PlanExecutor context.executor = PlanExecutor( lifecycle_service=context.lifecycle_service, ) @given("a plan executor with a checkpoint manager") def step_given_executor_with_checkpoint(context: Any) -> None: from cleveragents.application.services.plan_executor import PlanExecutor context.executor = PlanExecutor( lifecycle_service=context.lifecycle_service, checkpoint_manager=CheckpointManager(), ) @given("a plan apply service without checkpoint manager") def step_given_apply_no_checkpoint(context: Any) -> None: from cleveragents.application.services.plan_apply_service import ( PlanApplyService, ) context.apply_service = PlanApplyService( lifecycle_service=context.lifecycle_service, ) @given("a plan apply service with a checkpoint manager") def step_given_apply_with_checkpoint(context: Any) -> None: from cleveragents.application.services.plan_apply_service import ( PlanApplyService, ) context.apply_service = PlanApplyService( lifecycle_service=context.lifecycle_service, checkpoint_manager=CheckpointManager(), ) # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when('I create a checkpoint with phase "{phase}" and plan "{plan_id}"') def step_when_create_checkpoint(context: Any, phase: str, plan_id: str) -> None: meta: dict[str, str] = {} if hasattr(context, "sandbox_path") and context.sandbox_path: meta["sandbox_path"] = context.sandbox_path context.checkpoint = context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id=plan_id, phase=phase, metadata=meta, ) context.checkpoints.append(context.checkpoint) @when('I attach metadata "{key}" as "{value}"') def step_when_attach_metadata(context: Any, key: str, value: str) -> None: # Metadata is attached during creation; re-create with metadata meta = dict(context.checkpoint.metadata) meta[key] = value if hasattr(context, "sandbox_path") and context.sandbox_path: meta["sandbox_path"] = context.sandbox_path context.checkpoint = context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id=context.checkpoint.plan_id, phase=context.checkpoint.phase, metadata=meta, ) @when("I modify the sandbox files") def step_when_modify_sandbox(context: Any) -> None: main_path = os.path.join(context.sandbox_path, "src", "main.py") with open(main_path, "w") as f: f.write("print('modified')\n") new_file = os.path.join(context.sandbox_path, "new_file.txt") with open(new_file, "w") as f: f.write("new content\n") @when("I rollback to the checkpoint") def step_when_rollback(context: Any) -> None: context.rollback_result = context.checkpoint_manager.rollback_to(context.checkpoint) @when("I attempt to rollback to the invalid checkpoint") def step_when_rollback_invalid(context: Any) -> None: context.rollback_result = context.checkpoint_manager.rollback_to( context.invalid_checkpoint ) @when("I delete the checkpoint") def step_when_delete_checkpoint(context: Any) -> None: context.delete_result = context.checkpoint_manager.delete_checkpoint( context.checkpoint.checkpoint_id ) @when('I delete a checkpoint with id "{checkpoint_id}"') def step_when_delete_nonexistent(context: Any, checkpoint_id: str) -> None: context.delete_result = context.checkpoint_manager.delete_checkpoint(checkpoint_id) # --------------------------------------------------------------------------- # Then steps # --------------------------------------------------------------------------- @then("a checkpoint should be returned") def step_then_checkpoint_returned(context: Any) -> None: assert context.checkpoint is not None, "Expected a checkpoint" assert isinstance(context.checkpoint, SandboxCheckpoint) @then('the checkpoint phase should be "{phase}"') def step_then_checkpoint_phase(context: Any, phase: str) -> None: assert context.checkpoint.phase == phase, ( f"Expected phase '{phase}', got '{context.checkpoint.phase}'" ) @then('the sandbox checkpoint plan_id should be "{plan_id}"') def step_then_checkpoint_plan_id(context: Any, plan_id: str) -> None: assert context.checkpoint.plan_id == plan_id, ( f"Expected plan_id '{plan_id}', got '{context.checkpoint.plan_id}'" ) @then("the checkpoint sandbox_id should match the sandbox") def step_then_checkpoint_sandbox_id(context: Any) -> None: assert context.checkpoint.sandbox_id == context.sandbox.sandbox_id, ( f"Expected sandbox_id '{context.sandbox.sandbox_id}', " f"got '{context.checkpoint.sandbox_id}'" ) @then('the checkpoint metadata should contain key "{key}" with value "{value}"') def step_then_checkpoint_metadata(context: Any, key: str, value: str) -> None: assert key in context.checkpoint.metadata, ( f"Key '{key}' not in metadata: {context.checkpoint.metadata}" ) assert context.checkpoint.metadata[key] == value, ( f"Expected metadata['{key}'] = '{value}', " f"got '{context.checkpoint.metadata[key]}'" ) @then("the sandbox rollback should succeed") def step_then_rollback_success(context: Any) -> None: assert context.rollback_result is True, "Expected rollback to succeed" @then("the rollback should fail") def step_then_rollback_fail(context: Any) -> None: assert context.rollback_result is False, "Expected rollback to fail" @then("the sandbox files should match the original state") def step_then_sandbox_matches_original(context: Any) -> None: main_path = os.path.join(context.sandbox_path, "src", "main.py") with open(main_path) as f: content = f.read() assert content == "print('hello')\n", f"Expected original content, got: {content!r}" new_file = os.path.join(context.sandbox_path, "new_file.txt") assert not os.path.exists(new_file), "new_file.txt should not exist" @then("listing checkpoints for the sandbox should return {count:d} checkpoints") def step_then_checkpoint_count(context: Any, count: int) -> None: result = context.checkpoint_manager.list_checkpoints(context.sandbox.sandbox_id) assert len(result) == count, f"Expected {count} checkpoints, got {len(result)}" @then("the checkpoints should be in creation order") def step_then_checkpoints_ordered(context: Any) -> None: result = context.checkpoint_manager.list_checkpoints(context.sandbox.sandbox_id) for i in range(1, len(result)): assert result[i].created_at >= result[i - 1].created_at, ( f"Checkpoints not in order at index {i}" ) @then("the checkpoint should have a valid ULID checkpoint_id") def step_then_valid_ulid(context: Any) -> None: cp_id = context.checkpoint.checkpoint_id assert len(cp_id) == 26, f"ULID should be 26 chars, got {len(cp_id)}" @then("the checkpoint should have a created_at timestamp") def step_then_has_timestamp(context: Any) -> None: assert context.checkpoint.created_at is not None @then("the checkpoint should have a non-empty snapshot_path") def step_then_has_snapshot_path(context: Any) -> None: assert context.checkpoint.snapshot_path, "snapshot_path should not be empty" assert os.path.isdir(context.checkpoint.snapshot_path), ( f"snapshot_path should be a directory: {context.checkpoint.snapshot_path}" ) @then("the deletion should succeed") def step_then_deletion_success(context: Any) -> None: assert context.delete_result is True, "Expected deletion to succeed" @then("the deletion should fail") def step_then_deletion_fail(context: Any) -> None: assert context.delete_result is False, "Expected deletion to fail" @then("the plan executor checkpoint_manager should be None") def step_then_executor_no_checkpoint(context: Any) -> None: assert context.executor.checkpoint_manager is None @then("the plan executor checkpoint_manager should not be None") def step_then_executor_has_checkpoint(context: Any) -> None: assert context.executor.checkpoint_manager is not None @then("the plan apply service checkpoint_manager should be None") def step_then_apply_no_checkpoint(context: Any) -> None: assert context.apply_service.checkpoint_manager is None @then("the plan apply service checkpoint_manager should not be None") def step_then_apply_has_checkpoint(context: Any) -> None: assert context.apply_service.checkpoint_manager is not None # --------------------------------------------------------------------------- # Context-less sandbox (metadata-only checkpoint) steps # --------------------------------------------------------------------------- class _NoContextSandboxProxy: """Sandbox proxy with ``context = None`` for metadata-only checkpoints.""" def __init__(self, sandbox_id: str) -> None: self._sandbox_id = sandbox_id @property def sandbox_id(self) -> str: return self._sandbox_id @property def context(self) -> Any: return None @given("a sandbox proxy with no context") def step_given_no_context_proxy(context: Any) -> None: context.sandbox = _NoContextSandboxProxy(sandbox_id="sb-no-ctx-001") @when('I create a checkpoint with phase "{phase}" and plan "{plan_id}" using the proxy') def step_when_create_checkpoint_proxy(context: Any, phase: str, plan_id: str) -> None: context.checkpoint = context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id=plan_id, phase=phase, ) @then("the checkpoint snapshot_path should be an empty directory") def step_then_snapshot_empty_dir(context: Any) -> None: path = context.checkpoint.snapshot_path assert os.path.isdir(path), f"snapshot_path should be a directory: {path}" contents = os.listdir(path) assert len(contents) == 0, f"Expected empty dir, got: {contents}" # --------------------------------------------------------------------------- # Rollback edge case steps # --------------------------------------------------------------------------- @given( 'a checkpoint exists for the sandbox with phase "{phase}" and plan "{plan_id}"' " without sandbox_path metadata" ) def step_given_checkpoint_no_sandbox_path( context: Any, phase: str, plan_id: str ) -> None: """Create a checkpoint without embedding sandbox_path in metadata.""" context.checkpoint = context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id=plan_id, phase=phase, metadata={}, ) @when("I attempt to rollback to the checkpoint without sandbox path") def step_when_rollback_no_sandbox_path(context: Any) -> None: context.rollback_result = context.checkpoint_manager.rollback_to(context.checkpoint) @when("I make the sandbox path read-only and attempt rollback") def step_when_rollback_readonly_sandbox(context: Any) -> None: from unittest.mock import patch # Create checkpoint with sandbox_path in metadata meta = {"sandbox_path": context.sandbox_path} cp = context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id="plan-001", phase="pre_execute", metadata=meta, ) # Patch os.listdir to raise OSError, simulating a filesystem failure # during rollback. This avoids chmod which is ignored when running as root. with patch("os.listdir", side_effect=OSError("Simulated I/O error")): context.rollback_result = context.checkpoint_manager.rollback_to(cp) # --------------------------------------------------------------------------- # Multi-sandbox delete steps # --------------------------------------------------------------------------- @given("two sandboxes with checkpoints") def step_given_two_sandboxes(context: Any) -> None: """Create checkpoints for two different sandboxes.""" # First sandbox tmpdir1 = tempfile.mkdtemp(prefix="ca-cp-multi1-") os.makedirs(os.path.join(tmpdir1, "src"), exist_ok=True) with open(os.path.join(tmpdir1, "src", "a.py"), "w") as f: f.write("# sandbox1\n") sb1 = NoSandbox(resource_id="res-multi-1", original_path=tmpdir1) sb1.create(plan_id="plan-multi") tmpdir2 = tempfile.mkdtemp(prefix="ca-cp-multi2-") os.makedirs(os.path.join(tmpdir2, "src"), exist_ok=True) with open(os.path.join(tmpdir2, "src", "b.py"), "w") as f: f.write("# sandbox2\n") sb2 = NoSandbox(resource_id="res-multi-2", original_path=tmpdir2) sb2.create(plan_id="plan-multi") context.sandbox_one = sb1 context.sandbox_two = sb2 context.cp_one = context.checkpoint_manager.create_checkpoint( sandbox=sb1, plan_id="plan-multi", phase="pre_execute" ) context.cp_two = context.checkpoint_manager.create_checkpoint( sandbox=sb2, plan_id="plan-multi", phase="pre_execute" ) @when("I delete the checkpoint from the second sandbox") def step_when_delete_second_sandbox_cp(context: Any) -> None: context.delete_result = context.checkpoint_manager.delete_checkpoint( context.cp_two.checkpoint_id ) @then( "listing checkpoints for the first sandbox should return" " 1 checkpoint for sandbox one" ) def step_then_first_sandbox_still_has_one(context: Any) -> None: result = context.checkpoint_manager.list_checkpoints(context.sandbox_one.sandbox_id) assert len(result) == 1, f"Expected 1 checkpoint, got {len(result)}" # --------------------------------------------------------------------------- # Snapshot copy failure steps # --------------------------------------------------------------------------- @given("a sandbox with an unreadable directory") def step_given_unreadable_sandbox(context: Any) -> None: tmpdir = tempfile.mkdtemp(prefix="ca-cp-unreadable-") os.makedirs(os.path.join(tmpdir, "src"), exist_ok=True) with open(os.path.join(tmpdir, "src", "file.txt"), "w") as f: f.write("data\n") sandbox = NoSandbox(resource_id="res-cp-unreadable", original_path=tmpdir) sandbox.create(plan_id="plan-unreadable") context.sandbox = sandbox context.sandbox_path = tmpdir @when("I attempt to create a checkpoint that fails during snapshot") def step_when_create_checkpoint_snapshot_fail(context: Any) -> None: from unittest.mock import patch from cleveragents.infrastructure.sandbox.protocol import SandboxError # Patch shutil.copytree to raise OSError, simulating a snapshot copy # failure. This avoids chmod which is ignored when running as root. with patch("shutil.copytree", side_effect=OSError("Simulated copy error")): try: context.checkpoint_manager.create_checkpoint( sandbox=context.sandbox, plan_id="plan-unreadable", phase="pre_execute", ) context.error = None except SandboxError as exc: context.error = exc @then("a sandbox error should be raised") def step_then_sandbox_error(context: Any) -> None: from cleveragents.infrastructure.sandbox.protocol import SandboxError assert context.error is not None, "Expected a SandboxError" assert isinstance(context.error, SandboxError) # --------------------------------------------------------------------------- # Cleanup snapshot edge case steps # --------------------------------------------------------------------------- @when("I cleanup a snapshot with an empty path") def step_when_cleanup_empty_path(context: Any) -> None: context.error = None try: CheckpointManager._cleanup_snapshot("") except Exception as exc: context.error = exc @when("I cleanup a snapshot with a non-existent parent") def step_when_cleanup_nonexistent_parent(context: Any) -> None: context.error = None try: CheckpointManager._cleanup_snapshot("/nonexistent/path/that/does/not/exist") except Exception as exc: context.error = exc @then("no error should occur") def step_then_no_error(context: Any) -> None: assert context.error is None, f"Expected no error, got: {context.error}"