test_sandbox.py
python
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