gabriel / muse public
test_breakage_supercharge.py python
366 lines 14.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Supercharge tests for ``muse code breakage`` — agent-usability gaps.
2
3 The existing test file (test_cmd_breakage.py, 855 lines) already covers
4 correctness, exit codes, regression, and stress. This file targets only the
5 gaps those tests leave open:
6
7 Coverage matrix
8 ---------------
9 - --json / -j: -j alias works identically to --json
10 - exit_code: JSON output includes exit_code mirroring process exit
11 - duration_ms: JSON output includes non-negative float duration_ms
12 - TypedDicts: _BreakageOutputJson gains exit_code/duration_ms annotations
13 - Docstrings: run() docstring mentions exit_code and duration_ms
14 - ANSI: JSON output never contains terminal escape sequences
15 - Performance: duration_ms stays under 2000 ms for a small repo
16
17 Critical distinction from other commands: exit_code in the JSON payload
18 mirrors the actual computed process exit code (may be 1 when issues are
19 found), NOT hardcoded to 0.
20 """
21
22 from __future__ import annotations
23
24 import json
25 import pathlib
26 import textwrap
27
28 import pytest
29
30 from tests.cli_test_helper import CliRunner
31
32 runner = CliRunner()
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _env(root: pathlib.Path) -> dict[str, str]:
41 return {"MUSE_REPO_ROOT": str(root)}
42
43
44 def _run(root: pathlib.Path, *args: str): # type: ignore[return]
45 return runner.invoke(None, list(args), env=_env(root))
46
47
48 # ---------------------------------------------------------------------------
49 # Fixture — clean repo with no working-tree changes (no breakage)
50 # ---------------------------------------------------------------------------
51
52
53 @pytest.fixture()
54 def clean_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
55 """Code-domain repo whose working tree matches HEAD — zero breakage."""
56 monkeypatch.chdir(tmp_path)
57
58 r = _run(tmp_path, "init", "--domain", "code")
59 assert r.exit_code == 0, r.output
60
61 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
62 class Invoice:
63 def compute_total(self, items):
64 return sum(items)
65
66 def add_tax(self, rate):
67 return rate
68
69 def create_invoice(items):
70 return Invoice()
71 """))
72 r1 = _run(tmp_path, "code", "add", "billing.py")
73 assert r1.exit_code == 0, r1.output
74 r2 = _run(tmp_path, "commit", "-m", "initial billing")
75 assert r2.exit_code == 0, r2.output
76
77 return tmp_path
78
79
80 # ---------------------------------------------------------------------------
81 # Fixture — repo with a removed public method in the working tree (breakage)
82 # ---------------------------------------------------------------------------
83
84
85 @pytest.fixture()
86 def broken_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
87 """Repo where working tree removes a public method → breakage detected.
88
89 Commit 1: Invoice has compute_total + add_tax
90 Working tree: Invoice has only compute_total (add_tax removed)
91 → removed_public_method error → exit_code == 1
92 """
93 monkeypatch.chdir(tmp_path)
94
95 r = _run(tmp_path, "init", "--domain", "code")
96 assert r.exit_code == 0, r.output
97
98 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
99 class Invoice:
100 def compute_total(self, items):
101 return sum(items)
102
103 def add_tax(self, rate):
104 return rate
105 """))
106 r1 = _run(tmp_path, "code", "add", "billing.py")
107 assert r1.exit_code == 0, r1.output
108 r2 = _run(tmp_path, "commit", "-m", "initial billing")
109 assert r2.exit_code == 0, r2.output
110
111 # Remove add_tax from working tree (not committed)
112 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
113 class Invoice:
114 def compute_total(self, items):
115 return sum(items)
116 """))
117
118 return tmp_path
119
120
121 # ---------------------------------------------------------------------------
122 # TestJsonAlias — -j works identically to --json
123 # ---------------------------------------------------------------------------
124
125
126 class TestJsonAlias:
127 """The -j shorthand must behave identically to --json."""
128
129 def test_j_alias_exits_zero_on_clean_repo(self, clean_repo: pathlib.Path) -> None:
130 r = _run(clean_repo, "code", "breakage", "-j")
131 assert r.exit_code == 0, r.output
132
133 def test_j_alias_valid_json(self, clean_repo: pathlib.Path) -> None:
134 r = _run(clean_repo, "code", "breakage", "-j")
135 json.loads(r.output) # must not raise
136
137 def test_j_alias_has_issues_key(self, clean_repo: pathlib.Path) -> None:
138 r = _run(clean_repo, "code", "breakage", "-j")
139 data = json.loads(r.output)
140 assert "issues" in data
141
142 def test_j_alias_has_errors_key(self, clean_repo: pathlib.Path) -> None:
143 r = _run(clean_repo, "code", "breakage", "-j")
144 data = json.loads(r.output)
145 assert "errors" in data
146
147 def test_j_alias_same_top_level_keys_as_json_flag(self, clean_repo: pathlib.Path) -> None:
148 r1 = _run(clean_repo, "code", "breakage", "--json")
149 r2 = _run(clean_repo, "code", "breakage", "-j")
150 d1 = json.loads(r1.output)
151 d2 = json.loads(r2.output)
152 d1.pop("duration_ms", None)
153 d2.pop("duration_ms", None)
154 assert set(d1.keys()) == set(d2.keys())
155
156 def test_j_alias_same_exit_code_as_json_flag(self, clean_repo: pathlib.Path) -> None:
157 r1 = _run(clean_repo, "code", "breakage", "--json")
158 r2 = _run(clean_repo, "code", "breakage", "-j")
159 assert r1.exit_code == r2.exit_code
160
161
162 # ---------------------------------------------------------------------------
163 # TestDurationMs — JSON output must include duration_ms
164 # ---------------------------------------------------------------------------
165
166
167 class TestDurationMs:
168 """JSON output must include a non-negative float duration_ms."""
169
170 def test_json_has_duration_ms(self, clean_repo: pathlib.Path) -> None:
171 r = _run(clean_repo, "code", "breakage", "--json")
172 data = json.loads(r.output)
173 assert "duration_ms" in data
174
175 def test_json_duration_ms_nonnegative(self, clean_repo: pathlib.Path) -> None:
176 r = _run(clean_repo, "code", "breakage", "--json")
177 data = json.loads(r.output)
178 assert data["duration_ms"] >= 0
179
180 def test_json_duration_ms_is_float(self, clean_repo: pathlib.Path) -> None:
181 r = _run(clean_repo, "code", "breakage", "--json")
182 data = json.loads(r.output)
183 assert isinstance(data["duration_ms"], float)
184
185 def test_j_alias_duration_ms_present(self, clean_repo: pathlib.Path) -> None:
186 r = _run(clean_repo, "code", "breakage", "-j")
187 data = json.loads(r.output)
188 assert "duration_ms" in data
189
190 def test_duration_ms_present_on_broken_repo(self, broken_repo: pathlib.Path) -> None:
191 r = _run(broken_repo, "code", "breakage", "--json")
192 data = json.loads(r.output)
193 assert "duration_ms" in data
194 assert data["duration_ms"] >= 0
195
196 def test_duration_ms_with_strict_flag(self, clean_repo: pathlib.Path) -> None:
197 r = _run(clean_repo, "code", "breakage", "--json", "--strict")
198 data = json.loads(r.output)
199 assert "duration_ms" in data
200 assert data["duration_ms"] >= 0
201
202
203 # ---------------------------------------------------------------------------
204 # TestExitCode — JSON output must include exit_code mirroring process exit
205 # ---------------------------------------------------------------------------
206
207
208 class TestExitCode:
209 """JSON output must include exit_code that mirrors the process exit code."""
210
211 def test_json_has_exit_code(self, clean_repo: pathlib.Path) -> None:
212 r = _run(clean_repo, "code", "breakage", "--json")
213 data = json.loads(r.output)
214 assert "exit_code" in data
215
216 def test_json_exit_code_zero_on_clean_repo(self, clean_repo: pathlib.Path) -> None:
217 r = _run(clean_repo, "code", "breakage", "--json")
218 assert r.exit_code == 0
219 data = json.loads(r.output)
220 assert data["exit_code"] == 0
221
222 def test_json_exit_code_is_int(self, clean_repo: pathlib.Path) -> None:
223 r = _run(clean_repo, "code", "breakage", "--json")
224 data = json.loads(r.output)
225 assert isinstance(data["exit_code"], int)
226
227 def test_j_alias_exit_code_present(self, clean_repo: pathlib.Path) -> None:
228 r = _run(clean_repo, "code", "breakage", "-j")
229 data = json.loads(r.output)
230 assert "exit_code" in data
231
232 def test_exit_code_mirrors_process_exit_on_clean(self, clean_repo: pathlib.Path) -> None:
233 r = _run(clean_repo, "code", "breakage", "--json")
234 data = json.loads(r.output)
235 assert data["exit_code"] == r.exit_code
236
237 def test_exit_code_one_on_broken_repo(self, broken_repo: pathlib.Path) -> None:
238 """Breakage with errors exits 1 and JSON exit_code == 1."""
239 r = _run(broken_repo, "code", "breakage", "--json")
240 assert r.exit_code == 1
241 data = json.loads(r.output)
242 assert data["exit_code"] == 1
243
244 def test_exit_code_mirrors_process_exit_on_broken(self, broken_repo: pathlib.Path) -> None:
245 r = _run(broken_repo, "code", "breakage", "--json")
246 data = json.loads(r.output)
247 assert data["exit_code"] == r.exit_code
248
249 def test_exit_code_in_json_is_not_hardcoded_zero(self, broken_repo: pathlib.Path) -> None:
250 """Verify exit_code reflects real exit, not a hardcoded 0."""
251 r = _run(broken_repo, "code", "breakage", "--json")
252 data = json.loads(r.output)
253 # exit_code must equal 1 (errors found), proving it's not hardcoded
254 assert data["exit_code"] != 0
255
256
257 # ---------------------------------------------------------------------------
258 # TestTypedDicts — _BreakageOutputJson carries the new fields
259 # ---------------------------------------------------------------------------
260
261
262 class TestTypedDicts:
263 """_BreakageOutputJson must carry exit_code/duration_ms annotations."""
264
265 def test_breakage_output_json_exists(self) -> None:
266 from muse.cli.commands.breakage import _BreakageOutputJson # noqa: F401
267
268 def test_breakage_output_json_has_exit_code_annotation(self) -> None:
269 from muse.cli.commands.breakage import _BreakageOutputJson
270 assert "exit_code" in _BreakageOutputJson.__annotations__
271
272 def test_breakage_output_json_has_duration_ms_annotation(self) -> None:
273 from muse.cli.commands.breakage import _BreakageOutputJson
274 assert "duration_ms" in _BreakageOutputJson.__annotations__
275
276 def test_breakage_output_json_retains_issues_annotation(self) -> None:
277 from muse.cli.commands.breakage import _BreakageOutputJson
278 assert "issues" in _BreakageOutputJson.__annotations__
279
280 def test_breakage_output_json_retains_errors_annotation(self) -> None:
281 from muse.cli.commands.breakage import _BreakageOutputJson
282 assert "errors" in _BreakageOutputJson.__annotations__
283
284 def test_breakage_output_json_retains_warnings_annotation(self) -> None:
285 from muse.cli.commands.breakage import _BreakageOutputJson
286 assert "warnings" in _BreakageOutputJson.__annotations__
287
288 def test_breakage_output_json_retains_schema_version_annotation(self) -> None:
289 from muse.cli.commands.breakage import _BreakageOutputJson
290 assert "schema_version" in _BreakageOutputJson.__annotations__
291
292 def test_breakage_issue_exists(self) -> None:
293 from muse.cli.commands.breakage import _BreakageIssue # noqa: F401
294
295 def test_breakage_issue_has_severity(self) -> None:
296 from muse.cli.commands.breakage import _BreakageIssue
297 assert "severity" in _BreakageIssue.__annotations__
298
299
300 # ---------------------------------------------------------------------------
301 # TestDocstrings — run() docstring documents new fields
302 # ---------------------------------------------------------------------------
303
304
305 class TestDocstrings:
306 """run() must document exit_code and duration_ms."""
307
308 def test_run_docstring_mentions_exit_code(self) -> None:
309 from muse.cli.commands.breakage import run
310 assert run.__doc__ is not None
311 assert "exit_code" in run.__doc__
312
313 def test_run_docstring_mentions_duration_ms(self) -> None:
314 from muse.cli.commands.breakage import run
315 assert run.__doc__ is not None
316 assert "duration_ms" in run.__doc__
317
318
319 # ---------------------------------------------------------------------------
320 # TestAnsiSanitization — no escape codes in JSON output
321 # ---------------------------------------------------------------------------
322
323
324 class TestAnsiSanitization:
325 """No ANSI escape sequences anywhere in the JSON output."""
326
327 def test_json_output_no_ansi(self, clean_repo: pathlib.Path) -> None:
328 r = _run(clean_repo, "code", "breakage", "--json")
329 assert "\x1b" not in r.output
330
331 def test_j_alias_output_no_ansi(self, clean_repo: pathlib.Path) -> None:
332 r = _run(clean_repo, "code", "breakage", "-j")
333 assert "\x1b" not in r.output
334
335 def test_broken_repo_json_output_no_ansi(self, broken_repo: pathlib.Path) -> None:
336 r = _run(broken_repo, "code", "breakage", "--json")
337 assert "\x1b" not in r.output
338
339
340 # ---------------------------------------------------------------------------
341 # TestPerformance — duration_ms under 2000 ms for a small repo
342 # ---------------------------------------------------------------------------
343
344
345 class TestPerformance:
346 """duration_ms must be non-negative and under 2000 ms for small repos."""
347
348 def test_json_duration_under_2000ms(self, clean_repo: pathlib.Path) -> None:
349 r = _run(clean_repo, "code", "breakage", "--json")
350 data = json.loads(r.output)
351 assert data["duration_ms"] < 2000
352
353 def test_j_alias_duration_under_2000ms(self, clean_repo: pathlib.Path) -> None:
354 r = _run(clean_repo, "code", "breakage", "-j")
355 data = json.loads(r.output)
356 assert data["duration_ms"] < 2000
357
358 def test_broken_repo_duration_under_2000ms(self, broken_repo: pathlib.Path) -> None:
359 r = _run(broken_repo, "code", "breakage", "--json")
360 data = json.loads(r.output)
361 assert data["duration_ms"] < 2000
362
363 def test_duration_ms_is_float_not_int(self, clean_repo: pathlib.Path) -> None:
364 r = _run(clean_repo, "code", "breakage", "--json")
365 data = json.loads(r.output)
366 assert isinstance(data["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago