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
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
409 lines
14 KiB
Python
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)
|
|
# ===========================================================================
|