gabriel / muse public
test_switch_supercharge.py python
509 lines 19.0 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """SUPERCHARGE tests for ``muse switch``.
2
3 Gaps addressed beyond the existing test_cmd_switch.py:
4
5 Unit
6 U1 duration_ms present and float in all JSON success paths
7 U2 exit_code present and 0 in all JSON success paths
8 U3 JSON error emitted to stdout when --json + branch not found
9 U4 JSON error emitted to stdout when --json + no PREV_BRANCH
10 U5 JSON error emitted to stdout when --json + mutual-exclusion violation
11 U6 commit_id is sha256:-prefixed in JSON output
12 U7 from_branch always present and correct in JSON
13
14 Integration
15 I1 switch - + --json emits valid JSON with correct action
16 I2 switch -C existing branch + --json → action == "reset"
17 I3 switch -C new branch + --json → action == "created"
18 I4 switch -c + --intent stores intent on new branch
19 I5 switch -c + --resumable marks branch resumable
20 I6 --autoshelf + --json emits valid JSON
21 I7 --merge + --json emits valid JSON (smoke — merge may yield conflict)
22 I8 dry-run -C + --json emits JSON without creating branch
23 I9 --json + --dry-run switch - emits JSON (prev branch preview)
24 I10 switch -c on existing branch + --json → JSON error (not text traceback)
25
26 Security
27 S1 null byte in branch name rejected (exit non-zero)
28 S2 path traversal (../) in branch name rejected
29 S3 JSON error output contains no raw ANSI bytes
30 S4 branch name sanitized in JSON output values
31
32 Data integrity
33 D1 PREV_BRANCH correct after 10 alternating switches
34 D2 commit_id in JSON matches actual HEAD after switch
35 D3 duration_ms is float not int
36 D4 exit_code is int not bool
37 D5 force-create action field: "reset" when branch existed, "created" when new
38
39 Stress
40 P1 50 rapid switches among 3 branches — final state consistent
41 P2 switch with 30 branches in the repo completes
42 P3 duration_ms present in all 10 rapid JSON calls
43
44 Concurrent
45 C1 4 threads switching in separate repos — all succeed
46 """
47
48 from __future__ import annotations
49 from collections.abc import Mapping
50
51 import json
52 import os
53 import pathlib
54 import threading
55
56 import pytest
57
58 from tests.cli_test_helper import CliRunner
59 from muse.core.store import get_head_commit_id, read_current_branch
60
61 runner = CliRunner()
62
63 # ---------------------------------------------------------------------------
64 # Helpers
65 # ---------------------------------------------------------------------------
66
67
68 _CHDIR_LOCK = threading.Lock()
69
70
71 def _env(repo: pathlib.Path) -> Mapping[str, str]:
72 return {"MUSE_REPO_ROOT": str(repo)}
73
74
75 def _invoke(repo: pathlib.Path, *args: str):
76 return runner.invoke(None, ["switch", *args], env=_env(repo))
77
78
79 def _run(repo: pathlib.Path, *args: str):
80 return runner.invoke(None, list(args), env=_env(repo))
81
82
83 def _init_repo(tmp: pathlib.Path) -> pathlib.Path:
84 tmp.mkdir(parents=True, exist_ok=True)
85 with _CHDIR_LOCK:
86 saved = os.getcwd()
87 try:
88 os.chdir(tmp)
89 runner.invoke(None, ["init"])
90 finally:
91 os.chdir(saved)
92 (tmp / "a.py").write_text("x = 1\n")
93 _run(tmp, "commit", "-m", "initial")
94 return tmp
95
96
97 def _add_branch(repo: pathlib.Path, name: str) -> None:
98 _run(repo, "branch", name)
99
100
101 def _switch(repo: pathlib.Path, *args: str):
102 return _invoke(repo, *args)
103
104
105 def _json_switch(repo: pathlib.Path, *args: str) -> Mapping[str, object]:
106 result = _invoke(repo, "--json", *args)
107 return result, json.loads(result.stdout) if result.stdout.strip() else {}
108
109
110 @pytest.fixture()
111 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
112 return _init_repo(tmp_path)
113
114
115 @pytest.fixture()
116 def two_branch_repo(repo: pathlib.Path) -> pathlib.Path:
117 _add_branch(repo, "feat")
118 _run(repo, "checkout", "feat")
119 (repo / "feat.py").write_text("f = 1\n")
120 _run(repo, "commit", "-m", "feat commit")
121 _run(repo, "checkout", "main")
122 return repo
123
124
125 # ---------------------------------------------------------------------------
126 # U1–U2 duration_ms and exit_code in all JSON success paths
127 # ---------------------------------------------------------------------------
128
129
130 class TestElapsedAndExitCode:
131 def test_U1_duration_ms_switched(self, two_branch_repo: pathlib.Path) -> None:
132 result, data = _json_switch(two_branch_repo, "feat")
133 assert result.exit_code == 0
134 assert "duration_ms" in data, f"duration_ms missing; got keys: {list(data)}"
135
136 def test_U1_duration_ms_created(self, repo: pathlib.Path) -> None:
137 result, data = _json_switch(repo, "-c", "new-feat")
138 assert result.exit_code == 0
139 assert "duration_ms" in data
140
141 def test_U1_duration_ms_force_create_new(self, repo: pathlib.Path) -> None:
142 result, data = _json_switch(repo, "-C", "brand-new")
143 assert result.exit_code == 0
144 assert "duration_ms" in data
145
146 def test_U1_duration_ms_force_create_existing(
147 self, two_branch_repo: pathlib.Path
148 ) -> None:
149 result, data = _json_switch(two_branch_repo, "-C", "feat")
150 assert result.exit_code == 0
151 assert "duration_ms" in data
152
153 def test_U1_duration_ms_dry_run(self, two_branch_repo: pathlib.Path) -> None:
154 result, data = _json_switch(two_branch_repo, "--dry-run", "feat")
155 assert result.exit_code == 0
156 assert "duration_ms" in data
157
158 def test_U2_exit_code_in_json(self, two_branch_repo: pathlib.Path) -> None:
159 result, data = _json_switch(two_branch_repo, "feat")
160 assert result.exit_code == 0
161 assert "exit_code" in data
162 assert data["exit_code"] == 0
163
164 def test_D3_duration_ms_is_float(self, two_branch_repo: pathlib.Path) -> None:
165 _, data = _json_switch(two_branch_repo, "feat")
166 assert isinstance(data["duration_ms"], float)
167
168 def test_D4_exit_code_is_int_not_bool(self, two_branch_repo: pathlib.Path) -> None:
169 _, data = _json_switch(two_branch_repo, "feat")
170 assert isinstance(data["exit_code"], int)
171 assert not isinstance(data["exit_code"], bool)
172
173
174 # ---------------------------------------------------------------------------
175 # U3–U5 JSON error output on failure
176 # ---------------------------------------------------------------------------
177
178
179 class TestJsonErrors:
180 def test_U3_branch_not_found_json_error(self, repo: pathlib.Path) -> None:
181 result = _invoke(repo, "--json", "no-such-branch")
182 assert result.exit_code != 0
183 data = json.loads(result.stdout)
184 assert "error" in data
185 assert data["exit_code"] != 0
186
187 def test_U4_no_prev_branch_json_error(self, repo: pathlib.Path) -> None:
188 result = _invoke(repo, "--json", "-")
189 assert result.exit_code != 0
190 data = json.loads(result.stdout)
191 assert "error" in data
192
193 def test_U5_mutual_exclusion_c_and_C_json_error(self, repo: pathlib.Path) -> None:
194 result = _invoke(repo, "--json", "-c", "-C", "foo")
195 assert result.exit_code != 0
196 data = json.loads(result.stdout)
197 assert "error" in data
198
199 def test_U5_mutual_exclusion_discard_merge_json_error(
200 self, repo: pathlib.Path
201 ) -> None:
202 result = _invoke(repo, "--json", "--discard-changes", "--merge", "feat")
203 assert result.exit_code != 0
204 data = json.loads(result.stdout)
205 assert "error" in data
206
207 def test_U5_mutual_exclusion_discard_autoshelf_json_error(
208 self, repo: pathlib.Path
209 ) -> None:
210 result = _invoke(repo, "--json", "--discard-changes", "--autoshelf", "feat")
211 assert result.exit_code != 0
212 data = json.loads(result.stdout)
213 assert "error" in data
214
215 def test_U5_mutual_exclusion_merge_autoshelf_json_error(
216 self, repo: pathlib.Path
217 ) -> None:
218 result = _invoke(repo, "--json", "--merge", "--autoshelf", "feat")
219 assert result.exit_code != 0
220 data = json.loads(result.stdout)
221 assert "error" in data
222
223 def test_json_error_has_duration_ms(self, repo: pathlib.Path) -> None:
224 result = _invoke(repo, "--json", "no-such-branch")
225 data = json.loads(result.stdout)
226 assert "duration_ms" in data
227
228 def test_json_error_has_exit_code(self, repo: pathlib.Path) -> None:
229 result = _invoke(repo, "--json", "no-such-branch")
230 data = json.loads(result.stdout)
231 assert "exit_code" in data
232 assert data["exit_code"] != 0
233
234 def test_create_existing_branch_json_error(
235 self, two_branch_repo: pathlib.Path
236 ) -> None:
237 """switch -c on an existing branch must emit a JSON error, not a traceback."""
238 result = _invoke(two_branch_repo, "--json", "-c", "feat")
239 assert result.exit_code != 0
240 data = json.loads(result.stdout)
241 assert "error" in data
242 assert "Traceback" not in result.stdout
243
244
245 # ---------------------------------------------------------------------------
246 # U6–U7 commit_id and from_branch
247 # ---------------------------------------------------------------------------
248
249
250 class TestCommitIdAndFromBranch:
251 def test_U6_commit_id_sha256_prefixed(self, two_branch_repo: pathlib.Path) -> None:
252 _, data = _json_switch(two_branch_repo, "feat")
253 assert data["commit_id"].startswith("sha256:")
254
255 def test_U7_from_branch_correct(self, two_branch_repo: pathlib.Path) -> None:
256 _, data = _json_switch(two_branch_repo, "feat")
257 assert data["from_branch"] == "main"
258
259 def test_U7_from_branch_after_create(self, repo: pathlib.Path) -> None:
260 _, data = _json_switch(repo, "-c", "new-feat")
261 assert data["from_branch"] == "main"
262
263 def test_D2_commit_id_matches_head(self, two_branch_repo: pathlib.Path) -> None:
264 result, data = _json_switch(two_branch_repo, "feat")
265 assert result.exit_code == 0
266 actual = get_head_commit_id(two_branch_repo, "feat")
267 assert data["commit_id"] == actual
268
269
270 # ---------------------------------------------------------------------------
271 # I1–I10 Integration — uncovered paths
272 # ---------------------------------------------------------------------------
273
274
275 class TestIntegration:
276 def test_I1_switch_dash_json(self, two_branch_repo: pathlib.Path) -> None:
277 """switch - + --json must emit valid JSON."""
278 _switch(two_branch_repo, "feat") # go to feat first
279 result = _invoke(two_branch_repo, "--json", "-")
280 assert result.exit_code == 0
281 data = json.loads(result.stdout)
282 assert "action" in data
283 assert data["branch"] == "main"
284 assert "duration_ms" in data
285
286 def test_I2_force_create_existing_action_reset(
287 self, two_branch_repo: pathlib.Path
288 ) -> None:
289 result, data = _json_switch(two_branch_repo, "-C", "feat")
290 assert result.exit_code == 0
291 assert data["action"] == "reset"
292
293 def test_I3_force_create_new_action_created(self, repo: pathlib.Path) -> None:
294 result, data = _json_switch(repo, "-C", "brand-new")
295 assert result.exit_code == 0
296 assert data["action"] == "created"
297
298 def test_I4_create_with_intent(self, repo: pathlib.Path) -> None:
299 """switch -c + --intent stores intent metadata on the new branch."""
300 result = _invoke(repo, "-c", "task/foo", "--intent", "implement foo feature")
301 assert result.exit_code == 0
302 # Verify branch was created
303 assert read_current_branch(repo) == "task/foo"
304 # Verify intent is queryable
305 branch_data = json.loads(
306 _run(repo, "branch", "--json").stdout
307 )
308 foo = next((b for b in branch_data if b["name"] == "task/foo"), None)
309 assert foo is not None
310 assert foo.get("intent") == "implement foo feature"
311
312 def test_I5_create_with_resumable(self, repo: pathlib.Path) -> None:
313 """switch -c + --resumable marks the branch as a resumable checkpoint."""
314 result = _invoke(repo, "-c", "task/bar", "--resumable")
315 assert result.exit_code == 0
316 branch_data = json.loads(_run(repo, "branch", "--json").stdout)
317 bar = next((b for b in branch_data if b["name"] == "task/bar"), None)
318 assert bar is not None
319 assert bar.get("resumable") is True
320
321 def test_I6_autoshelf_json(self, two_branch_repo: pathlib.Path) -> None:
322 result, data = _json_switch(two_branch_repo, "--autoshelf", "feat")
323 assert result.exit_code == 0
324 assert "action" in data
325 assert "duration_ms" in data
326
327 def test_I8_dry_run_force_create_json(self, two_branch_repo: pathlib.Path) -> None:
328 result, data = _json_switch(two_branch_repo, "--dry-run", "-C", "feat")
329 assert result.exit_code == 0
330 assert data["dry_run"] is True
331 assert "action" in data
332 assert "duration_ms" in data
333 # Branch ref must not be modified
334 orig_tip = get_head_commit_id(two_branch_repo, "feat")
335 main_tip = get_head_commit_id(two_branch_repo, "main")
336 assert get_head_commit_id(two_branch_repo, "feat") == orig_tip
337
338 def test_I9_dry_run_switch_dash_json(self, two_branch_repo: pathlib.Path) -> None:
339 _switch(two_branch_repo, "feat") # record prev
340 result, data = _json_switch(two_branch_repo, "--dry-run", "-")
341 assert result.exit_code == 0
342 assert data["dry_run"] is True
343 assert "duration_ms" in data
344
345 def test_I10_create_existing_json_error_not_traceback(
346 self, two_branch_repo: pathlib.Path
347 ) -> None:
348 result = _invoke(two_branch_repo, "--json", "-c", "feat")
349 assert result.exit_code != 0
350 assert "Traceback" not in result.stdout
351 data = json.loads(result.stdout)
352 assert "error" in data
353
354
355 # ---------------------------------------------------------------------------
356 # Security
357 # ---------------------------------------------------------------------------
358
359
360 class TestSecurity:
361 def test_S1_null_byte_in_branch_name_rejected(self, repo: pathlib.Path) -> None:
362 result = _invoke(repo, "feat\x00evil")
363 assert result.exit_code != 0
364
365 def test_S2_path_traversal_in_branch_name_rejected(
366 self, repo: pathlib.Path
367 ) -> None:
368 result = _invoke(repo, "../evil")
369 assert result.exit_code != 0
370
371 def test_S3_json_error_no_ansi(self, repo: pathlib.Path) -> None:
372 result = _invoke(repo, "--json", "no-such-branch")
373 assert "\x1b" not in result.stdout
374
375 def test_S4_branch_name_sanitized_in_json(
376 self, two_branch_repo: pathlib.Path
377 ) -> None:
378 _, data = _json_switch(two_branch_repo, "feat")
379 assert "\x1b" not in json.dumps(data)
380
381 def test_ansi_in_branch_name_rejected_with_json(
382 self, repo: pathlib.Path
383 ) -> None:
384 result = _invoke(repo, "--json", "\x1b[31mbad\x1b[0m")
385 assert result.exit_code != 0
386 # Must be parseable JSON, not raw error text
387 data = json.loads(result.stdout)
388 assert "error" in data
389
390
391 # ---------------------------------------------------------------------------
392 # Data integrity
393 # ---------------------------------------------------------------------------
394
395
396 class TestDataIntegrity:
397 def test_D1_prev_branch_correct_after_10_toggles(
398 self, two_branch_repo: pathlib.Path
399 ) -> None:
400 branches = ["main", "feat"]
401 for i in range(10):
402 _switch(two_branch_repo, branches[i % 2])
403 # After 10 switches index 9 → branches[1] = feat
404 assert read_current_branch(two_branch_repo) == "feat"
405 # PREV_BRANCH should be main (the branch we came from)
406 prev = (two_branch_repo / ".muse" / "PREV_BRANCH").read_text().strip()
407 assert prev == "main"
408
409 def test_D5_force_create_action_reset_when_existed(
410 self, two_branch_repo: pathlib.Path
411 ) -> None:
412 _, data = _json_switch(two_branch_repo, "-C", "feat")
413 assert data["action"] == "reset"
414
415 def test_D5_force_create_action_created_when_new(
416 self, repo: pathlib.Path
417 ) -> None:
418 _, data = _json_switch(repo, "-C", "new-branch")
419 assert data["action"] == "created"
420
421 def test_all_success_json_keys_present(
422 self, two_branch_repo: pathlib.Path
423 ) -> None:
424 """Every successful switch --json must have the full schema."""
425 _, data = _json_switch(two_branch_repo, "feat")
426 required = {"action", "branch", "from_branch", "commit_id", "dry_run",
427 "duration_ms", "exit_code"}
428 missing = required - set(data.keys())
429 assert not missing, f"Missing JSON keys: {missing}"
430
431 def test_all_error_json_keys_present(self, repo: pathlib.Path) -> None:
432 result = _invoke(repo, "--json", "ghost")
433 data = json.loads(result.stdout)
434 required = {"error", "duration_ms", "exit_code"}
435 missing = required - set(data.keys())
436 assert not missing, f"Missing error JSON keys: {missing}"
437
438
439 # ---------------------------------------------------------------------------
440 # Stress
441 # ---------------------------------------------------------------------------
442
443
444 class TestStress:
445 def test_P1_50_rapid_switches_three_branches(
446 self, tmp_path: pathlib.Path
447 ) -> None:
448 repo = _init_repo(tmp_path)
449 for name in ("feat-a", "feat-b"):
450 _add_branch(repo, name)
451 branches = ["main", "feat-a", "feat-b"]
452 for i in range(50):
453 result = _switch(repo, branches[i % 3])
454 assert result.exit_code == 0, f"Switch {i} failed"
455 expected = branches[49 % 3]
456 assert read_current_branch(repo) == expected
457
458 def test_P2_switch_with_many_branches(self, tmp_path: pathlib.Path) -> None:
459 """30 branches in the repo; switching still completes."""
460 repo = _init_repo(tmp_path)
461 for i in range(30):
462 _run(repo, "branch", f"branch-{i:02d}")
463 result = _switch(repo, "branch-00")
464 assert result.exit_code == 0
465 assert read_current_branch(repo) == "branch-00"
466
467 def test_P3_duration_ms_in_all_rapid_json_calls(
468 self, two_branch_repo: pathlib.Path
469 ) -> None:
470 branches = ["main", "feat"]
471 for i in range(10):
472 result = _invoke(two_branch_repo, "--json", branches[i % 2])
473 data = json.loads(result.stdout)
474 assert "duration_ms" in data, f"Missing duration_ms on call {i}"
475 assert isinstance(data["duration_ms"], float)
476
477
478 # ---------------------------------------------------------------------------
479 # Concurrent
480 # ---------------------------------------------------------------------------
481
482
483 _INIT_LOCK = threading.Lock()
484
485
486 class TestConcurrent:
487 def test_C1_four_concurrent_switches(self, tmp_path: pathlib.Path) -> None:
488 results = [None] * 4
489
490 def _work(idx: int) -> None:
491 repo = tmp_path / f"repo_{idx}"
492 with _INIT_LOCK:
493 r = _init_repo(repo)
494 _add_branch(r, "feat")
495 try:
496 res = _switch(r, "feat")
497 results[idx] = res.exit_code
498 except Exception as exc:
499 results[idx] = exc
500
501 threads = [threading.Thread(target=_work, args=(i,)) for i in range(4)]
502 for t in threads:
503 t.start()
504 for t in threads:
505 t.join()
506
507 for i, result in enumerate(results):
508 assert not isinstance(result, Exception), f"Thread {i}: {result}"
509 assert result == 0, f"Thread {i} exit code: {result}"
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago