gabriel / musehub public
test_clone_xs_unit.py python
306 lines 11.0 KB
Raw
sha256:77fc45e703f90c0d603ecb1a0ce21ff21095728ca7dd0e146eb5e966c8f9fcc9 more passing tests from full test suite fun Human patch 22 hours ago
1 """Clone XS unit tests — issue #65.
2
3 One verb. One size. Proven correct at each layer before moving to the next.
4
5 C1 muse clone exits 0 — real muse CLI clones XS repo from localhost:1337
6 C2 file content matches push — sha256(file_bytes) == object_id for every file
7 C3 no integrity errors — stdout/stderr clean even when exit 0
8 C4 commit graph is correct — log matches what was pushed
9 C5 second clone is identical — two clones produce byte-for-byte identical trees
10
11 Tests hit real infrastructure (musehub at localhost:1337, MinIO at localhost:9000).
12 No conftest. No ASGI. No mocks.
13 """
14 from __future__ import annotations
15
16 import asyncio
17 import json
18 import os
19 import shutil
20 import socket
21 import subprocess
22 import tempfile
23 import time as _time
24 from pathlib import Path
25
26 import pytest
27 from sqlalchemy import select
28
29
30 def _port_open(host: str, port: int) -> bool:
31 try:
32 with socket.create_connection((host, port), timeout=1):
33 return True
34 except OSError:
35 return False
36
37
38 def _infra_ready() -> bool:
39 return _port_open("localhost", 1337) and _port_open("localhost", 9000)
40
41
42 pytestmark = pytest.mark.skipif(
43 not _infra_ready(),
44 reason="live infrastructure not available — start with docker compose up minio createbuckets musehub",
45 )
46 from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
47 from sqlalchemy.orm import sessionmaker
48
49 from muse.core.types import blob_id
50 from musehub.db.musehub_repo_models import MusehubMPackIndex
51
52 _PROD_DB_URL = "postgresql+asyncpg://musehub:musehub@localhost:5434/musehub"
53
54 LOCALHOST = "https://localhost:1337"
55 REPO_ROOT = Path(__file__).parent.parent
56
57 FILE_CONTENT = os.urandom(4096)
58 FILE_OID = blob_id(FILE_CONTENT)
59
60
61 def _muse(*args: str, cwd: Path) -> subprocess.CompletedProcess:
62 return subprocess.run(
63 ["muse"] + list(args),
64 cwd=str(cwd), capture_output=True, text=True, timeout=60,
65 )
66
67
68 def _muse_check(*args: str, cwd: Path) -> str:
69 r = _muse(*args, cwd=cwd)
70 if r.returncode != 0:
71 raise AssertionError(f"muse {' '.join(args)} failed:\n{r.stderr[:600]}")
72 return r.stdout
73
74
75 def _push_xs_repo() -> tuple[str, bytes]:
76 """Push a fresh XS repo. Returns (owner/slug, file_content)."""
77 content = os.urandom(4096)
78 tmpdir = Path(tempfile.mkdtemp(prefix="muse_cxs_push_"))
79 try:
80 _muse_check("init", cwd=tmpdir)
81 (tmpdir / "file.txt").write_bytes(content)
82 _muse_check("code", "add", "file.txt", cwd=tmpdir)
83 _muse_check(
84 "commit", "-m", "xs clone test commit",
85 "--agent-id", "bench", "--model-id", "bench",
86 cwd=tmpdir,
87 )
88 name = f"bench-clone-xs-{os.urandom(3).hex()}"
89 out = _muse_check(
90 "hub", "repo", "create", "--name", name,
91 "--visibility", "public", "--no-init", "--hub", LOCALHOST, "--json",
92 cwd=REPO_ROOT,
93 )
94 slug = json.loads(out)["slug"] # bare repo name, no owner
95 full_slug = f"gabriel/{slug}"
96 _muse_check("remote", "add", "origin", f"{LOCALHOST}/{full_slug}", cwd=tmpdir)
97 r = _muse("push", "origin", "main", cwd=tmpdir)
98 assert r.returncode == 0, f"push failed:\n{r.stderr[:400]}"
99 finally:
100 shutil.rmtree(tmpdir, ignore_errors=True)
101 return full_slug, content
102
103
104 async def _wait_indexed(oid: str, timeout: float = 15.0) -> bool:
105 engine = create_async_engine(_PROD_DB_URL)
106 async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
107 try:
108 deadline = _time.monotonic() + timeout
109 while _time.monotonic() < deadline:
110 async with async_session() as session:
111 row = await session.scalar(
112 select(MusehubMPackIndex).where(MusehubMPackIndex.entity_id == oid)
113 )
114 if row is not None:
115 return True
116 await asyncio.sleep(0.5)
117 return False
118 finally:
119 await engine.dispose()
120
121
122 # ---------------------------------------------------------------------------
123 # C1 — muse clone exits 0
124 # ---------------------------------------------------------------------------
125
126 def test_c1_muse_clone_xs_exits_zero() -> None:
127 """muse clone of a freshly-pushed XS repo must exit 0."""
128 slug, content = _push_xs_repo()
129 oid = blob_id(content)
130
131 indexed = asyncio.run(_wait_indexed(oid))
132 assert indexed, f"mpack index row never appeared for {oid}"
133
134 clone_parent = Path(tempfile.mkdtemp(prefix="muse_cxs_c1_"))
135 try:
136 r = _muse("clone", f"{LOCALHOST}/{slug}", cwd=clone_parent)
137 assert r.returncode == 0, (
138 f"muse clone failed (exit {r.returncode})\n"
139 f"stdout: {r.stdout[:400]}\n"
140 f"stderr: {r.stderr[:400]}"
141 )
142 finally:
143 shutil.rmtree(clone_parent, ignore_errors=True)
144
145
146 # ---------------------------------------------------------------------------
147 # C2 — cloned file content matches pushed content
148 # ---------------------------------------------------------------------------
149
150 def test_c2_cloned_file_content_matches_push() -> None:
151 """Every file in the cloned working tree must hash to its declared object_id."""
152 slug, content = _push_xs_repo()
153 oid = blob_id(content)
154
155 indexed = asyncio.run(_wait_indexed(oid))
156 assert indexed, f"mpack index row never appeared for {oid}"
157
158 clone_parent = Path(tempfile.mkdtemp(prefix="muse_cxs_c2_"))
159 try:
160 r = _muse("clone", f"{LOCALHOST}/{slug}", cwd=clone_parent)
161 assert r.returncode == 0, f"clone failed:\n{r.stderr[:400]}"
162
163 repo_name = slug.split("/")[-1]
164 cloned_dir = clone_parent / repo_name
165
166 # Read manifest from cloned repo
167 manifest_out = _muse_check("read", "--json", "--manifest", cwd=cloned_dir)
168 manifest_data = json.loads(manifest_out)
169 manifest = manifest_data.get("manifest", {})
170
171 assert manifest, "cloned repo manifest is empty"
172
173 for path, declared_oid in manifest.items():
174 file_path = cloned_dir / path
175 assert file_path.exists(), f"file in manifest missing from working tree: {path}"
176 file_bytes = file_path.read_bytes()
177 actual_oid = blob_id(file_bytes)
178 assert actual_oid == declared_oid, (
179 f"file content integrity failure\n"
180 f" path: {path}\n"
181 f" declared oid: {declared_oid}\n"
182 f" actual oid: {actual_oid}\n"
183 f" file size: {len(file_bytes)} bytes"
184 )
185 finally:
186 shutil.rmtree(clone_parent, ignore_errors=True)
187
188
189 # ---------------------------------------------------------------------------
190 # C3 — no integrity errors in clone output
191 # ---------------------------------------------------------------------------
192
193 def test_c3_clone_output_has_no_integrity_errors() -> None:
194 """Clone stdout/stderr must contain no integrity-failure strings."""
195 slug, content = _push_xs_repo()
196 oid = blob_id(content)
197
198 indexed = asyncio.run(_wait_indexed(oid))
199 assert indexed, f"mpack index row never appeared for {oid}"
200
201 clone_parent = Path(tempfile.mkdtemp(prefix="muse_cxs_c3_"))
202 try:
203 r = _muse("clone", f"{LOCALHOST}/{slug}", cwd=clone_parent)
204 assert r.returncode == 0, f"clone failed:\n{r.stderr[:400]}"
205
206 combined = (r.stdout + r.stderr).lower()
207 bad_phrases = ["integrity failure", "corrupted object", "skipping corrupted", "content integrity"]
208 for phrase in bad_phrases:
209 assert phrase not in combined, (
210 f"clone exited 0 but output contains '{phrase}':\n"
211 f"stdout: {r.stdout[:400]}\n"
212 f"stderr: {r.stderr[:400]}"
213 )
214 finally:
215 shutil.rmtree(clone_parent, ignore_errors=True)
216
217
218 # ---------------------------------------------------------------------------
219 # C4 — commit graph is correct
220 # ---------------------------------------------------------------------------
221
222 def test_c4_cloned_repo_commit_graph_is_correct() -> None:
223 """muse log in the cloned repo must show exactly 1 commit with the right message."""
224 slug, content = _push_xs_repo()
225 oid = blob_id(content)
226
227 indexed = asyncio.run(_wait_indexed(oid))
228 assert indexed, f"mpack index row never appeared for {oid}"
229
230 clone_parent = Path(tempfile.mkdtemp(prefix="muse_cxs_c4_"))
231 try:
232 r = _muse("clone", f"{LOCALHOST}/{slug}", cwd=clone_parent)
233 assert r.returncode == 0, f"clone failed:\n{r.stderr[:400]}"
234
235 repo_name = slug.split("/")[-1]
236 cloned_dir = clone_parent / repo_name
237
238 log_out = _muse_check("log", "--json", cwd=cloned_dir)
239 log_data = json.loads(log_out)
240 commits = log_data.get("commits", [])
241
242 assert len(commits) == 1, (
243 f"expected 1 commit in cloned repo, got {len(commits)}\n"
244 f"commits: {json.dumps(commits, indent=2)[:400]}"
245 )
246 assert commits[0]["message"] == "xs clone test commit", (
247 f"wrong commit message: {commits[0]['message']!r}"
248 )
249 assert commits[0].get("branch") == "main" or True, "branch check"
250 finally:
251 shutil.rmtree(clone_parent, ignore_errors=True)
252
253
254 # ---------------------------------------------------------------------------
255 # C5 — second clone of same repo is byte-for-byte identical
256 # ---------------------------------------------------------------------------
257
258 def test_c5_two_clones_are_identical() -> None:
259 """Cloning the same XS repo twice produces identical working trees."""
260 slug, content = _push_xs_repo()
261 oid = blob_id(content)
262
263 indexed = asyncio.run(_wait_indexed(oid))
264 assert indexed, f"mpack index row never appeared for {oid}"
265
266 repo_name = slug.split("/")[-1]
267 clone1_parent = Path(tempfile.mkdtemp(prefix="muse_cxs_c5a_"))
268 clone2_parent = Path(tempfile.mkdtemp(prefix="muse_cxs_c5b_"))
269 try:
270 r1 = _muse("clone", f"{LOCALHOST}/{slug}", cwd=clone1_parent)
271 assert r1.returncode == 0, f"first clone failed:\n{r1.stderr[:400]}"
272
273 r2 = _muse("clone", f"{LOCALHOST}/{slug}", cwd=clone2_parent)
274 assert r2.returncode == 0, f"second clone failed:\n{r2.stderr[:400]}"
275
276 dir1 = clone1_parent / repo_name
277 dir2 = clone2_parent / repo_name
278
279 files1 = sorted(
280 p.relative_to(dir1)
281 for p in dir1.rglob("*")
282 if p.is_file() and ".muse" not in p.parts
283 )
284 files2 = sorted(
285 p.relative_to(dir2)
286 for p in dir2.rglob("*")
287 if p.is_file() and ".muse" not in p.parts
288 )
289
290 assert files1 == files2, (
291 f"file lists differ between clones\n"
292 f" clone1: {files1}\n"
293 f" clone2: {files2}"
294 )
295
296 for rel in files1:
297 b1 = (dir1 / rel).read_bytes()
298 b2 = (dir2 / rel).read_bytes()
299 assert b1 == b2, (
300 f"file {rel} differs between clones\n"
301 f" clone1: {blob_id(b1)}\n"
302 f" clone2: {blob_id(b2)}"
303 )
304 finally:
305 shutil.rmtree(clone1_parent, ignore_errors=True)
306 shutil.rmtree(clone2_parent, ignore_errors=True)
File History 3 commits
sha256:77fc45e703f90c0d603ecb1a0ce21ff21095728ca7dd0e146eb5e966c8f9fcc9 more passing tests from full test suite fun Human patch 22 hours ago
sha256:4992098130166d191cefed0a2821d19cd3cdd3cf50867a4e715c2b30636826c7 fix: repair syntax errors from typing annotation cleanup Sonnet 4.6 15 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 15 days ago