gabriel / muse public

test_sandbox.py file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:8 docs: use muse switch instead of checkout for branch navigation/creatio… · gabriel · Sep 16, 2026
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