gabriel / muse public
test_sandbox.py python
142 lines 6.5 KB
Raw
sha256:f20267508f836150a1df05f57eaba390a49dae7412c1579a2c26beb3dfe94b50 feat(dev-safety): Phase 4 of #185 — disposable sandbox tool… Sonnet 5 patch 5 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)
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)
51 second = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base)
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)
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)
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)
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 TestResolveCurrentSandbox:
105 def test_raises_clear_error_when_never_refreshed(self, tmp_path: Path) -> None:
106 with pytest.raises(SandboxNotFoundError, match="sandbox-refresh"):
107 resolve_current_sandbox("muse", sandbox_base=tmp_path / "sandboxes")
108
109
110 class TestSandboxRunScript:
111 def _muse_dev_path(self) -> str | None:
112 proc = subprocess.run(["zsh", "-lc", "command -v muse-dev"], capture_output=True, text=True)
113 return proc.stdout.strip() or None
114
115 def test_sandbox_run_invokes_muse_dev_against_current_sandbox(self, tmp_path: Path) -> None:
116 if self._muse_dev_path() is None:
117 pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)")
118
119 canonical = tmp_path / "ecosystem" / "muse"
120 canonical.mkdir(parents=True)
121 # tests/conftest.py's autouse _isolate_muse_home fixture patches
122 # Path.home() to a fake tmp dir for every test (so nothing here can
123 # touch the real ~/.muse/) — the real HOME env var is untouched.
124 real_muse_repo = Path(os.environ["HOME"]) / "ecosystem" / "muse"
125 # A real (not fake) .muse/ is needed here since we're about to run a
126 # genuine `muse-dev status` against it — clone the real canonical
127 # repo's .muse/ read-only via the same cp -c mechanism refresh_sandbox
128 # itself uses, rather than hand-rolling a fake object store.
129 subprocess.run(["cp", "-c", "-R", str(real_muse_repo / ".muse"), str(canonical / ".muse")],
130 check=True, capture_output=True)
131 (canonical / "working-file.txt").write_text("hello\n")
132 sandbox_base = tmp_path / "sandboxes"
133 refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base)
134
135 script = str(Path(__file__).resolve().parents[1] / "scripts" / "dev" / "sandbox-run.sh")
136 proc = subprocess.run(
137 [script, "muse", "--sandbox-base", str(sandbox_base), "--", "status", "--json"],
138 capture_output=True, text=True,
139 )
140
141 assert proc.returncode == 0, proc.stderr
142 assert '"branch"' in proc.stdout
File History 1 commit
sha256:f20267508f836150a1df05f57eaba390a49dae7412c1579a2c26beb3dfe94b50 feat(dev-safety): Phase 4 of #185 — disposable sandbox tool… Sonnet 5 patch 5 days ago