gabriel / muse public
test_grep_supercharge.py python
347 lines 13.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Supercharge tests for ``muse code grep`` — agent-usability gaps.
2
3 The existing test_cmd_grep.py covers correctness, --regex, --kind, --language,
4 --file, --count, --hashes, --commit, --json schema (source_ref, working_tree,
5 pattern, total_matches, results), qualified-name search, ReDoS guards, and
6 a 1000-symbol stress test.
7
8 This file targets only the gaps those tests leave open:
9
10 Coverage matrix
11 ---------------
12 - --json / -j: -j alias works identically to --json
13 - exit_code: JSON output includes exit_code = 0 on success
14 - duration_ms: JSON output includes non-negative float duration_ms
15 - TypedDicts: _GrepOutputJson carries all fields including exit_code/duration_ms
16 - Docstrings: run() docstring mentions exit_code and duration_ms
17 - ANSI: JSON output never contains terminal escape sequences
18 - Performance: duration_ms stays under 2000 ms for a small repo
19 """
20
21 from __future__ import annotations
22
23 import json
24 import pathlib
25 import textwrap
26
27 import pytest
28
29 from tests.cli_test_helper import CliRunner
30
31 runner = CliRunner()
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers
36 # ---------------------------------------------------------------------------
37
38
39 def _env(root: pathlib.Path) -> dict[str, str]:
40 return {"MUSE_REPO_ROOT": str(root)}
41
42
43 def _run(root: pathlib.Path, *args: str):
44 return runner.invoke(None, list(args), env=_env(root))
45
46
47 # ---------------------------------------------------------------------------
48 # Fixture — minimal repo with named symbols
49 # ---------------------------------------------------------------------------
50
51
52 @pytest.fixture()
53 def grep_repo(
54 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
55 ) -> pathlib.Path:
56 """Repo with two Python files and several named symbols.
57
58 billing.py — Invoice class + validate_amount function
59 auth.py — verify_token function + AuthError class
60 """
61 monkeypatch.chdir(tmp_path)
62 r = _run(tmp_path, "init", "--domain", "code")
63 assert r.exit_code == 0, r.output
64
65 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
66 class Invoice:
67 def compute_total(self, items):
68 return sum(items)
69
70 def validate_amount(amount):
71 if amount < 0:
72 raise ValueError("negative amount")
73 return amount
74 """))
75 (tmp_path / "auth.py").write_text(textwrap.dedent("""\
76 class AuthError(Exception):
77 pass
78
79 def verify_token(token):
80 if not token:
81 raise AuthError("missing token")
82 return True
83 """))
84 r = _run(tmp_path, "code", "add", ".")
85 assert r.exit_code == 0, r.output
86 r = _run(tmp_path, "commit", "-m", "seed grep repo")
87 assert r.exit_code == 0, r.output
88
89 return tmp_path
90
91
92 # ---------------------------------------------------------------------------
93 # TestJsonAlias — -j works identically to --json
94 # ---------------------------------------------------------------------------
95
96
97 class TestJsonAlias:
98 """-j shorthand must behave identically to --json."""
99
100 def test_j_alias_exits_zero(self, grep_repo: pathlib.Path) -> None:
101 r = _run(grep_repo, "code", "grep", "validate", "-j")
102 assert r.exit_code == 0, r.output
103
104 def test_j_alias_valid_json(self, grep_repo: pathlib.Path) -> None:
105 r = _run(grep_repo, "code", "grep", "validate", "-j")
106 json.loads(r.output) # must not raise
107
108 def test_j_alias_has_results_key(self, grep_repo: pathlib.Path) -> None:
109 r = _run(grep_repo, "code", "grep", "validate", "-j")
110 assert "results" in json.loads(r.output)
111
112 def test_j_alias_has_total_matches_key(self, grep_repo: pathlib.Path) -> None:
113 r = _run(grep_repo, "code", "grep", "validate", "-j")
114 assert "total_matches" in json.loads(r.output)
115
116 def test_j_alias_has_pattern_key(self, grep_repo: pathlib.Path) -> None:
117 r = _run(grep_repo, "code", "grep", "validate", "-j")
118 assert "pattern" in json.loads(r.output)
119
120 def test_j_alias_same_top_level_keys_as_json_flag(
121 self, grep_repo: pathlib.Path
122 ) -> None:
123 r1 = _run(grep_repo, "code", "grep", "validate", "--json")
124 r2 = _run(grep_repo, "code", "grep", "validate", "-j")
125 d1 = json.loads(r1.output)
126 d2 = json.loads(r2.output)
127 d1.pop("duration_ms", None)
128 d2.pop("duration_ms", None)
129 assert set(d1.keys()) == set(d2.keys())
130
131 def test_j_alias_match_count_matches_json_flag(
132 self, grep_repo: pathlib.Path
133 ) -> None:
134 r1 = _run(grep_repo, "code", "grep", "validate", "--json")
135 r2 = _run(grep_repo, "code", "grep", "validate", "-j")
136 assert json.loads(r1.output)["total_matches"] == json.loads(r2.output)["total_matches"]
137
138 def test_j_alias_pattern_echoed(self, grep_repo: pathlib.Path) -> None:
139 r = _run(grep_repo, "code", "grep", "Invoice", "-j")
140 assert json.loads(r.output)["pattern"] == "Invoice"
141
142 def test_j_alias_no_match_empty_results(self, grep_repo: pathlib.Path) -> None:
143 r = _run(grep_repo, "code", "grep", "zzz_never", "-j")
144 assert r.exit_code == 0, r.output
145 data = json.loads(r.output)
146 assert data["results"] == []
147 assert data["total_matches"] == 0
148
149 def test_j_alias_with_kind_filter(self, grep_repo: pathlib.Path) -> None:
150 r = _run(grep_repo, "code", "grep", "Invoice", "-j", "--kind", "class")
151 assert r.exit_code == 0, r.output
152 data = json.loads(r.output)
153 for res in data["results"]:
154 assert res["kind"] == "class"
155
156
157 # ---------------------------------------------------------------------------
158 # TestDurationMs — JSON output must include duration_ms
159 # ---------------------------------------------------------------------------
160
161
162 class TestDurationMs:
163 """JSON output must include a non-negative float duration_ms."""
164
165 def test_json_has_duration_ms(self, grep_repo: pathlib.Path) -> None:
166 r = _run(grep_repo, "code", "grep", "validate", "--json")
167 assert "duration_ms" in json.loads(r.output)
168
169 def test_json_duration_ms_nonnegative(self, grep_repo: pathlib.Path) -> None:
170 r = _run(grep_repo, "code", "grep", "validate", "--json")
171 assert json.loads(r.output)["duration_ms"] >= 0
172
173 def test_json_duration_ms_is_float(self, grep_repo: pathlib.Path) -> None:
174 r = _run(grep_repo, "code", "grep", "validate", "--json")
175 assert isinstance(json.loads(r.output)["duration_ms"], float)
176
177 def test_j_alias_duration_ms_present(self, grep_repo: pathlib.Path) -> None:
178 r = _run(grep_repo, "code", "grep", "validate", "-j")
179 assert "duration_ms" in json.loads(r.output)
180
181 def test_duration_ms_no_results(self, grep_repo: pathlib.Path) -> None:
182 """duration_ms present even when no symbols match."""
183 r = _run(grep_repo, "code", "grep", "zzz_never", "--json")
184 data = json.loads(r.output)
185 assert "duration_ms" in data
186 assert data["duration_ms"] >= 0
187
188 def test_duration_ms_with_kind_filter(self, grep_repo: pathlib.Path) -> None:
189 r = _run(grep_repo, "code", "grep", "Invoice", "--json", "--kind", "class")
190 data = json.loads(r.output)
191 assert "duration_ms" in data
192 assert isinstance(data["duration_ms"], float)
193
194 def test_duration_ms_with_regex(self, grep_repo: pathlib.Path) -> None:
195 r = _run(grep_repo, "code", "grep", "^validate", "--json", "--regex")
196 data = json.loads(r.output)
197 assert "duration_ms" in data
198 assert data["duration_ms"] >= 0
199
200
201 # ---------------------------------------------------------------------------
202 # TestExitCode — JSON includes exit_code = 0 on success
203 # ---------------------------------------------------------------------------
204
205
206 class TestExitCode:
207 """JSON exit_code must be 0 on success."""
208
209 def test_json_has_exit_code(self, grep_repo: pathlib.Path) -> None:
210 r = _run(grep_repo, "code", "grep", "validate", "--json")
211 assert "exit_code" in json.loads(r.output)
212
213 def test_json_exit_code_zero(self, grep_repo: pathlib.Path) -> None:
214 r = _run(grep_repo, "code", "grep", "validate", "--json")
215 assert r.exit_code == 0
216 assert json.loads(r.output)["exit_code"] == 0
217
218 def test_json_exit_code_is_int(self, grep_repo: pathlib.Path) -> None:
219 r = _run(grep_repo, "code", "grep", "validate", "--json")
220 assert isinstance(json.loads(r.output)["exit_code"], int)
221
222 def test_j_alias_exit_code_present(self, grep_repo: pathlib.Path) -> None:
223 r = _run(grep_repo, "code", "grep", "validate", "-j")
224 assert "exit_code" in json.loads(r.output)
225
226 def test_exit_code_mirrors_process_exit(self, grep_repo: pathlib.Path) -> None:
227 r = _run(grep_repo, "code", "grep", "validate", "--json")
228 assert json.loads(r.output)["exit_code"] == r.exit_code
229
230 def test_exit_code_zero_empty_results(self, grep_repo: pathlib.Path) -> None:
231 """exit_code is 0 even when no symbols match."""
232 r = _run(grep_repo, "code", "grep", "zzz_never", "--json")
233 assert r.exit_code == 0
234 data = json.loads(r.output)
235 assert data["exit_code"] == 0
236 assert data["results"] == []
237
238 def test_exit_code_zero_with_kind_filter(self, grep_repo: pathlib.Path) -> None:
239 r = _run(grep_repo, "code", "grep", "Invoice", "--json", "--kind", "class")
240 assert r.exit_code == 0
241 assert json.loads(r.output)["exit_code"] == 0
242
243 def test_exit_code_zero_with_regex(self, grep_repo: pathlib.Path) -> None:
244 r = _run(grep_repo, "code", "grep", "validate.*", "--json", "--regex")
245 assert r.exit_code == 0
246 assert json.loads(r.output)["exit_code"] == 0
247
248
249 # ---------------------------------------------------------------------------
250 # TestTypedDicts — _GrepOutputJson carries all fields
251 # ---------------------------------------------------------------------------
252
253
254 class TestTypedDicts:
255 """_GrepOutputJson must carry exit_code and duration_ms annotations."""
256
257 def test_grep_output_json_typeddict_exists(self) -> None:
258 from muse.cli.commands.grep import _GrepOutputJson # noqa: F401
259
260 def test_has_exit_code_annotation(self) -> None:
261 from muse.cli.commands.grep import _GrepOutputJson
262 assert "exit_code" in _GrepOutputJson.__annotations__
263
264 def test_has_duration_ms_annotation(self) -> None:
265 from muse.cli.commands.grep import _GrepOutputJson
266 assert "duration_ms" in _GrepOutputJson.__annotations__
267
268 def test_retains_results_annotation(self) -> None:
269 from muse.cli.commands.grep import _GrepOutputJson
270 assert "results" in _GrepOutputJson.__annotations__
271
272 def test_retains_total_matches_annotation(self) -> None:
273 from muse.cli.commands.grep import _GrepOutputJson
274 assert "total_matches" in _GrepOutputJson.__annotations__
275
276 def test_retains_pattern_annotation(self) -> None:
277 from muse.cli.commands.grep import _GrepOutputJson
278 assert "pattern" in _GrepOutputJson.__annotations__
279
280 def test_retains_source_ref_annotation(self) -> None:
281 from muse.cli.commands.grep import _GrepOutputJson
282 assert "source_ref" in _GrepOutputJson.__annotations__
283
284 def test_retains_working_tree_annotation(self) -> None:
285 from muse.cli.commands.grep import _GrepOutputJson
286 assert "working_tree" in _GrepOutputJson.__annotations__
287
288
289 # ---------------------------------------------------------------------------
290 # TestDocstrings — run() docstring documents exit_code and duration_ms
291 # ---------------------------------------------------------------------------
292
293
294 class TestDocstrings:
295 """run() must document exit_code and duration_ms."""
296
297 def test_run_docstring_mentions_exit_code(self) -> None:
298 from muse.cli.commands.grep import run
299 assert run.__doc__ is not None
300 assert "exit_code" in run.__doc__
301
302 def test_run_docstring_mentions_duration_ms(self) -> None:
303 from muse.cli.commands.grep import run
304 assert run.__doc__ is not None
305 assert "duration_ms" in run.__doc__
306
307
308 # ---------------------------------------------------------------------------
309 # TestAnsiSanitization — no escape codes in JSON output
310 # ---------------------------------------------------------------------------
311
312
313 class TestAnsiSanitization:
314 """No ANSI escape sequences anywhere in the JSON output."""
315
316 def test_json_output_no_ansi(self, grep_repo: pathlib.Path) -> None:
317 r = _run(grep_repo, "code", "grep", "validate", "--json")
318 assert "\x1b" not in r.output
319
320 def test_j_alias_output_no_ansi(self, grep_repo: pathlib.Path) -> None:
321 r = _run(grep_repo, "code", "grep", "validate", "-j")
322 assert "\x1b" not in r.output
323
324 def test_json_no_ansi_with_results(self, grep_repo: pathlib.Path) -> None:
325 r = _run(grep_repo, "code", "grep", "Invoice", "--json")
326 assert "\x1b" not in r.output
327
328
329 # ---------------------------------------------------------------------------
330 # TestPerformance — duration_ms under 2000 ms for a small repo
331 # ---------------------------------------------------------------------------
332
333
334 class TestPerformance:
335 """duration_ms must stay under 2000 ms for small repos."""
336
337 def test_json_duration_under_2000ms(self, grep_repo: pathlib.Path) -> None:
338 r = _run(grep_repo, "code", "grep", "validate", "--json")
339 assert json.loads(r.output)["duration_ms"] < 2000
340
341 def test_j_alias_duration_under_2000ms(self, grep_repo: pathlib.Path) -> None:
342 r = _run(grep_repo, "code", "grep", "Invoice", "-j")
343 assert json.loads(r.output)["duration_ms"] < 2000
344
345 def test_duration_ms_is_float_not_int(self, grep_repo: pathlib.Path) -> None:
346 r = _run(grep_repo, "code", "grep", "validate", "--json")
347 assert isinstance(json.loads(r.output)["duration_ms"], float)
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago