cgcardona / muse public
test_muse_golden_path.py python
495 lines 18.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse MVP golden-path integration test.
2
3 Exercises the complete local Muse VCS workflow — init → commit → branch →
4 commit → checkout → merge (conflict) → resolve → merge --continue → log
5 -- inside Docker, using the real Postgres database.
6
7 The remote portion (steps 12–15: push, pull, Hub PR) is skipped unless
8 ``MUSE_HUB_URL`` is set in the environment.
9
10 Run:
11 docker compose exec maestro pytest tests/e2e/test_muse_golden_path.py -v -s
12 """
13
14 from __future__ import annotations
15
16 import json
17 import os
18 import pathlib
19
20 import pytest
21 import pytest_asyncio
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from maestro.muse_cli.commands.checkout import checkout_branch
25 from maestro.muse_cli.commands.commit import _commit_async
26 from maestro.muse_cli.commands.merge import _merge_async, _merge_continue_async
27 from maestro.muse_cli.commands.resolve import resolve_conflict_async
28 from maestro.muse_cli.db import open_session
29 from maestro.muse_cli.merge_engine import read_merge_state
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers
34 # ---------------------------------------------------------------------------
35
36
37 def _init_repo(root: pathlib.Path) -> None:
38 """Initialise a minimal .muse/ directory tree in *root*."""
39 import datetime
40 import uuid
41
42 muse_dir = root / ".muse"
43 (muse_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True)
44
45 repo_json = {
46 "repo_id": str(uuid.uuid4()),
47 "schema_version": "1",
48 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
49 }
50 (muse_dir / "repo.json").write_text(json.dumps(repo_json, indent=2))
51 (muse_dir / "HEAD").write_text("refs/heads/main\n")
52 (muse_dir / "refs" / "heads" / "main").write_text("")
53 (muse_dir / "config.toml").write_text("[user]\n[auth]\n[remotes]\n")
54
55
56 def _write_artifacts(root: pathlib.Path, version: str) -> None:
57 """Write the initial set of synthetic muse-work/ artifacts.
58
59 ``drums.json`` is always written with a fixed content so that only
60 ``section-1.json`` varies between branches. This ensures the merge
61 conflict in golden-path tests is scoped to ``section-1.json`` only.
62 """
63 workdir = root / "muse-work"
64 workdir.mkdir(parents=True, exist_ok=True)
65 (workdir / "meta").mkdir(exist_ok=True)
66 (workdir / "tracks").mkdir(exist_ok=True)
67
68 (workdir / "meta" / "section-1.json").write_text(
69 json.dumps(
70 {
71 "section": "intro",
72 "tempo_bpm": 120,
73 "key": "C major",
74 "version": version,
75 },
76 indent=2,
77 )
78 )
79 # drums.json is fixed — only section-1.json varies across branches so
80 # that merge conflict tests have exactly one conflicting path.
81 (workdir / "tracks" / "drums.json").write_text(
82 json.dumps({"instrument": "drums", "bars": 8}, indent=2)
83 )
84
85
86 def _write_experiment_artifacts(root: pathlib.Path) -> None:
87 """Write ONLY the experiment-branch changes (section-1.json only).
88
89 Leaves ``drums.json`` unchanged so it doesn't participate in the
90 expected merge conflict between experiment and main.
91 """
92 workdir = root / "muse-work"
93 (workdir / "meta" / "section-1.json").write_text(
94 json.dumps(
95 {
96 "section": "intro",
97 "tempo_bpm": 140,
98 "key": "G minor",
99 "version": "experiment-v1",
100 },
101 indent=2,
102 )
103 )
104
105
106 def _write_conflicting_artifacts(root: pathlib.Path, version: str) -> None:
107 """Write only section-1.json with a conflicting version on main.
108
109 Only ``section-1.json`` is written so that ``drums.json`` (unchanged
110 from the initial commit) does not participate in the conflict.
111 """
112 workdir = root / "muse-work"
113 (workdir / "meta" / "section-1.json").write_text(
114 json.dumps(
115 {
116 "section": "verse",
117 "tempo_bpm": 110,
118 "key": "E minor",
119 "version": version,
120 },
121 indent=2,
122 )
123 )
124
125
126 def _head_commit_id(root: pathlib.Path, branch: str) -> str:
127 """Return the current HEAD commit ID on *branch*, or empty string."""
128 ref_path = root / ".muse" / "refs" / "heads" / branch
129 if not ref_path.exists():
130 return ""
131 return ref_path.read_text().strip()
132
133
134 # ---------------------------------------------------------------------------
135 # Tests
136 # ---------------------------------------------------------------------------
137
138
139 @pytest.mark.anyio
140 async def test_golden_path_local(tmp_path: pathlib.Path) -> None:
141 """Full local golden path: init → commit → branch → merge conflict → resolve.
142
143 Exercises the complete local Muse MVP workflow end-to-end using an
144 in-memory-compatible tmp_path and the real Postgres session.
145 """
146 root = tmp_path / "repo"
147 root.mkdir()
148
149 # Step 1: init
150 _init_repo(root)
151 assert (root / ".muse" / "repo.json").exists()
152 assert (root / ".muse" / "HEAD").exists()
153
154 async with open_session() as session:
155 # Step 2 + 3: generate artifacts and commit on main
156 _write_artifacts(root, version="main-v1")
157 commit_id_1 = await _commit_async(
158 message="feat: initial generation",
159 root=root,
160 session=session,
161 )
162 assert commit_id_1, "First commit must return a non-empty commit ID"
163 assert _head_commit_id(root, "main") == commit_id_1
164
165 # Step 4: checkout -b experiment
166 checkout_branch(root=root, branch="experiment", create=True)
167 head_ref = (root / ".muse" / "HEAD").read_text().strip()
168 assert head_ref == "refs/heads/experiment", f"HEAD should be experiment, got {head_ref!r}"
169 # experiment starts at same commit as main
170 assert _head_commit_id(root, "experiment") == commit_id_1
171
172 async with open_session() as session:
173 # Step 5 + 6: different artifacts on experiment, commit
174 # Only change section-1.json so drums.json doesn't participate in conflict
175 _write_experiment_artifacts(root)
176 commit_experiment = await _commit_async(
177 message="feat: experimental variation",
178 root=root,
179 session=session,
180 )
181 assert commit_experiment != commit_id_1, "Experiment commit must differ from main"
182 assert _head_commit_id(root, "experiment") == commit_experiment
183
184 # Step 7: checkout main
185 checkout_branch(root=root, branch="main", create=False)
186 head_ref = (root / ".muse" / "HEAD").read_text().strip()
187 assert head_ref == "refs/heads/main"
188
189 async with open_session() as session:
190 # Make a diverging commit on main (modifies section-1.json → conflict)
191 _write_conflicting_artifacts(root, version="main-v2")
192 commit_id_2 = await _commit_async(
193 message="feat: verse section on main",
194 root=root,
195 session=session,
196 )
197 assert commit_id_2 != commit_id_1
198
199 # Step 8: merge experiment → conflict expected (both modified section-1.json)
200 import typer
201
202 with pytest.raises(typer.Exit) as exc_info:
203 await _merge_async(branch="experiment", root=root, session=session)
204
205 # Exit code must be non-zero (USER_ERROR = 1 for conflict)
206 assert exc_info.value.exit_code != 0, "Merge should exit with error on conflict"
207
208 # MERGE_STATE.json must exist and list section-1.json
209 merge_state = read_merge_state(root)
210 assert merge_state is not None, "MERGE_STATE.json must be written on conflict"
211 conflict_rel_paths = [p for p in merge_state.conflict_paths]
212 assert any("section-1.json" in p for p in conflict_rel_paths), (
213 f"section-1.json must be in conflicts, got {conflict_rel_paths}"
214 )
215
216 # Step 9: resolve --ours
217 async with open_session() as session:
218 await resolve_conflict_async(
219 file_path="meta/section-1.json",
220 ours=True,
221 root=root,
222 session=session,
223 )
224 # After resolving the only conflict, MERGE_STATE.json must STILL exist with
225 # conflict_paths=[] so that --continue can read the stored commit IDs.
226 merge_state_after = read_merge_state(root)
227 assert merge_state_after is not None, (
228 "MERGE_STATE.json should persist after resolve (--continue needs commit IDs)"
229 )
230 assert merge_state_after.conflict_paths == [], (
231 f"Expected empty conflict_paths after resolve, got {merge_state_after.conflict_paths}"
232 )
233
234 async with open_session() as session:
235 # Step 10: muse merge --continue
236 await _merge_continue_async(root=root, session=session)
237
238 merge_commit_id = _head_commit_id(root, "main")
239 assert merge_commit_id, "main HEAD must have a commit after merge --continue"
240 assert merge_commit_id != commit_id_2, "main HEAD must advance to the merge commit"
241
242 # MERGE_STATE.json must be gone
243 assert not (root / ".muse" / "MERGE_STATE.json").exists()
244
245
246 @pytest.mark.anyio
247 async def test_golden_path_log_shows_merge_commit(tmp_path: pathlib.Path) -> None:
248 """muse log --graph output contains a merge commit with two parents.
249
250 Verifies that the DAG produced by the golden-path workflow includes a
251 merge commit node that references two distinct parent commit IDs.
252 """
253 from maestro.muse_cli.db import open_session
254 from maestro.muse_cli.commands.log import _load_commits
255
256 root = tmp_path / "repo"
257 root.mkdir()
258 _init_repo(root)
259
260 async with open_session() as session:
261 # main: initial commit
262 _write_artifacts(root, version="main-v1")
263 c1 = await _commit_async(message="feat: initial", root=root, session=session)
264
265 # branch experiment (only section-1.json changes)
266 checkout_branch(root=root, branch="experiment", create=True)
267 _write_experiment_artifacts(root)
268 c_exp = await _commit_async(message="feat: experiment", root=root, session=session)
269
270 # back to main, diverge (only section-1.json changes → single conflict)
271 checkout_branch(root=root, branch="main", create=False)
272 _write_conflicting_artifacts(root, version="main-v2")
273 c2 = await _commit_async(message="feat: main diverge", root=root, session=session)
274
275 # merge (will conflict)
276 import typer
277
278 with pytest.raises(typer.Exit):
279 await _merge_async(branch="experiment", root=root, session=session)
280
281 # resolve
282 await resolve_conflict_async(file_path="meta/section-1.json", ours=True, root=root, session=session)
283
284 # merge --continue → merge commit
285 await _merge_continue_async(root=root, session=session)
286
287 merge_commit_id = _head_commit_id(root, "main")
288 assert merge_commit_id
289
290 # Load the merge commit and verify it has two parents
291 from maestro.muse_cli.models import MuseCliCommit
292
293 merge_commit = await session.get(MuseCliCommit, merge_commit_id)
294 assert merge_commit is not None
295 assert merge_commit.parent_commit_id is not None, "Merge commit must have parent1"
296 assert merge_commit.parent2_commit_id is not None, "Merge commit must have parent2"
297 assert merge_commit.parent_commit_id != merge_commit.parent2_commit_id, (
298 "The two parents of a merge commit must be distinct"
299 )
300
301 # Walk the log and confirm ≥4 nodes (c1, c2, c_exp, merge)
302 commits = await _load_commits(session, head_commit_id=merge_commit_id, limit=100)
303 assert len(commits) >= 3, (
304 f"Expected ≥3 commits in the log, got {len(commits)}"
305 )
306
307
308 @pytest.mark.anyio
309 @pytest.mark.skipif(
310 not os.environ.get("MUSE_HUB_URL"),
311 reason="MUSE_HUB_URL not set — remote golden-path test skipped",
312 )
313 async def test_golden_path_remote(tmp_path: pathlib.Path) -> None:
314 """Push/pull round-trip: after pull, Rene's log matches Gabriel's.
315
316 Skipped unless ``MUSE_HUB_URL`` is set in the environment. This test
317 is intended to run in environments where the Muse Hub is reachable.
318 """
319 hub_url = os.environ["MUSE_HUB_URL"]
320
321 root = tmp_path / "gabriel"
322 root.mkdir()
323 _init_repo(root)
324
325 async with open_session() as session:
326 _write_artifacts(root, version="main-v1")
327 await _commit_async(message="feat: initial", root=root, session=session)
328
329 # remote add + push (stubs — log the call but verify the CLI doesn't crash)
330 import subprocess
331
332 result_remote = subprocess.run(
333 ["muse", "remote", "add", "origin", hub_url],
334 cwd=root,
335 capture_output=True,
336 text=True,
337 )
338 assert result_remote.returncode == 0, (
339 f"muse remote add failed: {result_remote.stderr}"
340 )
341
342 result_push = subprocess.run(
343 ["muse", "push"],
344 cwd=root,
345 capture_output=True,
346 text=True,
347 )
348 assert result_push.returncode == 0, f"muse push failed: {result_push.stderr}"
349
350 # Rene: fresh repo → pull
351 rene_root = tmp_path / "rene"
352 rene_root.mkdir()
353 _init_repo(rene_root)
354
355 result_pull = subprocess.run(
356 ["muse", "remote", "add", "origin", hub_url],
357 cwd=rene_root,
358 capture_output=True,
359 text=True,
360 )
361 assert result_pull.returncode == 0
362
363 result_pull = subprocess.run(
364 ["muse", "pull", "--branch", "main"],
365 cwd=rene_root,
366 capture_output=True,
367 text=True,
368 )
369 assert result_pull.returncode == 0, f"muse pull failed: {result_pull.stderr}"
370
371 # Verify Rene has at least one commit on main
372 rene_head = _head_commit_id(rene_root, "main")
373 assert rene_head, "Rene should have a HEAD commit after pull"
374
375
376 @pytest.mark.anyio
377 async def test_checkout_branch_creates_and_switches(tmp_path: pathlib.Path) -> None:
378 """muse checkout -b creates a new branch seeded from current HEAD."""
379 root = tmp_path / "repo"
380 root.mkdir()
381 _init_repo(root)
382
383 async with open_session() as session:
384 _write_artifacts(root, version="v1")
385 commit_id = await _commit_async(message="initial", root=root, session=session)
386
387 checkout_branch(root=root, branch="feature", create=True)
388
389 # feature branch should point to same commit as main
390 assert _head_commit_id(root, "feature") == commit_id
391 assert (root / ".muse" / "HEAD").read_text().strip() == "refs/heads/feature"
392
393 # Switch back
394 checkout_branch(root=root, branch="main", create=False)
395 assert (root / ".muse" / "HEAD").read_text().strip() == "refs/heads/main"
396
397
398 @pytest.mark.anyio
399 async def test_resolve_clears_conflict_paths(tmp_path: pathlib.Path) -> None:
400 """muse resolve --ours removes path from MERGE_STATE and clears when empty."""
401 import datetime
402 import uuid
403
404 from maestro.muse_cli.merge_engine import write_merge_state
405
406 root = tmp_path / "repo"
407 root.mkdir()
408 _init_repo(root)
409
410 write_merge_state(
411 root,
412 base_commit="base000",
413 ours_commit="ours111",
414 theirs_commit="theirs222",
415 conflict_paths=["meta/section-1.json", "tracks/piano.mid"],
416 other_branch="feature",
417 )
418 assert (root / ".muse" / "MERGE_STATE.json").exists()
419
420 # Resolve first conflict
421 async with open_session() as session:
422 await resolve_conflict_async(file_path="meta/section-1.json", ours=True, root=root, session=session)
423 state = read_merge_state(root)
424 assert state is not None
425 assert "meta/section-1.json" not in state.conflict_paths
426 assert "tracks/piano.mid" in state.conflict_paths
427
428 # Resolve second — MERGE_STATE.json persists with empty conflict_paths so
429 # that `muse merge --continue` can still read ours_commit / theirs_commit.
430 async with open_session() as session:
431 await resolve_conflict_async(file_path="tracks/piano.mid", ours=True, root=root, session=session)
432 state = read_merge_state(root)
433 assert state is not None, "MERGE_STATE.json should persist after resolve (--continue needs it)"
434 assert state.conflict_paths == [], "All conflicts resolved — conflict_paths must be empty"
435
436
437 @pytest.mark.anyio
438 async def test_merge_continue_creates_merge_commit(tmp_path: pathlib.Path) -> None:
439 """muse merge --continue creates a merge commit with two parents."""
440 from maestro.muse_cli.merge_engine import write_merge_state
441 from maestro.muse_cli.models import MuseCliCommit
442
443 root = tmp_path / "repo"
444 root.mkdir()
445 _init_repo(root)
446
447 async with open_session() as session:
448 # Commit on main
449 _write_artifacts(root, version="v1")
450 c1 = await _commit_async(message="main initial", root=root, session=session)
451
452 # Branch and commit on experiment (only section-1.json)
453 checkout_branch(root=root, branch="experiment", create=True)
454 _write_experiment_artifacts(root)
455 c_exp = await _commit_async(message="experiment v1", root=root, session=session)
456
457 # Switch back to main, diverge
458 checkout_branch(root=root, branch="main", create=False)
459 _write_conflicting_artifacts(root, version="main-v2")
460 c2 = await _commit_async(message="main v2", root=root, session=session)
461
462 # Manually write MERGE_STATE with no conflicts (as if resolve already ran)
463 write_merge_state(
464 root,
465 base_commit=c1,
466 ours_commit=c2,
467 theirs_commit=c_exp,
468 conflict_paths=[],
469 other_branch="experiment",
470 )
471 # Clear the empty conflict list to simulate fully-resolved state.
472 # (The actual resolve command does this, but we do it manually here.)
473 (root / ".muse" / "MERGE_STATE.json").write_text(
474 json.dumps(
475 {
476 "base_commit": c1,
477 "ours_commit": c2,
478 "theirs_commit": c_exp,
479 "conflict_paths": [],
480 "other_branch": "experiment",
481 },
482 indent=2,
483 )
484 )
485
486 await _merge_continue_async(root=root, session=session)
487
488 merge_id = _head_commit_id(root, "main")
489 assert merge_id and merge_id != c2
490
491 merge_commit = await session.get(MuseCliCommit, merge_id)
492 assert merge_commit is not None
493 assert merge_commit.parent_commit_id is not None
494 assert merge_commit.parent2_commit_id is not None
495 assert not (root / ".muse" / "MERGE_STATE.json").exists()