test_sandbox.py
python
sha256:408631a8dc866047a2cff7440933b461af5abd4b55e755599765e433abceb88b
feat(dev-safety): Phase 6 of #185 — wire together, document…
Sonnet 5
minor
⚠ breaking
6 days ago
| 1 | """Phase 4 of #185 (musehub staging): disposable sandbox tooling. |
| 2 | |
| 3 | sandbox_refresh() clones a canonical repo into a timestamped, disposable |
| 4 | copy via APFS copy-on-write (`cp -c -R`), so real dev/testing of muse's own |
| 5 | engine code never touches the canonical object store. These tests always |
| 6 | point `canonical_root`/`sandbox_base` at tmp_path fixtures — never the real |
| 7 | ~/ecosystem/muse — same discipline as test_dev_guard.py. |
| 8 | """ |
| 9 | import os |
| 10 | import subprocess |
| 11 | import sys |
| 12 | from pathlib import Path |
| 13 | |
| 14 | import pytest |
| 15 | |
| 16 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts" / "dev")) |
| 17 | from sandbox import refresh_sandbox, resolve_current_sandbox, SandboxNotFoundError # noqa: E402 |
| 18 | |
| 19 | |
| 20 | def _make_fake_repo(root: Path, branches: dict[str, str]) -> None: |
| 21 | """A minimal fake muse repo: a .muse/refs dir with branch -> commit_id files.""" |
| 22 | refs = root / ".muse" / "refs" / "heads" |
| 23 | refs.mkdir(parents=True) |
| 24 | for branch, commit_id in branches.items(): |
| 25 | (refs / branch).write_text(commit_id) |
| 26 | (root / "working-file.txt").write_text("hello\n") |
| 27 | |
| 28 | |
| 29 | def _read_branch_heads(root: Path) -> dict[str, str]: |
| 30 | refs = root / ".muse" / "refs" / "heads" |
| 31 | return {p.name: p.read_text() for p in refs.iterdir()} |
| 32 | |
| 33 | |
| 34 | class TestRefreshSandbox: |
| 35 | def test_sandbox_branch_heads_match_canonical_exactly(self, tmp_path: Path) -> None: |
| 36 | canonical = tmp_path / "ecosystem" / "muse" |
| 37 | _make_fake_repo(canonical, {"dev": "sha256:aaa", "main": "sha256:bbb"}) |
| 38 | sandbox_base = tmp_path / "sandboxes" |
| 39 | |
| 40 | sandbox_path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") |
| 41 | |
| 42 | assert _read_branch_heads(sandbox_path) == _read_branch_heads(canonical) |
| 43 | assert (sandbox_path / "working-file.txt").read_text() == "hello\n" |
| 44 | |
| 45 | def test_current_symlink_points_at_latest_refresh(self, tmp_path: Path) -> None: |
| 46 | canonical = tmp_path / "ecosystem" / "muse" |
| 47 | _make_fake_repo(canonical, {"dev": "sha256:aaa"}) |
| 48 | sandbox_base = tmp_path / "sandboxes" |
| 49 | |
| 50 | first = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") |
| 51 | second = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") |
| 52 | |
| 53 | current = resolve_current_sandbox("muse", sandbox_base=sandbox_base) |
| 54 | assert current.resolve() == second.resolve() |
| 55 | assert first != second |
| 56 | |
| 57 | def test_rotation_keeps_only_newest_n(self, tmp_path: Path) -> None: |
| 58 | canonical = tmp_path / "ecosystem" / "muse" |
| 59 | _make_fake_repo(canonical, {"dev": "sha256:aaa"}) |
| 60 | sandbox_base = tmp_path / "sandboxes" |
| 61 | |
| 62 | paths = [ |
| 63 | refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, keep=3, backup_base=tmp_path / "backups") |
| 64 | for _ in range(6) |
| 65 | ] |
| 66 | |
| 67 | remaining = { |
| 68 | p for p in (sandbox_base / "muse-sandbox").iterdir() |
| 69 | if p.is_dir() and not p.is_symlink() |
| 70 | } |
| 71 | assert len(remaining) == 3 |
| 72 | assert remaining == set(paths[-3:]) |
| 73 | |
| 74 | def test_corruption_in_sandbox_never_touches_canonical(self, tmp_path: Path) -> None: |
| 75 | canonical = tmp_path / "ecosystem" / "muse" |
| 76 | _make_fake_repo(canonical, {"dev": "sha256:aaa"}) |
| 77 | sandbox_base = tmp_path / "sandboxes" |
| 78 | |
| 79 | sandbox_path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") |
| 80 | |
| 81 | # Corrupt the sandbox's copy. |
| 82 | (sandbox_path / ".muse" / "refs" / "heads" / "dev").write_text("CORRUPTED") |
| 83 | |
| 84 | assert _read_branch_heads(sandbox_path)["dev"] == "CORRUPTED" |
| 85 | assert _read_branch_heads(canonical)["dev"] == "sha256:aaa" |
| 86 | |
| 87 | def test_editing_canonical_after_refresh_does_not_affect_existing_sandbox(self, tmp_path: Path) -> None: |
| 88 | # Proves cp -c produced an independent copy, not a shared/linked view — |
| 89 | # APFS clonefile is copy-on-write, so writes to either side must diverge. |
| 90 | canonical = tmp_path / "ecosystem" / "muse" |
| 91 | _make_fake_repo(canonical, {"dev": "sha256:aaa"}) |
| 92 | sandbox_base = tmp_path / "sandboxes" |
| 93 | |
| 94 | sandbox_path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") |
| 95 | (canonical / ".muse" / "refs" / "heads" / "dev").write_text("sha256:changed-after-refresh") |
| 96 | |
| 97 | assert _read_branch_heads(sandbox_path)["dev"] == "sha256:aaa" |
| 98 | |
| 99 | def test_unknown_repo_name_raises_clear_error(self, tmp_path: Path) -> None: |
| 100 | with pytest.raises(ValueError, match="Unknown repo"): |
| 101 | refresh_sandbox("not-a-real-repo", sandbox_base=tmp_path / "sandboxes") |
| 102 | |
| 103 | |
| 104 | class TestSandboxRefreshTakesSnapshotFirst: |
| 105 | """Phase 6 of #185: sandbox-refresh always snapshots canonical first — |
| 106 | cheap insurance even though the sandbox itself never touches canonical.""" |
| 107 | |
| 108 | def test_refresh_creates_a_snapshot_backup_before_cloning(self, tmp_path: Path) -> None: |
| 109 | canonical = tmp_path / "ecosystem" / "muse" |
| 110 | _make_fake_repo(canonical, {"main": "sha256:aaa"}) |
| 111 | sandbox_base = tmp_path / "sandboxes" |
| 112 | backup_base = tmp_path / "backups" |
| 113 | |
| 114 | refresh_sandbox( |
| 115 | "muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=backup_base, |
| 116 | ) |
| 117 | |
| 118 | from backup import list_snapshots |
| 119 | snaps = list_snapshots("muse", backup_base=backup_base) |
| 120 | assert len(snaps) == 1 |
| 121 | assert (snaps[0] / ".muse" / "refs" / "heads" / "main").read_text() == "sha256:aaa" |
| 122 | |
| 123 | def test_refresh_still_works_when_backup_base_is_not_given(self, tmp_path: Path) -> None: |
| 124 | # backup_base defaults to the real ~/dev/backups when unset — callers |
| 125 | # that don't care about the snapshot side-effect (e.g. most existing |
| 126 | # tests in this file) must not be forced to pass it. |
| 127 | canonical = tmp_path / "ecosystem" / "muse" |
| 128 | _make_fake_repo(canonical, {"main": "sha256:aaa"}) |
| 129 | sandbox_base = tmp_path / "sandboxes" |
| 130 | |
| 131 | import backup as backup_mod |
| 132 | real_default = backup_mod.DEFAULT_BACKUP_BASE |
| 133 | backup_mod.DEFAULT_BACKUP_BASE = tmp_path / "backups-default" |
| 134 | try: |
| 135 | path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base) |
| 136 | finally: |
| 137 | backup_mod.DEFAULT_BACKUP_BASE = real_default |
| 138 | |
| 139 | assert path.exists() |
| 140 | assert (tmp_path / "backups-default" / "muse-snapshots").exists() |
| 141 | |
| 142 | |
| 143 | class TestResolveCurrentSandbox: |
| 144 | def test_raises_clear_error_when_never_refreshed(self, tmp_path: Path) -> None: |
| 145 | with pytest.raises(SandboxNotFoundError, match="sandbox-refresh"): |
| 146 | resolve_current_sandbox("muse", sandbox_base=tmp_path / "sandboxes") |
| 147 | |
| 148 | |
| 149 | class TestSandboxRunScript: |
| 150 | def _muse_dev_path(self) -> str | None: |
| 151 | proc = subprocess.run(["zsh", "-lc", "command -v muse-dev"], capture_output=True, text=True) |
| 152 | return proc.stdout.strip() or None |
| 153 | |
| 154 | def test_sandbox_run_invokes_muse_dev_against_current_sandbox(self, tmp_path: Path) -> None: |
| 155 | if self._muse_dev_path() is None: |
| 156 | pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") |
| 157 | |
| 158 | canonical = tmp_path / "ecosystem" / "muse" |
| 159 | canonical.mkdir(parents=True) |
| 160 | # tests/conftest.py's autouse _isolate_muse_home fixture patches |
| 161 | # Path.home() to a fake tmp dir for every test (so nothing here can |
| 162 | # touch the real ~/.muse/) — the real HOME env var is untouched. |
| 163 | real_muse_repo = Path(os.environ["HOME"]) / "ecosystem" / "muse" |
| 164 | # A real (not fake) .muse/ is needed here since we're about to run a |
| 165 | # genuine `muse-dev status` against it — clone the real canonical |
| 166 | # repo's .muse/ read-only via the same cp -c mechanism refresh_sandbox |
| 167 | # itself uses, rather than hand-rolling a fake object store. |
| 168 | subprocess.run(["cp", "-c", "-R", str(real_muse_repo / ".muse"), str(canonical / ".muse")], |
| 169 | check=True, capture_output=True) |
| 170 | (canonical / "working-file.txt").write_text("hello\n") |
| 171 | sandbox_base = tmp_path / "sandboxes" |
| 172 | refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") |
| 173 | |
| 174 | script = str(Path(__file__).resolve().parents[1] / "scripts" / "dev" / "sandbox-run.sh") |
| 175 | proc = subprocess.run( |
| 176 | [script, "muse", "--sandbox-base", str(sandbox_base), "--", "status", "--json"], |
| 177 | capture_output=True, text=True, |
| 178 | ) |
| 179 | |
| 180 | assert proc.returncode == 0, proc.stderr |
| 181 | assert '"branch"' in proc.stdout |
File History
1 commit
sha256:408631a8dc866047a2cff7440933b461af5abd4b55e755599765e433abceb88b
feat(dev-safety): Phase 6 of #185 — wire together, document…
Sonnet 5
minor
⚠
6 days ago