Files
cleveragents-core/features/steps/checkpoint_manager_coverage_steps.py
HAL9000 66850665b7
CI / push-validation (pull_request) Successful in 10s
CI / helm (pull_request) Successful in 33s
CI / lint (pull_request) Successful in 44s
CI / quality (pull_request) Successful in 48s
CI / security (pull_request) Successful in 54s
CI / e2e_tests (pull_request) Successful in 3m14s
CI / build (pull_request) Successful in 3m25s
CI / typecheck (pull_request) Successful in 4m1s
CI / integration_tests (pull_request) Successful in 6m33s
CI / unit_tests (pull_request) Successful in 7m48s
CI / docker (pull_request) Successful in 55s
CI / coverage (pull_request) Successful in 5m58s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / push-validation (push) Successful in 13s
CI / lint (push) Successful in 19s
CI / helm (push) Successful in 24s
CI / build (push) Successful in 31s
CI / quality (push) Successful in 39s
CI / typecheck (push) Successful in 42s
CI / security (push) Successful in 43s
CI / e2e_tests (push) Successful in 3m20s
CI / unit_tests (push) Successful in 3m36s
CI / integration_tests (push) Successful in 4m34s
CI / docker (push) Successful in 1m38s
CI / coverage (push) Successful in 9m25s
CI / status-check (push) Successful in 1s
fix(sandbox): split oversized step file and update CONTRIBUTORS.md
Split checkpoint_manager_coverage_steps.py (692 lines) into three
focused modules to comply with the 500-line file limit:

- checkpoint_manager_coverage_steps.py: model, protocol, manager init,
  create_checkpoint, _snapshot_directory, and _cleanup_snapshot steps
  (408 lines)
- checkpoint_manager_rollback_steps.py: rollback_to, list_checkpoints,
  and delete_checkpoint branch steps (283 lines)
- checkpoint_manager_bug7488_steps.py: Bug #7488 sandbox_path
  auto-stored-in-metadata steps (59 lines)

Also updated CONTRIBUTORS.md to document HAL 9000's contribution for
bug fix #7488 as required by CONTRIBUTING.md guidelines.

ISSUES CLOSED: #7488
2026-04-17 18:34:16 +00:00

409 lines
14 KiB
Python

"""Step definitions for checkpoint_manager_coverage.feature.
Targets every method, branch, and edge case in checkpoint.py to achieve
full line-rate and branch-rate coverage.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import threading
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.infrastructure.sandbox.checkpoint import (
Checkpointable,
CheckpointManager,
SandboxCheckpoint,
)
from cleveragents.infrastructure.sandbox.protocol import SandboxError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_sandbox(
sandbox_id: str = "sb-mock-001",
context: Any = None,
) -> MagicMock:
"""Build a mock that satisfies the Checkpointable protocol."""
sb = MagicMock()
sb.sandbox_id = sandbox_id
sb.context = context
return sb
def _make_temp_sandbox_dir() -> str:
"""Create a temp dir with sample files and a subdirectory."""
tmpdir = tempfile.mkdtemp(prefix="ca-cpmgr-test-")
os.makedirs(os.path.join(tmpdir, "subdir"), exist_ok=True)
with open(os.path.join(tmpdir, "file.txt"), "w") as f:
f.write("original\n")
with open(os.path.join(tmpdir, "subdir", "nested.txt"), "w") as f:
f.write("nested-original\n")
return tmpdir
def _register_cleanup(context: Context, path: str) -> None:
"""Schedule a directory for cleanup after the scenario."""
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: shutil.rmtree(path, ignore_errors=True))
# ===========================================================================
# SandboxCheckpoint Pydantic model
# ===========================================================================
@given("I build a SandboxCheckpoint with valid fields")
def step_build_checkpoint_model(context: Context) -> None:
context.cp_model = SandboxCheckpoint(
checkpoint_id="cp-aaa",
sandbox_id="sb-bbb",
plan_id="plan-ccc",
phase="pre_execute",
created_at=datetime(2025, 1, 1, 12, 0, 0),
metadata={"key": "val"},
snapshot_path="/tmp/snap",
)
@then("the constructed checkpoint model should expose all field values")
def step_assert_checkpoint_fields(context: Context) -> None:
cp = context.cp_model
assert cp.checkpoint_id == "cp-aaa"
assert cp.sandbox_id == "sb-bbb"
assert cp.plan_id == "plan-ccc"
assert cp.phase == "pre_execute"
assert cp.created_at == datetime(2025, 1, 1, 12, 0, 0)
assert cp.metadata == {"key": "val"}
assert cp.snapshot_path == "/tmp/snap"
@then("the constructed checkpoint model should be frozen")
def step_assert_checkpoint_frozen(context: Context) -> None:
try:
context.cp_model.phase = "modified"
raise AssertionError("Expected ValidationError for frozen model")
except Exception:
pass # pydantic frozen model rejects mutation
@given("I build a SandboxCheckpoint without explicit metadata")
def step_build_checkpoint_no_metadata(context: Context) -> None:
context.cp_model = SandboxCheckpoint(
checkpoint_id="cp-ddd",
sandbox_id="sb-eee",
plan_id="plan-fff",
phase="post_execute",
created_at=datetime.now(),
snapshot_path="/tmp/snap2",
)
@then("the constructed checkpoint metadata should be an empty dict")
def step_assert_empty_metadata_default(context: Context) -> None:
assert context.cp_model.metadata == {}
# ===========================================================================
# Checkpointable protocol
# ===========================================================================
@given("a mock object with sandbox_id and context properties")
def step_mock_checkpointable(context: Context) -> None:
class _Good:
@property
def sandbox_id(self) -> str:
return "sb-proto"
@property
def context(self) -> Any:
return None
context.proto_obj = _Good()
@then("the mock object should satisfy the Checkpointable protocol")
def step_assert_satisfies_checkpointable(context: Context) -> None:
assert isinstance(context.proto_obj, Checkpointable)
@given("a mock object without a sandbox_id property")
def step_mock_not_checkpointable(context: Context) -> None:
class _Bad:
@property
def context(self) -> Any:
return None
context.proto_obj = _Bad()
@then("the mock object should not satisfy the Checkpointable protocol")
def step_assert_not_checkpointable(context: Context) -> None:
assert not isinstance(context.proto_obj, Checkpointable)
# ===========================================================================
# CheckpointManager.__init__
# ===========================================================================
@given("a freshly created CheckpointManager")
def step_fresh_manager(context: Context) -> None:
context.mgr = CheckpointManager()
context.crafted_cp = None
context.cp_result = None
context.rollback_result = None
context.delete_result = None
context.cp_list = None
context.snapshot_result = None
context.raised_error = None
context.cleanup_error = None
@then("the manager internal checkpoints dict should be empty")
def step_assert_empty_checkpoints(context: Context) -> None:
assert context.mgr._checkpoints == {}
@then("the manager should have a threading lock")
def step_assert_has_lock(context: Context) -> None:
assert isinstance(context.mgr._lock, type(threading.RLock()))
# ===========================================================================
# create_checkpoint - context is None
# ===========================================================================
@given("a mock sandbox whose context is None")
def step_mock_sandbox_no_context(context: Context) -> None:
context.mock_sb = _make_mock_sandbox(sandbox_id="sb-nocontext")
@when(
'I invoke create_checkpoint with plan "{plan_id}" phase "{phase}" and no metadata'
)
def step_create_cp_no_metadata(context: Context, plan_id: str, phase: str) -> None:
context.cp_result = context.mgr.create_checkpoint(
sandbox=context.mock_sb,
plan_id=plan_id,
phase=phase,
)
@then("the returned checkpoint should have a non-empty snapshot_path directory")
def step_assert_snapshot_path_dir(context: Context) -> None:
sp = context.cp_result.snapshot_path
assert sp, "snapshot_path should not be empty"
assert os.path.isdir(sp), f"Expected directory at {sp}"
_register_cleanup(context, os.path.dirname(sp))
@then("the returned checkpoint sandbox_id should equal the mock sandbox id")
def step_assert_cp_sandbox_id(context: Context) -> None:
assert context.cp_result.sandbox_id == context.mock_sb.sandbox_id
@when(
'I invoke create_checkpoint with plan "{plan_id}" phase "{phase}" and numeric metadata'
)
def step_create_cp_numeric_metadata(context: Context, plan_id: str, phase: str) -> None:
context.cp_result = context.mgr.create_checkpoint(
sandbox=context.mock_sb,
plan_id=plan_id,
phase=phase,
metadata={"count": 42, "flag": True},
)
_register_cleanup(context, os.path.dirname(context.cp_result.snapshot_path))
@then("the returned checkpoint metadata values should all be strings")
def step_assert_metadata_strings(context: Context) -> None:
for k, v in context.cp_result.metadata.items():
assert isinstance(v, str), f"metadata[{k!r}] = {v!r} is not a string"
@when(
'I invoke create_checkpoint with plan "{plan_id}" phase "{phase}" and None metadata'
)
def step_create_cp_none_metadata(context: Context, plan_id: str, phase: str) -> None:
context.cp_result = context.mgr.create_checkpoint(
sandbox=context.mock_sb,
plan_id=plan_id,
phase=phase,
metadata=None,
)
_register_cleanup(context, os.path.dirname(context.cp_result.snapshot_path))
@then("the returned checkpoint metadata should be an empty dict")
def step_assert_cp_metadata_empty(context: Context) -> None:
assert context.cp_result.metadata == {}
# ===========================================================================
# create_checkpoint - context with sandbox_path
# ===========================================================================
@given("a temporary sandbox directory with sample files")
def step_temp_sandbox_dir(context: Context) -> None:
context.temp_sandbox = _make_temp_sandbox_dir()
_register_cleanup(context, context.temp_sandbox)
@given("a mock sandbox whose context points to the temporary directory")
def step_mock_sandbox_with_context(context: Context) -> None:
ctx = MagicMock()
ctx.sandbox_path = context.temp_sandbox
context.mock_sb = _make_mock_sandbox(sandbox_id="sb-withctx", context=ctx)
@then("the snapshot directory should contain copies of the sample files")
def step_assert_snapshot_has_files(context: Context) -> None:
snap = context.cp_result.snapshot_path
_register_cleanup(context, os.path.dirname(snap))
assert os.path.isfile(os.path.join(snap, "file.txt"))
assert os.path.isfile(os.path.join(snap, "subdir", "nested.txt"))
with open(os.path.join(snap, "file.txt")) as f:
assert f.read() == "original\n"
# ===========================================================================
# _snapshot_directory - branches
# ===========================================================================
@when("I call _snapshot_directory with a None sandbox_path")
def step_snapshot_none_path(context: Context) -> None:
context.snapshot_result = context.mgr._snapshot_directory(None, "cp-test-none")
_register_cleanup(context, os.path.dirname(context.snapshot_result))
@then("the returned snapshot should be an existing empty directory")
def step_assert_snapshot_empty_dir(context: Context) -> None:
sp = context.snapshot_result
assert os.path.isdir(sp), f"Expected directory at {sp}"
assert os.listdir(sp) == [], f"Expected empty dir, got {os.listdir(sp)}"
@when("I call _snapshot_directory with a non-existent sandbox_path")
def step_snapshot_nonexistent_path(context: Context) -> None:
context.snapshot_result = context.mgr._snapshot_directory(
"/nonexistent/path/xyz", "cp-test-noexist"
)
_register_cleanup(context, os.path.dirname(context.snapshot_result))
@when("I call _snapshot_directory with the temporary sandbox_path")
def step_snapshot_valid_path(context: Context) -> None:
context.snapshot_result = context.mgr._snapshot_directory(
context.temp_sandbox, "cp-test-valid"
)
_register_cleanup(context, os.path.dirname(context.snapshot_result))
@then("the returned snapshot should contain the sample files")
def step_assert_snapshot_has_sample_files(context: Context) -> None:
sp = context.snapshot_result
assert os.path.isfile(os.path.join(sp, "file.txt"))
assert os.path.isfile(os.path.join(sp, "subdir", "nested.txt"))
@when("I call _snapshot_directory with a path that triggers OSError")
def step_snapshot_oserror(context: Context) -> None:
context.raised_error = None
with patch("shutil.copytree", side_effect=OSError("mock copy failure")):
try:
context.mgr._snapshot_directory(context.temp_sandbox, "cp-test-err")
except SandboxError as exc:
context.raised_error = exc
@then("a SandboxError should have been raised")
def step_assert_sandbox_error(context: Context) -> None:
assert context.raised_error is not None, "Expected SandboxError to be raised"
assert isinstance(context.raised_error, SandboxError)
assert "Failed to snapshot" in str(context.raised_error)
# ===========================================================================
# _cleanup_snapshot - branches
# ===========================================================================
@when("I call _cleanup_snapshot with an empty string")
def step_cleanup_empty_string(context: Context) -> None:
context.cleanup_error = None
try:
CheckpointManager._cleanup_snapshot("")
except Exception as exc:
context.cleanup_error = exc
@then("no error should occur from cleanup_snapshot")
def step_assert_no_cleanup_error(context: Context) -> None:
assert context.cleanup_error is None, (
f"Expected no error, got {context.cleanup_error!r}"
)
@given("a temporary snapshot directory tree for cleanup")
def step_temp_snapshot_for_cleanup(context: Context) -> None:
parent = tempfile.mkdtemp(prefix="ca-cleanup-test-")
snap = os.path.join(parent, "snapshot")
os.makedirs(snap, exist_ok=True)
with open(os.path.join(snap, "data.txt"), "w") as f:
f.write("to-be-cleaned\n")
context.cleanup_parent = parent
context.cleanup_snapshot = snap
@when("I call _cleanup_snapshot with the temporary snapshot path")
def step_cleanup_real_snapshot(context: Context) -> None:
context.cleanup_error = None
try:
CheckpointManager._cleanup_snapshot(context.cleanup_snapshot)
except Exception as exc:
context.cleanup_error = exc
@then("the temporary snapshot parent should no longer exist")
def step_assert_parent_removed(context: Context) -> None:
assert not os.path.exists(context.cleanup_parent), (
f"Parent should have been removed: {context.cleanup_parent}"
)
@when("I call _cleanup_snapshot with a non-existent path")
def step_cleanup_nonexistent(context: Context) -> None:
context.cleanup_error = None
try:
CheckpointManager._cleanup_snapshot("/nonexistent/snapshot/path")
except Exception as exc:
context.cleanup_error = exc
# ===========================================================================
# rollback_to, list_checkpoints, delete_checkpoint branches
# (step definitions moved to checkpoint_manager_rollback_steps.py to keep
# this file under the 500-line limit)
# ===========================================================================
# ===========================================================================
# Bug fix #7488 — sandbox_path auto-stored in metadata
# (step definitions moved to checkpoint_manager_bug7488_steps.py to keep
# this file under the 500-line limit)
# ===========================================================================