gabriel / muse public
test_cmd_bisect.py python
372 lines 14.1 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
1 """Comprehensive tests for ``muse bisect`` — binary search for bad commits.
2
3 Coverage:
4 - Unit: bisect core functions (start, mark, skip, reset)
5 - Integration: CLI subcommands (start, bad, good, skip, log, reset)
6 - E2E: full bisect workflow resolving to a first-bad commit
7 - Security: invalid refs, session guard (no double-start), ref sanitization
8 - Stress: deep commit history bisect
9 """
10
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16 import uuid
17
18 import pytest
19 from tests.cli_test_helper import CliRunner
20
21 cli = None # argparse migration — CliRunner ignores this arg
22 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
23 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
24 from muse.core._types import Manifest
25
26 runner = CliRunner()
27
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33
34 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
35 repo_id = str(uuid.uuid4())
36 muse = tmp_path / ".muse"
37 muse.mkdir()
38 (muse / "repo.json").write_text(
39 json.dumps({"repo_id": repo_id, "domain": "midi",
40 "default_branch": "main",
41 "created_at": "2026-01-01T00:00:00+00:00"})
42 )
43 (muse / "HEAD").write_text("ref: refs/heads/main")
44 (muse / "refs" / "heads").mkdir(parents=True)
45 (muse / "snapshots").mkdir()
46 (muse / "commits").mkdir()
47 (muse / "objects").mkdir()
48 return tmp_path, repo_id
49
50
51 def _env(root: pathlib.Path) -> Manifest:
52 return {"MUSE_REPO_ROOT": str(root)}
53
54
55 def _make_commit(
56 root: pathlib.Path,
57 repo_id: str,
58 *,
59 branch: str = "main",
60 message: str = "commit",
61 parent_id: str | None = None,
62 ) -> str:
63 manifest: Manifest = {}
64 snap_id = compute_snapshot_id(manifest)
65 committed_at = datetime.datetime.now(datetime.timezone.utc)
66 commit_id = compute_commit_id(
67 parent_ids=[parent_id] if parent_id else [],
68 snapshot_id=snap_id,
69 message=message,
70 committed_at_iso=committed_at.isoformat(),
71 )
72 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
73 write_commit(root, CommitRecord(
74 commit_id=commit_id,
75 repo_id=repo_id,
76 branch=branch,
77 snapshot_id=snap_id,
78 message=message,
79 committed_at=committed_at,
80 parent_commit_id=parent_id,
81 ))
82 ref_file = root / ".muse" / "refs" / "heads" / branch
83 ref_file.parent.mkdir(parents=True, exist_ok=True)
84 ref_file.write_text(commit_id)
85 return commit_id
86
87
88 def _make_chain(root: pathlib.Path, repo_id: str, n: int) -> list[str]:
89 """Create a linear chain of n commits; return commit IDs oldest-first."""
90 ids: list[str] = []
91 parent: str | None = None
92 for i in range(n):
93 cid = _make_commit(root, repo_id, message=f"commit-{i}", parent_id=parent)
94 ids.append(cid)
95 parent = cid
96 return ids
97
98
99 # ---------------------------------------------------------------------------
100 # Unit tests — core bisect logic
101 # ---------------------------------------------------------------------------
102
103
104 class TestBisectCore:
105 def test_start_bisect_returns_result(self, tmp_path: pathlib.Path) -> None:
106 root, repo_id = _init_repo(tmp_path)
107 ids = _make_chain(root, repo_id, 4)
108 from muse.core.bisect import start_bisect
109 result = start_bisect(root, ids[-1], [ids[0]], branch="main")
110 assert result.next_to_test is not None or result.done
111
112 def test_mark_bad_advances_search(self, tmp_path: pathlib.Path) -> None:
113 root, repo_id = _init_repo(tmp_path)
114 ids = _make_chain(root, repo_id, 8)
115 from muse.core.bisect import mark_bad, start_bisect
116 start_bisect(root, ids[-1], [ids[0]], branch="main")
117 result = mark_bad(root, ids[-1])
118 assert not result.done or result.first_bad is not None
119
120 def test_mark_good_advances_search(self, tmp_path: pathlib.Path) -> None:
121 root, repo_id = _init_repo(tmp_path)
122 ids = _make_chain(root, repo_id, 8)
123 from muse.core.bisect import mark_good, start_bisect
124 start_bisect(root, ids[-1], [ids[0]], branch="main")
125 result = mark_good(root, ids[0])
126 assert result is not None
127
128 def test_reset_clears_state(self, tmp_path: pathlib.Path) -> None:
129 root, repo_id = _init_repo(tmp_path)
130 ids = _make_chain(root, repo_id, 4)
131 from muse.core.bisect import is_bisect_active, reset_bisect, start_bisect
132 start_bisect(root, ids[-1], [ids[0]], branch="main")
133 assert is_bisect_active(root)
134 reset_bisect(root)
135 assert not is_bisect_active(root)
136
137 def test_bisect_log_records_events(self, tmp_path: pathlib.Path) -> None:
138 root, repo_id = _init_repo(tmp_path)
139 ids = _make_chain(root, repo_id, 4)
140 from muse.core.bisect import get_bisect_log, start_bisect
141 start_bisect(root, ids[-1], [ids[0]], branch="main")
142 log = get_bisect_log(root)
143 assert len(log) > 0
144
145
146 # ---------------------------------------------------------------------------
147 # Integration tests — CLI subcommands
148 # ---------------------------------------------------------------------------
149
150
151 class TestBisectCLI:
152 def test_start_requires_good_ref(self, tmp_path: pathlib.Path) -> None:
153 root, repo_id = _init_repo(tmp_path)
154 ids = _make_chain(root, repo_id, 2)
155 result = runner.invoke(
156 cli, ["bisect", "start", "--bad", ids[-1]],
157 env=_env(root)
158 )
159 assert result.exit_code != 0
160 assert "good" in result.output.lower()
161
162 def test_start_with_bad_and_good(self, tmp_path: pathlib.Path) -> None:
163 root, repo_id = _init_repo(tmp_path)
164 ids = _make_chain(root, repo_id, 4)
165 result = runner.invoke(
166 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
167 env=_env(root), catch_exceptions=False
168 )
169 assert result.exit_code == 0
170 assert "started" in result.output.lower() or "next" in result.output.lower()
171
172 def test_bad_without_session_fails(self, tmp_path: pathlib.Path) -> None:
173 root, repo_id = _init_repo(tmp_path)
174 ids = _make_chain(root, repo_id, 2)
175 result = runner.invoke(cli, ["bisect", "bad", ids[-1]], env=_env(root))
176 assert result.exit_code != 0
177
178 def test_good_without_session_fails(self, tmp_path: pathlib.Path) -> None:
179 root, repo_id = _init_repo(tmp_path)
180 ids = _make_chain(root, repo_id, 2)
181 result = runner.invoke(cli, ["bisect", "good", ids[0]], env=_env(root))
182 assert result.exit_code != 0
183
184 def test_skip_without_session_fails(self, tmp_path: pathlib.Path) -> None:
185 root, repo_id = _init_repo(tmp_path)
186 ids = _make_chain(root, repo_id, 2)
187 result = runner.invoke(cli, ["bisect", "skip", ids[0]], env=_env(root))
188 assert result.exit_code != 0
189
190 def test_reset_clears_session(self, tmp_path: pathlib.Path) -> None:
191 root, repo_id = _init_repo(tmp_path)
192 ids = _make_chain(root, repo_id, 4)
193 runner.invoke(
194 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
195 env=_env(root)
196 )
197 result = runner.invoke(cli, ["bisect", "reset"], env=_env(root), catch_exceptions=False)
198 assert result.exit_code == 0
199 # After reset, bad should fail
200 result2 = runner.invoke(cli, ["bisect", "bad", ids[-1]], env=_env(root))
201 assert result2.exit_code != 0
202
203 def test_log_shows_entries(self, tmp_path: pathlib.Path) -> None:
204 root, repo_id = _init_repo(tmp_path)
205 ids = _make_chain(root, repo_id, 4)
206 runner.invoke(
207 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
208 env=_env(root)
209 )
210 result = runner.invoke(cli, ["bisect", "log"], env=_env(root), catch_exceptions=False)
211 assert result.exit_code == 0
212
213 def test_double_start_fails(self, tmp_path: pathlib.Path) -> None:
214 root, repo_id = _init_repo(tmp_path)
215 ids = _make_chain(root, repo_id, 4)
216 runner.invoke(
217 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
218 env=_env(root)
219 )
220 result = runner.invoke(
221 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
222 env=_env(root)
223 )
224 assert result.exit_code != 0
225 assert "already" in result.output.lower()
226
227 def test_bad_invalid_ref_fails(self, tmp_path: pathlib.Path) -> None:
228 root, repo_id = _init_repo(tmp_path)
229 ids = _make_chain(root, repo_id, 4)
230 runner.invoke(
231 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
232 env=_env(root)
233 )
234 result = runner.invoke(cli, ["bisect", "bad", "deadbeef"], env=_env(root))
235 assert result.exit_code != 0
236
237 def test_reset_without_session_succeeds(self, tmp_path: pathlib.Path) -> None:
238 """reset when no session is active should not crash."""
239 root, _ = _init_repo(tmp_path)
240 result = runner.invoke(cli, ["bisect", "reset"], env=_env(root), catch_exceptions=False)
241 assert result.exit_code == 0
242
243 def test_log_empty_without_session(self, tmp_path: pathlib.Path) -> None:
244 root, _ = _init_repo(tmp_path)
245 result = runner.invoke(cli, ["bisect", "log"], env=_env(root), catch_exceptions=False)
246 assert result.exit_code == 0
247 assert "no bisect" in result.output.lower() or result.output.strip() == "" or "no" in result.output.lower()
248
249
250 # ---------------------------------------------------------------------------
251 # E2E tests
252 # ---------------------------------------------------------------------------
253
254
255 class TestBisectE2E:
256 def test_full_bisect_workflow_2_commits(self, tmp_path: pathlib.Path) -> None:
257 """Start → mark good → mark bad → find first bad commit."""
258 root, repo_id = _init_repo(tmp_path)
259 ids = _make_chain(root, repo_id, 2)
260 good_id, bad_id = ids[0], ids[1]
261
262 runner.invoke(
263 cli, ["bisect", "start", "--bad", bad_id, "--good", good_id],
264 env=_env(root)
265 )
266 # With only 2 commits, bisect should already identify bad_id
267 from muse.core.bisect import get_bisect_log
268 log = get_bisect_log(root)
269 assert len(log) >= 1
270
271 def test_full_bisect_workflow_many_commits(self, tmp_path: pathlib.Path) -> None:
272 """With a chain of 8 commits, bisect converges without error."""
273 root, repo_id = _init_repo(tmp_path)
274 ids = _make_chain(root, repo_id, 8)
275
276 runner.invoke(
277 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
278 env=_env(root), catch_exceptions=False
279 )
280
281 from muse.core.bisect import _load_state, is_bisect_active, mark_bad, mark_good
282 # Simulate binary search: assume the bug was introduced at ids[4]
283 max_steps = 20
284 steps = 0
285 done = False
286 while is_bisect_active(root) and steps < max_steps and not done:
287 state = _load_state(root)
288 if state is None:
289 break
290 remaining = state.get("remaining", [])
291 if not remaining:
292 break
293 mid = remaining[len(remaining) // 2]
294 # ids[4] and later are "bad"
295 if mid in ids[4:]:
296 result = mark_bad(root, mid)
297 else:
298 result = mark_good(root, mid)
299 done = result.done
300 steps += 1
301
302 # Bisect should have converged or be close
303 assert done or steps < max_steps
304
305
306 # ---------------------------------------------------------------------------
307 # Security tests
308 # ---------------------------------------------------------------------------
309
310
311 class TestBisectSecurity:
312 def test_ref_with_control_chars_is_rejected(self, tmp_path: pathlib.Path) -> None:
313 root, repo_id = _init_repo(tmp_path)
314 ids = _make_chain(root, repo_id, 2)
315 runner.invoke(
316 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
317 env=_env(root)
318 )
319 # Inject control chars in a bad ref
320 result = runner.invoke(cli, ["bisect", "bad", "\x1b[31minjection\x1b[0m"], env=_env(root))
321 assert result.exit_code != 0
322
323 def test_output_contains_no_ansi_on_invalid_ref(self, tmp_path: pathlib.Path) -> None:
324 root, repo_id = _init_repo(tmp_path)
325 ids = _make_chain(root, repo_id, 2)
326 runner.invoke(
327 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
328 env=_env(root)
329 )
330 result = runner.invoke(cli, ["bisect", "bad", "nonexistent-ref\x1b[31m"], env=_env(root))
331 assert "\x1b[31m" not in result.output
332
333
334 # ---------------------------------------------------------------------------
335 # Stress tests
336 # ---------------------------------------------------------------------------
337
338
339 class TestBisectStress:
340 def test_bisect_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
341 """A 50-commit chain converges within log2(50) + 2 ≈ 8 steps."""
342 root, repo_id = _init_repo(tmp_path)
343 ids = _make_chain(root, repo_id, 50)
344 bad_start = 25 # regression introduced at index 25
345
346 result = runner.invoke(
347 cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]],
348 env=_env(root)
349 )
350 assert result.exit_code == 0
351
352 from muse.core.bisect import _load_state, is_bisect_active, mark_bad, mark_good
353 max_steps = 10 # ceil(log2(48)) = 6; allow generous headroom
354 steps = 0
355 done = False
356 while is_bisect_active(root) and steps < max_steps and not done:
357 state = _load_state(root)
358 if state is None:
359 break
360 remaining = state.get("remaining", [])
361 if not remaining:
362 break
363 mid = remaining[len(remaining) // 2]
364 idx = ids.index(mid) if mid in ids else -1
365 if idx >= bad_start:
366 result = mark_bad(root, mid)
367 else:
368 result = mark_good(root, mid)
369 done = result.done
370 steps += 1
371
372 assert done or steps < max_steps, f"Bisect failed to converge in {steps} steps"
File History 4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago