forked from HAL9000/cleveragents-core
7f078f75a5
Changed SandboxManager.commit_all() from partial-commit semantics to all-or-nothing atomic operation per specification requirement. On partial failure, already-committed sandboxes are rolled back. Error reporting indicates which sandbox failed and what was rolled back. Added Behave scenarios verifying atomicity guarantee. Hardened atomicity guarantees after code review: - commit_all and _rollback_committed catch Exception (not just SandboxError) so unexpected errors cannot bypass rollback. Non-SandboxError exceptions are wrapped in a new AtomicCommitError (chaining the original as __cause__) that carries rolled_back_ids and failed_rollback_ids attributes so callers can programmatically determine rollback outcomes. - _rollback_committed returns both rolled_back_ids and failed_rollback_ids; the error result metadata now carries both "rolled_back" and "rollback_failed" keys. - _rollback_committed iterates in reverse (LIFO) order following the standard transaction-log undo pattern. Clarified in docstring that this is distinct from the specification DAG-based "top-down" rollback ordering (line 24632). - TransactionSandbox.rollback() from COMMITTED now raises SandboxRollbackError (database commits are irreversible) instead of silently transitioning to ROLLED_BACK. - TransactionSandbox is now classified as non-rollbackable in commit_all alongside NoSandbox and committed last in the batch, since database COMMIT is irreversible. Docstring corrected to match the raising behavior. - CopyOnWriteSandbox and OverlaySandbox rollback-from-COMMITTED uses rename-based safe_restore() to prevent data loss when the copytree step fails after the original was removed. - CopyOnWriteSandbox and OverlaySandbox rollback() now catches Exception (not just OSError), matching the broader catch used in _rollback_committed, so non-OSError exceptions from safe_restore set the status to ERRORED correctly. - Extracted shared _fs_utils module (backup_directory, safe_restore, compute_diff) with symlink, permission, and timestamp preservation (including directory timestamps), replacing duplicated per-class _backup_directory and _compute_diff methods. - backup_directory defers directory permissions and timestamps to a bottom-up post-walk pass, fixing incorrect mtime preservation (POSIX file creation inside a directory overwrites its mtime) and preventing restrictive source permissions from blocking backup writes. - backup_directory skips non-regular files (FIFOs, sockets, device files) with a warning to prevent hangs on special files. - CopyOnWriteSandbox and OverlaySandbox commit() now attempts to restore the original from the pre-commit backup when the file-copy phase fails midway, preventing partial corruption. If the restore itself fails the backup is preserved for manual recovery (cleanup() still removes it). - CopyOnWriteSandbox and OverlaySandbox commit() error handler now catches Exception (not just OSError) so that unexpected errors during the file-copy phase also trigger pre-commit backup restoration, preventing partial corruption of the original directory. - Pre-commit backup exception handler catches Exception (not just OSError) preventing temp directory leaks on non-OSError failures from backup_directory. - Fixed _pre_commit_backup assignment timing: the backup reference is now assigned only AFTER backup_directory() succeeds, preventing safe_restore() from corrupting an intact original with a partial backup when backup_directory() fails (e.g. disk full). - Rollback from COMMITTED with no pre-commit backup (no changes were applied) is now a no-op instead of raising SandboxRollbackError, preventing false rollback-failure reports in commit_all error metadata. - commit_all logs a warning when NoSandbox or TransactionSandbox instances are present in the batch since their changes cannot be rolled back, which breaks the atomicity guarantee. - Pre-commit backup is skipped when compute_diff returns no changes, avoiding a full directory copy for no-op commits. - GitWorktreeSandbox clears _pre_merge_commit on commit failure so the stale value cannot be used by future code. - commit_all docstring documents Raises clause for AtomicCommitError exception wrapping behavior. - GitWorktreeSandbox.rollback() docstring warns about multi-worktree safety when rolling back from COMMITTED. - Updated SandboxStatus transition diagram in protocol.py to clearly show the COMMITTED -> ROLLED_BACK path. - Added spec-contradiction note (line 45938 vs 19193) in commit_all docstring. - OverlaySandbox rollback from COMMITTED now properly remounts OverlayFS for real overlay (unmount, clean upper/work dirs, remount) and uses dirs_exist_ok=True for userspace fallback to prevent FileExistsError if rmtree silently fails. The merged directory is reset from the restored original, preventing stale pre-rollback data from being exposed on re-activation via get_path() (which allows ROLLED_BACK status). - OverlaySandbox rollback from COMMITTED now raises SandboxRollbackError if the OverlayFS unmount fails, preventing a double-mount attempt that would leave the sandbox in an inconsistent state. - OverlaySandbox rollback from ACTIVE now uses dirs_exist_ok=True for userspace fallback to prevent FileExistsError when rmtree with ignore_errors=True silently fails. - CopyOnWriteSandbox rollback from ACTIVE now uses dirs_exist_ok=True in copytree to prevent FileExistsError when rmtree with ignore_errors=True silently fails, matching the fix already applied to OverlaySandbox. - Non-rollbackable sandboxes (NoSandbox, TransactionSandbox) are committed last in the batch so that all rollbackable sandboxes commit first; if any rollbackable sandbox fails, none of the non-rollbackable sandboxes will have committed yet. - Moved NoSandbox and TransactionSandbox imports to module level in manager.py (no circular dependency exists). - Pre-commit backups are now created on the same filesystem as the original directory (using dir= argument to mkdtemp), avoiding cross-device copy overhead and ensuring os.rename compatibility. - safe_restore now renames the target into the mkdtemp directory instead of removing the mkdtemp dir first, eliminating the residual TOCTOU window between rmdir and rename. - safe_restore catches BaseException (not just OSError) to ensure the original directory is always renamed back on unexpected errors, preventing the original from being left in the renamed-aside state. - Added AtomicCommitError exception class to protocol.py carrying rolled_back_ids and failed_rollback_ids attributes. - Exported AtomicCommitError from sandbox package __init__.py so callers can import it from the public API. - Added BDD scenarios: LIFO rollback order, AtomicCommitError wrapping with RuntimeError cause and rollback metadata, _fs_utils backup/restore coverage, no-change commit rollback success, directory timestamp preservation, OverlaySandbox merged dir reset after COMMITTED rollback, CopyOnWriteSandbox rollback from COMMITTED restores original, GitWorktreeSandbox rollback from COMMITTED undoes merge, TransactionSandbox rollback from COMMITTED raises SandboxRollbackError about irreversible commit. ISSUES CLOSED: #925 Post-review hardening (PR #1146 review findings): - OverlaySandbox rollback from COMMITTED with no backup (no-op) now skips the merged directory reset entirely, preventing unnecessary unmount/remount or re-copy that could fail and turn a harmless no-op rollback into a SandboxRollbackError during commit_all atomic recovery. - OverlaySandbox rollback no longer double-wraps SandboxRollbackError: the outer except Exception handler now has a preceding except SandboxRollbackError clause that re-raises directly, avoiding a confusing double-wrapped error chain. - CopyOnWriteSandbox.get_path() now accepts ROLLED_BACK status for consistency with OverlaySandbox and the protocol status transition table (ROLLED_BACK -> ACTIVE). - CopyOnWriteSandbox rollback from COMMITTED now resets the sandbox copy from the restored original via rmtree+copytree, preventing stale pre-rollback modifications from being exposed on re-activation. - rollback_all now catches Exception (not just SandboxError) so that unexpected rollback errors do not prevent remaining sandboxes from being rolled back, consistent with the pattern already used in _rollback_committed. - commit_all docstring now documents a thread-safety warning: the method is not safe for concurrent calls on the same plan_id since sandbox commit/rollback runs outside the lock. - Fixed CHANGELOG.md whitespace inconsistencies (double leading spaces on two lines). Post-review hardening round 2 (PR #1146 automated review): - safe_restore now uses os.rename (O(1) atomic rename) instead of shutil.copytree (O(n) recursive copy) for the main restore path, since backup and target are always on the same filesystem. This eliminates the ENOTEMPTY bug where a partial copytree failure left target_path partially populated, causing the recovery os.rename to fail and strand the original in the stale temp directory. - OverlaySandbox.get_path() now transitions ROLLED_BACK to ACTIVE, matching CopyOnWriteSandbox and the protocol transition table (ROLLED_BACK -> ACTIVE). - GitWorktreeSandbox.get_path() now accepts ROLLED_BACK status for consistency with all other sandbox implementations and the protocol transition table (ROLLED_BACK -> ACTIVE). - rollback_all now also handles sandboxes in COMMITTED status (not just ACTIVE), consistent with the state machine allowing COMMITTED -> ROLLED_BACK. - cleanup_all now catches Exception (not just SandboxError) so a single unexpected error does not abort cleanup of remaining sandboxes, consistent with _rollback_committed and rollback_all. - Restructured CHANGELOG entry from a single ~90-line paragraph into structured sub-bullets for readability. - Added BDD scenarios: no-op rollback from COMMITTED for CopyOnWriteSandbox and OverlaySandbox (zero-change commit), commit ordering verification (rollbackable before non-rollbackable). Post-review hardening round 3 (PR #1146 deep automated review): - cleanup_abandoned now catches Exception (not just SandboxError) so that unexpected errors (e.g. raw OSError, PermissionError) do not crash the loop and prevent remaining abandoned sandboxes from being cleaned up, consistent with cleanup_all, rollback_all, and _rollback_committed. - OverlaySandbox._mount_overlay() now catches subprocess.TimeoutExpired (in addition to CalledProcessError and OSError), preventing create() from leaving the sandbox in PENDING status when mount hangs beyond the timeout. - OverlaySandbox._unmount_overlay() now catches subprocess.TimeoutExpired (in addition to CalledProcessError and OSError), preventing cleanup() from leaving the sandbox in a zombie state when umount hangs beyond the timeout. - OverlaySandbox._mount_overlay() validates that overlay paths do not contain commas, which would corrupt the OverlayFS mount options string (comma is the mount option delimiter). - GitWorktreeSandbox.commit() now checks git diff return code so that a failed diff command raises CalledProcessError instead of silently concluding there are no changes and skipping the merge. - safe_restore cleanup of the temporary rollback container now runs in a finally block, preventing a temp directory leak when the rename fails and the exception is re-raised. - Fixed misleading BDD step name: "backup path that will cause copytree to fail" renamed to "backup path that will cause rename to fail" since safe_restore now uses os.rename.
428 lines
17 KiB
Python
428 lines
17 KiB
Python
"""Step definitions for CopyOnWriteSandbox uncovered lines and branches.
|
|
|
|
Targets every gap identified in the coverage report for
|
|
``copy_on_write.py`` (line-rate=0.9124, branch-rate=0.8519):
|
|
|
|
- Lines 143-145: ``create()`` OSError handler wrapping into SandboxCreationError
|
|
- Line 196 branch False→199: ``get_path()`` when already ACTIVE (skip CREATED transition)
|
|
- Line 200: ``get_path()`` raises when ``_sandbox_path`` is ``None``
|
|
- Line 230: ``commit()`` raises when ``_sandbox_path`` is ``None``
|
|
- Line 242 branch False→244: ``commit()`` skips ``os.makedirs`` when ``dst_dir`` is empty
|
|
- Line 249 branch False→247: ``commit()`` deleted file already missing from original
|
|
- Lines 252-254: ``commit()`` OSError handler wrapping into SandboxCommitError
|
|
- Line 297: ``rollback()`` raises when ``_sandbox_path`` is ``None``
|
|
- Lines 311-313: ``rollback()`` OSError handler wrapping into SandboxRollbackError
|
|
- Line 341 branch False→347: ``cleanup()`` when ``_sandbox_path`` is ``None``
|
|
- Line 344 branch False→347: ``cleanup()`` when parent dir already removed
|
|
|
|
All steps use the ``cowcov`` prefix to avoid collisions with existing
|
|
``cow`` and ``cowcb`` step prefixes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
|
|
from cleveragents.infrastructure.sandbox.protocol import (
|
|
SandboxCommitError,
|
|
SandboxCreationError,
|
|
SandboxRollbackError,
|
|
SandboxStateError,
|
|
SandboxStatus,
|
|
)
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Helpers
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def _cowcov_make_test_dir() -> str:
|
|
"""Create a temporary directory with seed files for testing."""
|
|
d = tempfile.mkdtemp(prefix="cowcov-test-dir-")
|
|
with open(os.path.join(d, "existing.txt"), "w") as f:
|
|
f.write("original content")
|
|
with open(os.path.join(d, "to_delete.txt"), "w") as f:
|
|
f.write("will be deleted")
|
|
os.makedirs(os.path.join(d, "sub"), exist_ok=True)
|
|
with open(os.path.join(d, "sub", "nested.txt"), "w") as f:
|
|
f.write("nested")
|
|
return d
|
|
|
|
|
|
def _cowcov_cleanup_dir(path: str) -> None:
|
|
"""Remove a directory tree if it still exists."""
|
|
if path and os.path.exists(path):
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a cowcov test directory is initialised")
|
|
def step_cowcov_init_dir(context: Context) -> None:
|
|
"""Create a fresh temporary directory with seed files."""
|
|
context.cowcov_test_dir = _cowcov_make_test_dir()
|
|
context.cowcov_sandbox = None
|
|
context.cowcov_error = None
|
|
context.cowcov_commit_result = None
|
|
context.cowcov_resolved_paths = []
|
|
context.cowcov_makedirs_mock = None
|
|
context._cleanup_handlers.append(
|
|
lambda: _cowcov_cleanup_dir(context.cowcov_test_dir)
|
|
)
|
|
|
|
|
|
@given("shutil.copytree is patched to raise OSError in cowcov")
|
|
def step_cowcov_patch_copytree_oserror(context: Context) -> None:
|
|
"""Patch shutil.copytree in the copy_on_write module to raise OSError."""
|
|
patcher = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copytree",
|
|
side_effect=OSError("mocked copytree failure"),
|
|
)
|
|
patcher.start()
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
@given('a cowcov sandbox is created for plan "{plan_id}"')
|
|
def step_cowcov_create_sandbox(context: Context, plan_id: str) -> None:
|
|
"""Create a CopyOnWriteSandbox and call create()."""
|
|
context.cowcov_sandbox = CopyOnWriteSandbox(
|
|
resource_id="res-cowcov",
|
|
original_path=context.cowcov_test_dir,
|
|
)
|
|
context.cowcov_sandbox.create(plan_id)
|
|
sandbox_path = context.cowcov_sandbox._sandbox_path
|
|
if sandbox_path:
|
|
parent = os.path.dirname(sandbox_path)
|
|
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(parent))
|
|
|
|
|
|
@given('a cowcov sandbox is created and activated for plan "{plan_id}"')
|
|
def step_cowcov_create_and_activate(context: Context, plan_id: str) -> None:
|
|
"""Create a sandbox and transition to ACTIVE via get_path."""
|
|
context.cowcov_sandbox = CopyOnWriteSandbox(
|
|
resource_id="res-cowcov",
|
|
original_path=context.cowcov_test_dir,
|
|
)
|
|
context.cowcov_sandbox.create(plan_id)
|
|
context.cowcov_sandbox.get_path("existing.txt")
|
|
assert context.cowcov_sandbox.status == SandboxStatus.ACTIVE, (
|
|
f"Expected ACTIVE, got {context.cowcov_sandbox.status}"
|
|
)
|
|
sandbox_path = context.cowcov_sandbox._sandbox_path
|
|
if sandbox_path:
|
|
parent = os.path.dirname(sandbox_path)
|
|
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(parent))
|
|
|
|
|
|
@given("a cowcov sandbox forced to ACTIVE with sandbox_path None")
|
|
def step_cowcov_force_active_none(context: Context) -> None:
|
|
"""Create a sandbox stub with status=ACTIVE and _sandbox_path=None."""
|
|
d = _cowcov_make_test_dir()
|
|
context.cowcov_test_dir = d
|
|
sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d)
|
|
sandbox._status = SandboxStatus.ACTIVE
|
|
sandbox._sandbox_path = None
|
|
context.cowcov_sandbox = sandbox
|
|
context.cowcov_error = None
|
|
context.cowcov_commit_result = None
|
|
context.cowcov_resolved_paths = []
|
|
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d))
|
|
|
|
|
|
@given("a cowcov sandbox forced to CREATED with sandbox_path None")
|
|
def step_cowcov_force_created_none(context: Context) -> None:
|
|
"""Create a sandbox stub with status=CREATED and _sandbox_path=None."""
|
|
d = _cowcov_make_test_dir()
|
|
context.cowcov_test_dir = d
|
|
sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d)
|
|
sandbox._status = SandboxStatus.CREATED
|
|
sandbox._sandbox_path = None
|
|
context.cowcov_sandbox = sandbox
|
|
context.cowcov_error = None
|
|
context.cowcov_commit_result = None
|
|
context.cowcov_resolved_paths = []
|
|
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d))
|
|
|
|
|
|
@given("a cowcov sandbox in PENDING state with sandbox_path None")
|
|
def step_cowcov_pending_none(context: Context) -> None:
|
|
"""Create a sandbox that is still PENDING (never created)."""
|
|
d = _cowcov_make_test_dir()
|
|
context.cowcov_test_dir = d
|
|
sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d)
|
|
assert sandbox._sandbox_path is None, "Expected _sandbox_path to be None"
|
|
assert sandbox.status == SandboxStatus.PENDING, "Expected PENDING status"
|
|
context.cowcov_sandbox = sandbox
|
|
context.cowcov_error = None
|
|
context.cowcov_commit_result = None
|
|
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d))
|
|
|
|
|
|
@given("cowcov _compute_diff is patched to return a changed root file")
|
|
def step_cowcov_patch_compute_diff(context: Context) -> None:
|
|
"""Patch compute_diff to return one changed file at root level."""
|
|
patcher = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.compute_diff",
|
|
return_value=(["rootfile.txt"], [], []),
|
|
)
|
|
patcher.start()
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
@given("cowcov os.path.dirname is patched to return empty string")
|
|
def step_cowcov_patch_dirname(context: Context) -> None:
|
|
"""Patch os.path.dirname in the copy_on_write module to return ''."""
|
|
patcher = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.os.path.dirname",
|
|
return_value="",
|
|
)
|
|
patcher.start()
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
@given("cowcov shutil.copy2 is patched to no-op")
|
|
def step_cowcov_patch_copy2_noop(context: Context) -> None:
|
|
"""Patch shutil.copy2 in the copy_on_write module to do nothing."""
|
|
makedirs_mock = MagicMock()
|
|
patcher_copy2 = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2",
|
|
)
|
|
patcher_makedirs = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.os.makedirs",
|
|
makedirs_mock,
|
|
)
|
|
patcher_copy2.start()
|
|
patcher_makedirs.start()
|
|
context.cowcov_makedirs_mock = makedirs_mock
|
|
context._cleanup_handlers.append(patcher_copy2.stop)
|
|
context._cleanup_handlers.append(patcher_makedirs.stop)
|
|
|
|
|
|
@given('a cowcov file "existing.txt" is modified in the sandbox')
|
|
def step_cowcov_modify_existing(context: Context) -> None:
|
|
"""Modify a file inside the sandbox copy."""
|
|
sandbox_path = context.cowcov_sandbox._sandbox_path
|
|
assert sandbox_path is not None, "Sandbox path must be set"
|
|
fpath = os.path.join(sandbox_path, "existing.txt")
|
|
with open(fpath, "w") as f:
|
|
f.write("modified for OSError test")
|
|
|
|
|
|
@given("cowcov shutil.copy2 is patched to raise OSError")
|
|
def step_cowcov_patch_copy2_oserror(context: Context) -> None:
|
|
"""Patch shutil.copy2 in copy_on_write module to raise OSError."""
|
|
patcher = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2",
|
|
side_effect=OSError("mocked copy2 failure"),
|
|
)
|
|
patcher.start()
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
@given("cowcov shutil.copytree is patched to raise OSError for rollback")
|
|
def step_cowcov_patch_copytree_rollback(context: Context) -> None:
|
|
"""Patch shutil.copytree to raise OSError (for rollback re-copy)."""
|
|
patcher = patch(
|
|
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copytree",
|
|
side_effect=OSError("mocked copytree failure during rollback"),
|
|
)
|
|
patcher.start()
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
@given("the cowcov sandbox parent directory is manually removed")
|
|
def step_cowcov_remove_parent(context: Context) -> None:
|
|
"""Remove the sandbox parent temp dir before cleanup."""
|
|
sandbox_path = context.cowcov_sandbox._sandbox_path
|
|
assert sandbox_path is not None, "Sandbox path must be set"
|
|
parent = os.path.dirname(sandbox_path)
|
|
if os.path.exists(parent):
|
|
shutil.rmtree(parent, ignore_errors=True)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when('a cowcov sandbox create is attempted for plan "{plan_id}"')
|
|
def step_cowcov_attempt_create(context: Context, plan_id: str) -> None:
|
|
"""Attempt to create a sandbox, capturing any error."""
|
|
context.cowcov_sandbox = CopyOnWriteSandbox(
|
|
resource_id="res-cowcov",
|
|
original_path=context.cowcov_test_dir,
|
|
)
|
|
try:
|
|
context.cowcov_sandbox.create(plan_id)
|
|
except (SandboxCreationError, SandboxStateError) as exc:
|
|
context.cowcov_error = exc
|
|
|
|
|
|
@when('cowcov get_path is called with "{path}"')
|
|
def step_cowcov_get_path(context: Context, path: str) -> None:
|
|
"""Call get_path and record the resolved path."""
|
|
resolved = context.cowcov_sandbox.get_path(path)
|
|
context.cowcov_resolved_paths.append(resolved)
|
|
|
|
|
|
@when('cowcov get_path is attempted with "{path}"')
|
|
def step_cowcov_attempt_get_path(context: Context, path: str) -> None:
|
|
"""Attempt get_path, capturing any error."""
|
|
try:
|
|
context.cowcov_sandbox.get_path(path)
|
|
except (SandboxStateError, ValueError) as exc:
|
|
context.cowcov_error = exc
|
|
|
|
|
|
@when("cowcov commit is attempted")
|
|
def step_cowcov_attempt_commit(context: Context) -> None:
|
|
"""Attempt commit, capturing any error."""
|
|
try:
|
|
context.cowcov_commit_result = context.cowcov_sandbox.commit()
|
|
except (SandboxStateError, SandboxCommitError) as exc:
|
|
context.cowcov_error = exc
|
|
|
|
|
|
@when('the cowcov file "existing.txt" is deleted from both sandbox and original')
|
|
def step_cowcov_delete_from_both(context: Context) -> None:
|
|
"""Delete a file from both sandbox copy and original directory."""
|
|
sandbox_path = context.cowcov_sandbox._sandbox_path
|
|
assert sandbox_path is not None, "Sandbox path must be set"
|
|
|
|
for base in (sandbox_path, context.cowcov_test_dir):
|
|
fpath = os.path.join(base, "existing.txt")
|
|
if os.path.exists(fpath):
|
|
os.remove(fpath)
|
|
|
|
|
|
@when("cowcov rollback is attempted")
|
|
def step_cowcov_attempt_rollback(context: Context) -> None:
|
|
"""Attempt rollback, capturing any error."""
|
|
try:
|
|
context.cowcov_sandbox.rollback()
|
|
except (SandboxStateError, SandboxRollbackError) as exc:
|
|
context.cowcov_error = exc
|
|
|
|
|
|
@when("cowcov cleanup is called")
|
|
def step_cowcov_cleanup(context: Context) -> None:
|
|
"""Call cleanup on the sandbox."""
|
|
context.cowcov_sandbox.cleanup()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Then
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@then("a cowcov SandboxCreationError should be raised")
|
|
def step_cowcov_check_creation_error(context: Context) -> None:
|
|
"""Assert a SandboxCreationError was captured."""
|
|
assert context.cowcov_error is not None, (
|
|
"Expected SandboxCreationError but no error occurred"
|
|
)
|
|
assert isinstance(context.cowcov_error, SandboxCreationError), (
|
|
f"Expected SandboxCreationError, got {type(context.cowcov_error).__name__}: "
|
|
f"{context.cowcov_error}"
|
|
)
|
|
|
|
|
|
@then('the cowcov error message should contain "{fragment}"')
|
|
def step_cowcov_error_message_contains(context: Context, fragment: str) -> None:
|
|
"""Assert the error message contains the given fragment."""
|
|
assert context.cowcov_error is not None, "No error was captured"
|
|
assert fragment in str(context.cowcov_error), (
|
|
f"Expected '{fragment}' in error message, got: {context.cowcov_error}"
|
|
)
|
|
|
|
|
|
@then('the cowcov sandbox status should be "{status}"')
|
|
def step_cowcov_check_status(context: Context, status: str) -> None:
|
|
"""Assert the sandbox status matches the expected value."""
|
|
expected = SandboxStatus(status)
|
|
assert context.cowcov_sandbox.status == expected, (
|
|
f"Expected status {expected.value}, got {context.cowcov_sandbox.status.value}"
|
|
)
|
|
|
|
|
|
@then("both cowcov resolved paths should be valid")
|
|
def step_cowcov_two_paths_valid(context: Context) -> None:
|
|
"""Assert two paths were resolved and both are inside the sandbox."""
|
|
assert len(context.cowcov_resolved_paths) == 2, (
|
|
f"Expected 2 resolved paths, got {len(context.cowcov_resolved_paths)}"
|
|
)
|
|
sandbox_path = context.cowcov_sandbox._sandbox_path
|
|
assert sandbox_path is not None, "Sandbox path should be set"
|
|
for p in context.cowcov_resolved_paths:
|
|
assert p.startswith(sandbox_path), (
|
|
f"Resolved path {p} is not inside sandbox {sandbox_path}"
|
|
)
|
|
|
|
|
|
@then('a cowcov SandboxStateError should be raised with message "{msg}"')
|
|
def step_cowcov_check_state_error_msg(context: Context, msg: str) -> None:
|
|
"""Assert a SandboxStateError with the expected message was captured."""
|
|
assert context.cowcov_error is not None, (
|
|
"Expected SandboxStateError but no error occurred"
|
|
)
|
|
assert isinstance(context.cowcov_error, SandboxStateError), (
|
|
f"Expected SandboxStateError, got {type(context.cowcov_error).__name__}: "
|
|
f"{context.cowcov_error}"
|
|
)
|
|
assert msg in str(context.cowcov_error), (
|
|
f"Expected '{msg}' in error message, got: {context.cowcov_error}"
|
|
)
|
|
|
|
|
|
@then("the cowcov commit should succeed")
|
|
def step_cowcov_commit_success(context: Context) -> None:
|
|
"""Assert commit completed without error."""
|
|
assert context.cowcov_error is None, (
|
|
f"Expected no error but got: {context.cowcov_error}"
|
|
)
|
|
assert context.cowcov_commit_result is not None, "Expected a commit result"
|
|
assert context.cowcov_commit_result.success is True, (
|
|
"Expected commit result success=True"
|
|
)
|
|
|
|
|
|
@then("cowcov os.makedirs should not have been called")
|
|
def step_cowcov_makedirs_not_called(context: Context) -> None:
|
|
"""Assert os.makedirs was not invoked (dst_dir was empty)."""
|
|
assert context.cowcov_makedirs_mock is not None, "makedirs mock not set up"
|
|
context.cowcov_makedirs_mock.assert_not_called()
|
|
|
|
|
|
@then("a cowcov SandboxCommitError should be raised")
|
|
def step_cowcov_check_commit_error(context: Context) -> None:
|
|
"""Assert a SandboxCommitError was captured."""
|
|
assert context.cowcov_error is not None, (
|
|
"Expected SandboxCommitError but no error occurred"
|
|
)
|
|
assert isinstance(context.cowcov_error, SandboxCommitError), (
|
|
f"Expected SandboxCommitError, got {type(context.cowcov_error).__name__}: "
|
|
f"{context.cowcov_error}"
|
|
)
|
|
|
|
|
|
@then("a cowcov SandboxRollbackError should be raised")
|
|
def step_cowcov_check_rollback_error(context: Context) -> None:
|
|
"""Assert a SandboxRollbackError was captured."""
|
|
assert context.cowcov_error is not None, (
|
|
"Expected SandboxRollbackError but no error occurred"
|
|
)
|
|
assert isinstance(context.cowcov_error, SandboxRollbackError), (
|
|
f"Expected SandboxRollbackError, got {type(context.cowcov_error).__name__}: "
|
|
f"{context.cowcov_error}"
|
|
)
|