gabriel / muse public
test_grep_supercharge.py python
364 lines 14.2 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 20 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 from collections.abc import Mapping
23
24 import argparse
25 import json
26 import pathlib
27 import textwrap
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner, InvokeResult
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _env(root: pathlib.Path) -> Mapping[str, str]:
42 return {"MUSE_REPO_ROOT": str(root)}
43
44
45 def _run(root: pathlib.Path, *args: str) -> InvokeResult:
46 return runner.invoke(None, list(args), env=_env(root))
47
48
49 # ---------------------------------------------------------------------------
50 # Fixture — minimal repo with named symbols
51 # ---------------------------------------------------------------------------
52
53
54 @pytest.fixture()
55 def grep_repo(
56 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
57 ) -> pathlib.Path:
58 """Repo with two Python files and several named symbols.
59
60 billing.py — Invoice class + validate_amount function
61 auth.py — verify_token function + AuthError class
62 """
63 monkeypatch.chdir(tmp_path)
64 r = _run(tmp_path, "init", "--domain", "code")
65 assert r.exit_code == 0, r.output
66
67 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
68 class Invoice:
69 def compute_total(self, items):
70 return sum(items)
71
72 def validate_amount(amount):
73 if amount < 0:
74 raise ValueError("negative amount")
75 return amount
76 """))
77 (tmp_path / "auth.py").write_text(textwrap.dedent("""\
78 class AuthError(Exception):
79 pass
80
81 def verify_token(token):
82 if not token:
83 raise AuthError("missing token")
84 return True
85 """))
86 r = _run(tmp_path, "code", "add", ".")
87 assert r.exit_code == 0, r.output
88 r = _run(tmp_path, "commit", "-m", "seed grep repo")
89 assert r.exit_code == 0, r.output
90
91 return tmp_path
92
93
94 # ---------------------------------------------------------------------------
95 # TestJsonAlias — -j works identically to --json
96 # ---------------------------------------------------------------------------
97
98
99 class TestJsonAlias:
100 """-j shorthand must behave identically to --json."""
101
102 def test_j_alias_exits_zero(self, grep_repo: pathlib.Path) -> None:
103 r = _run(grep_repo, "code", "grep", "validate", "-j")
104 assert r.exit_code == 0, r.output
105
106 def test_j_alias_valid_json(self, grep_repo: pathlib.Path) -> None:
107 r = _run(grep_repo, "code", "grep", "validate", "-j")
108 json.loads(r.output) # must not raise
109
110 def test_j_alias_has_results_key(self, grep_repo: pathlib.Path) -> None:
111 r = _run(grep_repo, "code", "grep", "validate", "-j")
112 assert "results" in json.loads(r.output)
113
114 def test_j_alias_has_total_matches_key(self, grep_repo: pathlib.Path) -> None:
115 r = _run(grep_repo, "code", "grep", "validate", "-j")
116 assert "total_matches" in json.loads(r.output)
117
118 def test_j_alias_has_pattern_key(self, grep_repo: pathlib.Path) -> None:
119 r = _run(grep_repo, "code", "grep", "validate", "-j")
120 assert "pattern" in json.loads(r.output)
121
122 def test_j_alias_same_top_level_keys_as_json_flag(
123 self, grep_repo: pathlib.Path
124 ) -> None:
125 r1 = _run(grep_repo, "code", "grep", "validate", "--json")
126 r2 = _run(grep_repo, "code", "grep", "validate", "-j")
127 d1 = json.loads(r1.output)
128 d2 = json.loads(r2.output)
129 d1.pop("duration_ms", None)
130 d2.pop("duration_ms", None)
131 assert set(d1.keys()) == set(d2.keys())
132
133 def test_j_alias_match_count_matches_json_flag(
134 self, grep_repo: pathlib.Path
135 ) -> None:
136 r1 = _run(grep_repo, "code", "grep", "validate", "--json")
137 r2 = _run(grep_repo, "code", "grep", "validate", "-j")
138 assert json.loads(r1.output)["total_matches"] == json.loads(r2.output)["total_matches"]
139
140 def test_j_alias_pattern_echoed(self, grep_repo: pathlib.Path) -> None:
141 r = _run(grep_repo, "code", "grep", "Invoice", "-j")
142 assert json.loads(r.output)["pattern"] == "Invoice"
143
144 def test_j_alias_no_match_empty_results(self, grep_repo: pathlib.Path) -> None:
145 r = _run(grep_repo, "code", "grep", "zzz_never", "-j")
146 assert r.exit_code == 0, r.output
147 data = json.loads(r.output)
148 assert data["results"] == []
149 assert data["total_matches"] == 0
150
151 def test_j_alias_with_kind_filter(self, grep_repo: pathlib.Path) -> None:
152 r = _run(grep_repo, "code", "grep", "Invoice", "-j", "--kind", "class")
153 assert r.exit_code == 0, r.output
154 data = json.loads(r.output)
155 for res in data["results"]:
156 assert res["kind"] == "class"
157
158
159 # ---------------------------------------------------------------------------
160 # TestDurationMs — JSON output must include duration_ms
161 # ---------------------------------------------------------------------------
162
163
164 class TestDurationMs:
165 """JSON output must include a non-negative float duration_ms."""
166
167 def test_json_has_duration_ms(self, grep_repo: pathlib.Path) -> None:
168 r = _run(grep_repo, "code", "grep", "validate", "--json")
169 assert "duration_ms" in json.loads(r.output)
170
171 def test_json_duration_ms_nonnegative(self, grep_repo: pathlib.Path) -> None:
172 r = _run(grep_repo, "code", "grep", "validate", "--json")
173 assert json.loads(r.output)["duration_ms"] >= 0
174
175 def test_json_duration_ms_is_float(self, grep_repo: pathlib.Path) -> None:
176 r = _run(grep_repo, "code", "grep", "validate", "--json")
177 assert isinstance(json.loads(r.output)["duration_ms"], float)
178
179 def test_j_alias_duration_ms_present(self, grep_repo: pathlib.Path) -> None:
180 r = _run(grep_repo, "code", "grep", "validate", "-j")
181 assert "duration_ms" in json.loads(r.output)
182
183 def test_duration_ms_no_results(self, grep_repo: pathlib.Path) -> None:
184 """duration_ms present even when no symbols match."""
185 r = _run(grep_repo, "code", "grep", "zzz_never", "--json")
186 data = json.loads(r.output)
187 assert "duration_ms" in data
188 assert data["duration_ms"] >= 0
189
190 def test_duration_ms_with_kind_filter(self, grep_repo: pathlib.Path) -> None:
191 r = _run(grep_repo, "code", "grep", "Invoice", "--json", "--kind", "class")
192 data = json.loads(r.output)
193 assert "duration_ms" in data
194 assert isinstance(data["duration_ms"], float)
195
196 def test_duration_ms_with_regex(self, grep_repo: pathlib.Path) -> None:
197 r = _run(grep_repo, "code", "grep", "^validate", "--json", "--regex")
198 data = json.loads(r.output)
199 assert "duration_ms" in data
200 assert data["duration_ms"] >= 0
201
202
203 # ---------------------------------------------------------------------------
204 # TestExitCode — JSON includes exit_code = 0 on success
205 # ---------------------------------------------------------------------------
206
207
208 class TestExitCode:
209 """JSON exit_code must be 0 on success."""
210
211 def test_json_has_exit_code(self, grep_repo: pathlib.Path) -> None:
212 r = _run(grep_repo, "code", "grep", "validate", "--json")
213 assert "exit_code" in json.loads(r.output)
214
215 def test_json_exit_code_zero(self, grep_repo: pathlib.Path) -> None:
216 r = _run(grep_repo, "code", "grep", "validate", "--json")
217 assert r.exit_code == 0
218 assert json.loads(r.output)["exit_code"] == 0
219
220 def test_json_exit_code_is_int(self, grep_repo: pathlib.Path) -> None:
221 r = _run(grep_repo, "code", "grep", "validate", "--json")
222 assert isinstance(json.loads(r.output)["exit_code"], int)
223
224 def test_j_alias_exit_code_present(self, grep_repo: pathlib.Path) -> None:
225 r = _run(grep_repo, "code", "grep", "validate", "-j")
226 assert "exit_code" in json.loads(r.output)
227
228 def test_exit_code_mirrors_process_exit(self, grep_repo: pathlib.Path) -> None:
229 r = _run(grep_repo, "code", "grep", "validate", "--json")
230 assert json.loads(r.output)["exit_code"] == r.exit_code
231
232 def test_exit_code_zero_empty_results(self, grep_repo: pathlib.Path) -> None:
233 """exit_code is 0 even when no symbols match."""
234 r = _run(grep_repo, "code", "grep", "zzz_never", "--json")
235 assert r.exit_code == 0
236 data = json.loads(r.output)
237 assert data["exit_code"] == 0
238 assert data["results"] == []
239
240 def test_exit_code_zero_with_kind_filter(self, grep_repo: pathlib.Path) -> None:
241 r = _run(grep_repo, "code", "grep", "Invoice", "--json", "--kind", "class")
242 assert r.exit_code == 0
243 assert json.loads(r.output)["exit_code"] == 0
244
245 def test_exit_code_zero_with_regex(self, grep_repo: pathlib.Path) -> None:
246 r = _run(grep_repo, "code", "grep", "validate.*", "--json", "--regex")
247 assert r.exit_code == 0
248 assert json.loads(r.output)["exit_code"] == 0
249
250
251 # ---------------------------------------------------------------------------
252 # TestTypedDicts — _GrepOutputJson carries all fields
253 # ---------------------------------------------------------------------------
254
255
256 class TestTypedDicts:
257 """_GrepOutputJson must carry exit_code and duration_ms annotations."""
258
259 def test_grep_output_json_typeddict_exists(self) -> None:
260 from muse.cli.commands.grep import _GrepOutputJson # noqa: F401
261
262 def test_has_exit_code_annotation(self) -> None:
263 from muse.cli.commands.grep import _GrepOutputJson
264 assert "exit_code" in _GrepOutputJson.__annotations__
265
266 def test_has_duration_ms_annotation(self) -> None:
267 from muse.cli.commands.grep import _GrepOutputJson
268 assert "duration_ms" in _GrepOutputJson.__annotations__
269
270 def test_retains_results_annotation(self) -> None:
271 from muse.cli.commands.grep import _GrepOutputJson
272 assert "results" in _GrepOutputJson.__annotations__
273
274 def test_retains_total_matches_annotation(self) -> None:
275 from muse.cli.commands.grep import _GrepOutputJson
276 assert "total_matches" in _GrepOutputJson.__annotations__
277
278 def test_retains_pattern_annotation(self) -> None:
279 from muse.cli.commands.grep import _GrepOutputJson
280 assert "pattern" in _GrepOutputJson.__annotations__
281
282 def test_retains_source_ref_annotation(self) -> None:
283 from muse.cli.commands.grep import _GrepOutputJson
284 assert "source_ref" in _GrepOutputJson.__annotations__
285
286 def test_retains_working_tree_annotation(self) -> None:
287 from muse.cli.commands.grep import _GrepOutputJson
288 assert "working_tree" in _GrepOutputJson.__annotations__
289
290
291 # ---------------------------------------------------------------------------
292 # TestAnsiSanitization — no escape codes in JSON output
293 # ---------------------------------------------------------------------------
294
295
296 class TestAnsiSanitization:
297 """No ANSI escape sequences anywhere in the JSON output."""
298
299 def test_json_output_no_ansi(self, grep_repo: pathlib.Path) -> None:
300 r = _run(grep_repo, "code", "grep", "validate", "--json")
301 assert "\x1b" not in r.output
302
303 def test_j_alias_output_no_ansi(self, grep_repo: pathlib.Path) -> None:
304 r = _run(grep_repo, "code", "grep", "validate", "-j")
305 assert "\x1b" not in r.output
306
307 def test_json_no_ansi_with_results(self, grep_repo: pathlib.Path) -> None:
308 r = _run(grep_repo, "code", "grep", "Invoice", "--json")
309 assert "\x1b" not in r.output
310
311
312 # ---------------------------------------------------------------------------
313 # TestPerformance — duration_ms under 2000 ms for a small repo
314 # ---------------------------------------------------------------------------
315
316
317 class TestPerformance:
318 """duration_ms must stay under 2000 ms for small repos."""
319
320 def test_json_duration_under_2000ms(self, grep_repo: pathlib.Path) -> None:
321 r = _run(grep_repo, "code", "grep", "validate", "--json")
322 assert json.loads(r.output)["duration_ms"] < 2000
323
324 def test_j_alias_duration_under_2000ms(self, grep_repo: pathlib.Path) -> None:
325 r = _run(grep_repo, "code", "grep", "Invoice", "-j")
326 assert json.loads(r.output)["duration_ms"] < 2000
327
328 def test_duration_ms_is_float_not_int(self, grep_repo: pathlib.Path) -> None:
329 r = _run(grep_repo, "code", "grep", "validate", "--json")
330 assert isinstance(json.loads(r.output)["duration_ms"], float)
331
332
333 # ---------------------------------------------------------------------------
334 # TestRegisterFlags — argparse-level verification
335 # ---------------------------------------------------------------------------
336
337
338 class TestRegisterFlags:
339 """Verify that register() wires --json / -j correctly."""
340
341 def _make_parser(self) -> "argparse.ArgumentParser":
342 import argparse
343 from muse.cli.commands.grep import register
344 ap = argparse.ArgumentParser()
345 subs = ap.add_subparsers()
346 register(subs)
347 return ap
348
349 def test_json_flag_long(self) -> None:
350 ns = self._make_parser().parse_args(["grep", "X", "--json"])
351 assert ns.json_out is True
352
353 def test_j_alias(self) -> None:
354 ns = self._make_parser().parse_args(["grep", "X", "-j"])
355 assert ns.json_out is True
356
357 def test_default_is_text(self) -> None:
358 ns = self._make_parser().parse_args(["grep", "X"])
359 assert ns.json_out is False
360
361 def test_dest_is_json_out(self) -> None:
362 ns = self._make_parser().parse_args(["grep", "X", "-j"])
363 assert hasattr(ns, "json_out")
364 assert not hasattr(ns, "fmt")
File History 1 commit
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 20 days ago