"""Step definitions for fs_directory_coverage.feature. Exercises uncovered lines in fs_directory.py: L133 — delete() with empty path raises PermissionError L143 — delete() removes subdirectory via shutil.rmtree L197 — diff() with file missing from resource dir (a_lines = []) L204 — diff() with file missing from other dir (b_lines = []) L287-289 — create_checkpoint() no sandbox RuntimeError L329-331 — rollback_to() no sandbox RuntimeError L336-339 — rollback_to() missing/expired checkpoint L347,349 — rollback_to() .git skip + rmtree in sandbox clear L357,360 — rollback_to() .git skip + copytree in snapshot restore L374-381 — discard_checkpoints() """ from __future__ import annotations import shutil import tempfile from pathlib import Path from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ResourceCapabilities, ) from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler __all__: list[str] = [] _ULID_COUNTER = 900_000 def _next_ulid() -> str: """Generate a valid 26-char Crockford Base32 ID for tests.""" global _ULID_COUNTER _ULID_COUNTER += 1 cb32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" n = _ULID_COUNTER chars: list[str] = [] for _ in range(26): chars.append(cb32[n % 32]) n //= 32 return "".join(reversed(chars)) def _make_resource(location: str) -> Resource: return Resource( resource_id=_next_ulid(), resource_type_name="fs-directory", classification=PhysVirt.PHYSICAL, location=location, capabilities=ResourceCapabilities( readable=True, writable=True, sandboxable=True ), ) # --------------------------------------------------------------------------- # Delete with empty path — L133 # --------------------------------------------------------------------------- @given('fsdcov a temp directory with a file "{fname}"') def step_fsdcov_given_temp_dir_file(context: Context, fname: str) -> None: d = tempfile.mkdtemp(prefix="fsdcov_del_") context.add_cleanup(lambda: shutil.rmtree(d, ignore_errors=True)) Path(d, fname).write_text("content", encoding="utf-8") context.fsdcov_handler = FsDirectoryHandler() context.fsdcov_resource = _make_resource(d) context.fsdcov_tmpdir = d @when("fsdcov I delete with an empty path") def step_fsdcov_delete_empty_path(context: Context) -> None: handler: FsDirectoryHandler = context.fsdcov_handler try: handler.delete(resource=context.fsdcov_resource, path="") context.fsdcov_error = None except Exception as exc: context.fsdcov_error = exc @then('fsdcov a PermissionError is raised with message "{fragment}"') def step_fsdcov_permission_error(context: Context, fragment: str) -> None: assert context.fsdcov_error is not None, "Expected an error but none was raised" assert isinstance(context.fsdcov_error, PermissionError), ( f"Expected PermissionError, got {type(context.fsdcov_error).__name__}" ) assert fragment in str(context.fsdcov_error), ( f"Expected '{fragment}' in '{context.fsdcov_error}'" ) # --------------------------------------------------------------------------- # Delete subdirectory — L143 # --------------------------------------------------------------------------- @given('fsdcov a temp directory with a subdirectory "{dirname}" containing "{fname}"') def step_fsdcov_given_temp_dir_subdir( context: Context, dirname: str, fname: str ) -> None: d = tempfile.mkdtemp(prefix="fsdcov_delsub_") context.add_cleanup(lambda: shutil.rmtree(d, ignore_errors=True)) sub = Path(d, dirname) sub.mkdir(parents=True, exist_ok=True) (sub / fname).write_text("inner content", encoding="utf-8") context.fsdcov_handler = FsDirectoryHandler() context.fsdcov_resource = _make_resource(d) context.fsdcov_tmpdir = d @when('fsdcov I delete subdirectory "{dirname}"') def step_fsdcov_delete_subdir(context: Context, dirname: str) -> None: handler: FsDirectoryHandler = context.fsdcov_handler try: context.fsdcov_delete_result = handler.delete( resource=context.fsdcov_resource, path=dirname ) context.fsdcov_error = None except Exception as exc: context.fsdcov_error = exc context.fsdcov_delete_result = None @then("fsdcov the delete result is successful") def step_fsdcov_delete_success(context: Context) -> None: assert context.fsdcov_error is None, f"Unexpected error: {context.fsdcov_error}" assert context.fsdcov_delete_result is not None assert context.fsdcov_delete_result.success is True @then('fsdcov the subdirectory "{dirname}" no longer exists') def step_fsdcov_subdir_gone(context: Context, dirname: str) -> None: target = Path(context.fsdcov_tmpdir) / dirname assert not target.exists(), ( f"Expected '{dirname}' to be removed, but it still exists" ) # --------------------------------------------------------------------------- # Diff with files on only one side — L197, L204 # --------------------------------------------------------------------------- @given('fsdcov a temp directory "{label}" with no files') def step_fsdcov_given_empty_dir(context: Context, label: str) -> None: d = tempfile.mkdtemp(prefix=f"fsdcov_{label}_") context.add_cleanup(lambda: shutil.rmtree(d, ignore_errors=True)) if not hasattr(context, "fsdcov_diff_dirs"): context.fsdcov_diff_dirs = {} context.fsdcov_diff_dirs[label] = d @given('fsdcov a temp directory "{label}" with file "{fname}" containing "{content}"') def step_fsdcov_given_dir_with_file( context: Context, label: str, fname: str, content: str ) -> None: d = tempfile.mkdtemp(prefix=f"fsdcov_{label}_") context.add_cleanup(lambda: shutil.rmtree(d, ignore_errors=True)) Path(d, fname).write_text(content, encoding="utf-8") if not hasattr(context, "fsdcov_diff_dirs"): context.fsdcov_diff_dirs = {} context.fsdcov_diff_dirs[label] = d @when("fsdcov I diff dir_a against dir_b") def step_fsdcov_diff(context: Context) -> None: handler = FsDirectoryHandler() dir_a = context.fsdcov_diff_dirs["dir_a"] dir_b = context.fsdcov_diff_dirs["dir_b"] resource = _make_resource(dir_a) context.fsdcov_diff_result = handler.diff(resource=resource, other_location=dir_b) @then("fsdcov the diff shows changes with insertions") def step_fsdcov_diff_insertions(context: Context) -> None: r = context.fsdcov_diff_result assert r.has_changes is True, "Expected diff to have changes" assert r.insertions > 0, f"Expected insertions > 0, got {r.insertions}" @then("fsdcov the diff shows changes with deletions") def step_fsdcov_diff_deletions(context: Context) -> None: r = context.fsdcov_diff_result assert r.has_changes is True, "Expected diff to have changes" assert r.deletions > 0, f"Expected deletions > 0, got {r.deletions}" # --------------------------------------------------------------------------- # create_checkpoint with no sandbox — L287-289 # --------------------------------------------------------------------------- @given("fsdcov a handler and a resource with location") def step_fsdcov_handler_and_resource(context: Context) -> None: d = tempfile.mkdtemp(prefix="fsdcov_ckpt_") context.add_cleanup(lambda: shutil.rmtree(d, ignore_errors=True)) context.fsdcov_handler = FsDirectoryHandler() context.fsdcov_resource = _make_resource(d) context.fsdcov_tmpdir = d @given("fsdcov a sandbox manager that returns None for get_sandbox") def step_fsdcov_sandbox_mgr_none(context: Context) -> None: mgr = MagicMock() mgr.get_sandbox.return_value = None context.fsdcov_sandbox_manager = mgr @given("fsdcov a sandbox manager that returns sandbox with no context") def step_fsdcov_sandbox_mgr_no_context(context: Context) -> None: mgr = MagicMock() sandbox_mock = MagicMock() sandbox_mock.context = None mgr.get_sandbox.return_value = sandbox_mock context.fsdcov_sandbox_manager = mgr @when("fsdcov I call create_checkpoint") def step_fsdcov_call_create_checkpoint(context: Context) -> None: handler: FsDirectoryHandler = context.fsdcov_handler try: handler.create_checkpoint( resource=context.fsdcov_resource, plan_id="test-plan-1", sandbox_manager=context.fsdcov_sandbox_manager, ) context.fsdcov_error = None except Exception as exc: context.fsdcov_error = exc @then('fsdcov a RuntimeError is raised with message "{fragment}"') def step_fsdcov_runtime_error(context: Context, fragment: str) -> None: assert context.fsdcov_error is not None, "Expected an error but none was raised" assert isinstance(context.fsdcov_error, RuntimeError), ( f"Expected RuntimeError, got {type(context.fsdcov_error).__name__}" ) assert fragment in str(context.fsdcov_error), ( f"Expected '{fragment}' in '{context.fsdcov_error}'" ) # --------------------------------------------------------------------------- # rollback_to with no sandbox — L329-331 # --------------------------------------------------------------------------- @when('fsdcov I call rollback_to with checkpoint "{ckpt_id}"') def step_fsdcov_rollback_to(context: Context, ckpt_id: str) -> None: handler: FsDirectoryHandler = context.fsdcov_handler try: context.fsdcov_rollback_result = handler.rollback_to( resource=context.fsdcov_resource, plan_id="test-plan-1", checkpoint_id=ckpt_id, sandbox_manager=context.fsdcov_sandbox_manager, ) context.fsdcov_error = None except Exception as exc: context.fsdcov_error = exc context.fsdcov_rollback_result = None # --------------------------------------------------------------------------- # rollback_to with missing checkpoint — L336-339 # --------------------------------------------------------------------------- @given("fsdcov a sandbox manager with a valid sandbox") def step_fsdcov_sandbox_mgr_valid(context: Context) -> None: sandbox_path = tempfile.mkdtemp(prefix="fsdcov_sb_") context.add_cleanup(lambda: shutil.rmtree(sandbox_path, ignore_errors=True)) mgr = MagicMock() sandbox_mock = MagicMock() ctx_mock = MagicMock() ctx_mock.sandbox_path = sandbox_path sandbox_mock.context = ctx_mock mgr.get_sandbox.return_value = sandbox_mock context.fsdcov_sandbox_manager = mgr context.fsdcov_sandbox_path = sandbox_path @then('fsdcov the rollback result is unsuccessful with message "{fragment}"') def step_fsdcov_rollback_unsuccessful(context: Context, fragment: str) -> None: assert context.fsdcov_error is None, f"Unexpected exception: {context.fsdcov_error}" r = context.fsdcov_rollback_result assert r is not None, "Expected a rollback result" assert r.success is False, f"Expected success=False, got {r.success}" assert fragment in r.message, f"Expected '{fragment}' in '{r.message}'" # --------------------------------------------------------------------------- # rollback_to with expired checkpoint path — L336-339 # --------------------------------------------------------------------------- @given("fsdcov a handler with a checkpoint pointing to a deleted path") def step_fsdcov_handler_expired_ckpt(context: Context) -> None: d = tempfile.mkdtemp(prefix="fsdcov_expired_") context.add_cleanup(lambda: shutil.rmtree(d, ignore_errors=True)) context.fsdcov_handler = FsDirectoryHandler() context.fsdcov_resource = _make_resource(d) # Create a temp dir, record it as a checkpoint, then delete it expired_dir = tempfile.mkdtemp(prefix="fsdcov_snap_expired_") context.fsdcov_expired_ckpt_id = "expired-ckpt-xyz" context.fsdcov_handler._checkpoints[context.fsdcov_expired_ckpt_id] = expired_dir shutil.rmtree(expired_dir) @when("fsdcov I call rollback_to with the expired checkpoint") def step_fsdcov_rollback_expired(context: Context) -> None: handler: FsDirectoryHandler = context.fsdcov_handler try: context.fsdcov_rollback_result = handler.rollback_to( resource=context.fsdcov_resource, plan_id="test-plan-1", checkpoint_id=context.fsdcov_expired_ckpt_id, sandbox_manager=context.fsdcov_sandbox_manager, ) context.fsdcov_error = None except Exception as exc: context.fsdcov_error = exc context.fsdcov_rollback_result = None # --------------------------------------------------------------------------- # rollback_to with .git skip and directory handling — L347,349,357,360 # --------------------------------------------------------------------------- @given("fsdcov a handler with a real checkpoint snapshot") def step_fsdcov_handler_real_checkpoint(context: Context) -> None: # Create snapshot directory with: .git/, a subdir, and a file snap_parent = tempfile.mkdtemp(prefix="fsdcov_snap_") context.add_cleanup(lambda: shutil.rmtree(snap_parent, ignore_errors=True)) snap_path = Path(snap_parent) / "snapshot" snap_path.mkdir() # .git directory in snapshot (should be skipped during restore) (snap_path / ".git").mkdir() (snap_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n") # A subdirectory to restore (exercises copytree — L360) (snap_path / "src").mkdir() (snap_path / "src" / "main.py").write_text("print('hello')\n") # A regular file to restore (exercises copy2) (snap_path / "README.md").write_text("# Project\n") context.fsdcov_handler = FsDirectoryHandler() # Register the snapshot as a checkpoint on the handler context.fsdcov_real_ckpt_id = "real-ckpt-001" context.fsdcov_handler._checkpoints[context.fsdcov_real_ckpt_id] = str(snap_path) # Create a resource location res_dir = tempfile.mkdtemp(prefix="fsdcov_resdir_") context.add_cleanup(lambda: shutil.rmtree(res_dir, ignore_errors=True)) context.fsdcov_resource = _make_resource(res_dir) @given("fsdcov a sandbox with .git dir and other content") def step_fsdcov_sandbox_with_git(context: Context) -> None: sandbox_path = tempfile.mkdtemp(prefix="fsdcov_sb_git_") context.add_cleanup(lambda: shutil.rmtree(sandbox_path, ignore_errors=True)) sb = Path(sandbox_path) # .git directory — must be preserved (L347) (sb / ".git").mkdir() (sb / ".git" / "config").write_text("[core]\n") # A subdirectory — must be removed by rmtree (L349) (sb / "old_dir").mkdir() (sb / "old_dir" / "old.py").write_text("old\n") # A regular file — must be removed by unlink (L351) (sb / "stale.txt").write_text("stale\n") mgr = MagicMock() sandbox_mock = MagicMock() ctx_mock = MagicMock() ctx_mock.sandbox_path = sandbox_path sandbox_mock.context = ctx_mock mgr.get_sandbox.return_value = sandbox_mock context.fsdcov_sandbox_manager = mgr context.fsdcov_sandbox_path = sandbox_path @when("fsdcov I call rollback_to with the real checkpoint") def step_fsdcov_rollback_real(context: Context) -> None: handler: FsDirectoryHandler = context.fsdcov_handler try: context.fsdcov_rollback_result = handler.rollback_to( resource=context.fsdcov_resource, plan_id="test-plan-1", checkpoint_id=context.fsdcov_real_ckpt_id, sandbox_manager=context.fsdcov_sandbox_manager, ) context.fsdcov_error = None except Exception as exc: context.fsdcov_error = exc context.fsdcov_rollback_result = None @then("fsdcov the rollback result is successful") def step_fsdcov_rollback_success(context: Context) -> None: assert context.fsdcov_error is None, f"Unexpected error: {context.fsdcov_error}" r = context.fsdcov_rollback_result assert r is not None, "Expected a rollback result" assert r.success is True, f"Expected success=True, got {r.success}" @then("fsdcov the .git directory is preserved in the sandbox") def step_fsdcov_git_preserved(context: Context) -> None: sb = Path(context.fsdcov_sandbox_path) assert (sb / ".git").exists(), ".git directory was removed but should be preserved" assert (sb / ".git" / "config").exists(), ( ".git/config was removed but should be preserved" ) @then("fsdcov the snapshot content is restored in the sandbox") def step_fsdcov_snapshot_restored(context: Context) -> None: sb = Path(context.fsdcov_sandbox_path) # Subdirectory from snapshot should be restored (L360) assert (sb / "src").is_dir(), "Snapshot 'src/' directory was not restored" assert (sb / "src" / "main.py").exists(), "Snapshot 'src/main.py' was not restored" assert (sb / "src" / "main.py").read_text() == "print('hello')\n" # File from snapshot should be restored assert (sb / "README.md").exists(), "Snapshot 'README.md' was not restored" assert (sb / "README.md").read_text() == "# Project\n" # Old content should be gone assert not (sb / "old_dir").exists(), "Old sandbox content was not cleared" assert not (sb / "stale.txt").exists(), "Old sandbox file was not cleared" # --------------------------------------------------------------------------- # discard_checkpoints — L374-381 # --------------------------------------------------------------------------- @given('fsdcov a handler with two checkpoints for plan "{plan_id}"') def step_fsdcov_two_checkpoints(context: Context, plan_id: str) -> None: context.fsdcov_handler = FsDirectoryHandler() context.fsdcov_plan_id = plan_id # Create two temp snapshot dirs and register them for i in range(2): snap_dir = tempfile.mkdtemp(prefix=f"fsdcov_disc_{i}_") snap_path = Path(snap_dir) / "snapshot" snap_path.mkdir() (snap_path / f"file{i}.txt").write_text(f"data{i}") ckpt_id = f"checkpoint-{plan_id}-2024010{i}" context.fsdcov_handler._checkpoints[ckpt_id] = str(snap_path) # Track parent dirs for cleanup verification context.fsdcov_discard_snap_parents = [ str(Path(v).parent) for v in context.fsdcov_handler._checkpoints.values() ] for p in context.fsdcov_discard_snap_parents: context.add_cleanup(lambda pp=p: shutil.rmtree(pp, ignore_errors=True)) @when('fsdcov I call discard_checkpoints with plan_id "{plan_id}"') def step_fsdcov_discard_checkpoints(context: Context, plan_id: str) -> None: context.fsdcov_discard_count = context.fsdcov_handler.discard_checkpoints( plan_id=plan_id ) @then("fsdcov discard_checkpoints returns {count:d}") def step_fsdcov_discard_returns(context: Context, count: int) -> None: assert context.fsdcov_discard_count == count, ( f"Expected {count}, got {context.fsdcov_discard_count}" ) @then('fsdcov no checkpoints remain for plan "{plan_id}"') def step_fsdcov_no_checkpoints_for_plan(context: Context, plan_id: str) -> None: remaining = [cid for cid in context.fsdcov_handler._checkpoints if plan_id in cid] assert remaining == [], ( f"Expected no checkpoints for '{plan_id}', found {remaining}" ) @given("fsdcov a handler with checkpoints for different plans") def step_fsdcov_multi_plan_checkpoints(context: Context) -> None: context.fsdcov_handler = FsDirectoryHandler() for plan_name in ("plan-x", "plan-y"): snap_dir = tempfile.mkdtemp(prefix=f"fsdcov_mp_{plan_name}_") snap_path = Path(snap_dir) / "snapshot" snap_path.mkdir() (snap_path / "f.txt").write_text("data") ckpt_id = f"checkpoint-{plan_name}-20240101" context.fsdcov_handler._checkpoints[ckpt_id] = str(snap_path) context.add_cleanup(lambda d=snap_dir: shutil.rmtree(d, ignore_errors=True)) @when("fsdcov I call discard_checkpoints with empty plan_id") def step_fsdcov_discard_all(context: Context) -> None: context.fsdcov_discard_count = context.fsdcov_handler.discard_checkpoints( plan_id="" ) @then("fsdcov all checkpoints are removed") def step_fsdcov_all_removed(context: Context) -> None: assert len(context.fsdcov_handler._checkpoints) == 0, ( f"Expected 0 checkpoints, found {len(context.fsdcov_handler._checkpoints)}" ) assert context.fsdcov_discard_count >= 2, ( f"Expected at least 2 removed, got {context.fsdcov_discard_count}" )