gabriel / muse public
test_ls_tree_supercharge.py python
440 lines 16.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
1 """Supercharge tests for ``muse ls-tree``.
2
3 Coverage tiers
4 --------------
5 - JSON envelope schema: status, error, exit_code, duration_ms, entry_count,
6 path_prefix, recursive always present
7 - Error payload shape: exactly {status, error, exit_code} — no prose in --json mode
8 - OID integrity: blob object_ids sha256:-prefixed; synthetic tree object_ids sha256:-prefixed
9 - TypedDicts: _LsTreeJson and _LsTreeErrorJson exist and are annotated
10 - Docstring: module docstring covers all new envelope fields and error schema
11 - No-prose pollution: no emoji in JSON stdout, errors to stdout in --json mode
12 """
13 from __future__ import annotations
14 from collections.abc import Mapping
15
16 import datetime
17 import json
18 import pathlib
19 from typing import get_type_hints
20
21 import pytest
22
23 from muse.core.errors import ExitCode
24 from muse.core.object_store import write_object
25 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
26 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
27 from muse.core._types import Manifest, blob_id, split_id
28 from tests.cli_test_helper import CliRunner
29
30 runner = CliRunner()
31
32 _REPO_ID = "ls-tree-sg-test"
33 _counter = 0
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40 def _sha(data: bytes) -> str:
41 return blob_id(data)
42
43
44 def _init_repo(path: pathlib.Path) -> pathlib.Path:
45 muse = path / ".muse"
46 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
47 (muse / d).mkdir(parents=True, exist_ok=True)
48 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
49 (muse / "repo.json").write_text(
50 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
51 )
52 return path
53
54
55 def _commit_files(root: pathlib.Path, files: Mapping[str, bytes], branch: str = "main") -> str:
56 global _counter
57 _counter += 1
58 manifest: Manifest = {}
59 for rel_path, content in files.items():
60 obj_id = _sha(content)
61 write_object(root, obj_id, content)
62 manifest[rel_path] = obj_id
63 abs_path = root / rel_path
64 abs_path.parent.mkdir(parents=True, exist_ok=True)
65 abs_path.write_bytes(content)
66 snap_id = compute_snapshot_id(manifest)
67 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
68 committed_at = datetime.datetime.now(datetime.timezone.utc)
69 commit_id = compute_commit_id(
70 repo_id=_REPO_ID,
71 parent_ids=[],
72 snapshot_id=snap_id,
73 message=f"commit {_counter}",
74 committed_at_iso=committed_at.isoformat(),
75 )
76 write_commit(root, CommitRecord(
77 commit_id=commit_id,
78 repo_id=_REPO_ID,
79 created_on_branch=branch,
80 snapshot_id=snap_id,
81 message=f"commit {_counter}",
82 committed_at=committed_at,
83 ))
84 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
85 return commit_id
86
87
88 def _invoke(repo: pathlib.Path, *args: str):
89 from muse.cli.app import main as cli
90 return runner.invoke(cli, ["ls-tree", *args], env={"MUSE_REPO_ROOT": str(repo)})
91
92
93 # ---------------------------------------------------------------------------
94 # JSON envelope schema
95 # ---------------------------------------------------------------------------
96
97 class TestJsonEnvelopeSchema:
98 """Every required key is present in the success envelope."""
99
100 _REQUIRED = {
101 "status", "error", "treeish", "commit_id",
102 "path_prefix", "recursive", "entry_count", "entries",
103 "duration_ms", "exit_code",
104 }
105
106 def test_all_required_keys_present(self, tmp_path: pathlib.Path) -> None:
107 repo = _init_repo(tmp_path)
108 _commit_files(repo, {"a.py": b"# a\n"})
109 r = _invoke(repo, "HEAD", "--json")
110 assert r.exit_code == 0
111 d = json.loads(r.output)
112 missing = self._REQUIRED - d.keys()
113 assert not missing, f"Missing keys: {missing}"
114
115 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
116 repo = _init_repo(tmp_path)
117 _commit_files(repo, {"a.py": b"# a\n"})
118 r = _invoke(repo, "HEAD", "--json")
119 assert json.loads(r.output)["status"] == "ok"
120
121 def test_error_empty_on_success(self, tmp_path: pathlib.Path) -> None:
122 repo = _init_repo(tmp_path)
123 _commit_files(repo, {"a.py": b"# a\n"})
124 r = _invoke(repo, "HEAD", "--json")
125 assert json.loads(r.output)["error"] == ""
126
127 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
128 repo = _init_repo(tmp_path)
129 _commit_files(repo, {"a.py": b"# a\n"})
130 r = _invoke(repo, "HEAD", "--json")
131 assert json.loads(r.output)["exit_code"] == 0
132
133 def test_duration_ms_is_nonneg_float(self, tmp_path: pathlib.Path) -> None:
134 repo = _init_repo(tmp_path)
135 _commit_files(repo, {"a.py": b"# a\n"})
136 r = _invoke(repo, "HEAD", "--json")
137 d = json.loads(r.output)
138 assert isinstance(d["duration_ms"], float)
139 assert d["duration_ms"] >= 0.0
140
141 def test_entry_count_matches_entries_length(self, tmp_path: pathlib.Path) -> None:
142 repo = _init_repo(tmp_path)
143 _commit_files(repo, {"a.py": b"a", "b.py": b"b", "src/c.py": b"c"})
144 r = _invoke(repo, "HEAD", "--json")
145 d = json.loads(r.output)
146 assert d["entry_count"] == len(d["entries"])
147
148 def test_path_prefix_null_when_not_given(self, tmp_path: pathlib.Path) -> None:
149 repo = _init_repo(tmp_path)
150 _commit_files(repo, {"a.py": b"a"})
151 r = _invoke(repo, "HEAD", "--json")
152 d = json.loads(r.output)
153 assert d["path_prefix"] is None
154
155 def test_path_prefix_echoed_when_given(self, tmp_path: pathlib.Path) -> None:
156 repo = _init_repo(tmp_path)
157 _commit_files(repo, {"src/a.py": b"a"})
158 r = _invoke(repo, "HEAD", "src/", "--json")
159 d = json.loads(r.output)
160 assert d["path_prefix"] == "src/"
161
162 def test_recursive_false_by_default(self, tmp_path: pathlib.Path) -> None:
163 repo = _init_repo(tmp_path)
164 _commit_files(repo, {"src/a.py": b"a"})
165 r = _invoke(repo, "HEAD", "--json")
166 d = json.loads(r.output)
167 assert d["recursive"] is False
168
169 def test_recursive_true_when_flag_given(self, tmp_path: pathlib.Path) -> None:
170 repo = _init_repo(tmp_path)
171 _commit_files(repo, {"src/a.py": b"a"})
172 r = _invoke(repo, "-r", "HEAD", "--json")
173 d = json.loads(r.output)
174 assert d["recursive"] is True
175
176 def test_treeish_echoed(self, tmp_path: pathlib.Path) -> None:
177 repo = _init_repo(tmp_path)
178 _commit_files(repo, {"a.py": b"a"})
179 r = _invoke(repo, "HEAD", "--json")
180 d = json.loads(r.output)
181 assert d["treeish"] == "HEAD"
182
183 def test_commit_id_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
184 repo = _init_repo(tmp_path)
185 _commit_files(repo, {"a.py": b"a"})
186 r = _invoke(repo, "HEAD", "--json")
187 d = json.loads(r.output)
188 assert d["commit_id"].startswith("sha256:")
189
190
191 # ---------------------------------------------------------------------------
192 # Error payload shape
193 # ---------------------------------------------------------------------------
194
195 class TestErrorPayloadShape:
196 """In --json mode, errors go to stdout as {status, error, exit_code}."""
197
198 def test_error_on_empty_repo_is_json(self, tmp_path: pathlib.Path) -> None:
199 repo = _init_repo(tmp_path)
200 r = _invoke(repo, "HEAD", "--json")
201 assert r.exit_code != 0
202 d = json.loads(r.output) # must be valid JSON
203 assert d["status"] == "error"
204
205 def test_error_payload_has_required_keys(self, tmp_path: pathlib.Path) -> None:
206 repo = _init_repo(tmp_path)
207 r = _invoke(repo, "HEAD", "--json")
208 d = json.loads(r.output)
209 assert {"error", "exit_code"} <= set(d.keys())
210
211 def test_error_message_nonempty(self, tmp_path: pathlib.Path) -> None:
212 repo = _init_repo(tmp_path)
213 r = _invoke(repo, "HEAD", "--json")
214 d = json.loads(r.output)
215 assert d["error"]
216
217 def test_exit_code_nonzero_on_error(self, tmp_path: pathlib.Path) -> None:
218 repo = _init_repo(tmp_path)
219 r = _invoke(repo, "HEAD", "--json")
220 assert r.exit_code != 0
221 d = json.loads(r.output)
222 assert d["exit_code"] != 0
223
224 def test_ansi_in_ref_error_is_json(self, tmp_path: pathlib.Path) -> None:
225 repo = _init_repo(tmp_path)
226 _commit_files(repo, {"a.py": b"a"})
227 r = _invoke(repo, "\x1b[31mbad\x1b[0m", "--json")
228 assert r.exit_code != 0
229 d = json.loads(r.output)
230 assert d["status"] == "error"
231
232 def test_bad_ref_error_is_json(self, tmp_path: pathlib.Path) -> None:
233 repo = _init_repo(tmp_path)
234 _commit_files(repo, {"a.py": b"a"})
235 r = _invoke(repo, "no-such-branch", "--json")
236 assert r.exit_code != 0
237 d = json.loads(r.output)
238 assert d["status"] == "error"
239
240 def test_path_traversal_error_is_json(self, tmp_path: pathlib.Path) -> None:
241 repo = _init_repo(tmp_path)
242 _commit_files(repo, {"a.py": b"a"})
243 r = _invoke(repo, "HEAD", "../../../etc/", "--json")
244 assert r.exit_code != 0
245 d = json.loads(r.output)
246 assert d["status"] == "error"
247
248
249 # ---------------------------------------------------------------------------
250 # OID data integrity
251 # ---------------------------------------------------------------------------
252
253 class TestOidIntegrity:
254 """All object IDs in output carry the sha256: prefix."""
255
256 def test_blob_object_ids_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
257 repo = _init_repo(tmp_path)
258 _commit_files(repo, {"a.py": b"content"})
259 r = _invoke(repo, "-r", "HEAD", "--json")
260 d = json.loads(r.output)
261 for e in d["entries"]:
262 if e["type"] == "blob":
263 assert e["object_id"].startswith("sha256:"), (
264 f"blob OID not prefixed: {e['object_id']!r}"
265 )
266
267 def test_synthetic_tree_object_ids_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
268 repo = _init_repo(tmp_path)
269 _commit_files(repo, {"src/a.py": b"a", "lib/b.py": b"b"})
270 r = _invoke(repo, "HEAD", "--json")
271 d = json.loads(r.output)
272 for e in d["entries"]:
273 if e["type"] == "tree":
274 assert e["object_id"].startswith("sha256:"), (
275 f"tree OID not prefixed: {e['object_id']!r}"
276 )
277
278 def test_blob_oid_hex_part_is_64_chars(self, tmp_path: pathlib.Path) -> None:
279 repo = _init_repo(tmp_path)
280 _commit_files(repo, {"a.py": b"content"})
281 r = _invoke(repo, "-r", "HEAD", "--json")
282 d = json.loads(r.output)
283 for e in d["entries"]:
284 if e["type"] == "blob":
285 _, hex_part = split_id(e["object_id"])
286 assert len(hex_part) == 64
287 assert all(c in "0123456789abcdef" for c in hex_part)
288
289 def test_tree_oid_hex_part_is_64_chars(self, tmp_path: pathlib.Path) -> None:
290 repo = _init_repo(tmp_path)
291 _commit_files(repo, {"src/a.py": b"a"})
292 r = _invoke(repo, "HEAD", "--json")
293 d = json.loads(r.output)
294 for e in d["entries"]:
295 if e["type"] == "tree":
296 _, hex_part = split_id(e["object_id"])
297 assert len(hex_part) == 64
298 assert all(c in "0123456789abcdef" for c in hex_part)
299
300 def test_text_format_blob_oid_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
301 repo = _init_repo(tmp_path)
302 _commit_files(repo, {"a.py": b"content"})
303 r = _invoke(repo, "-r", "HEAD")
304 assert r.exit_code == 0
305 for line in r.output.strip().splitlines():
306 meta, _ = line.split("\t", 1)
307 parts = meta.split()
308 oid = parts[2]
309 assert oid.startswith("sha256:"), f"text OID not prefixed: {oid!r}"
310
311
312 # ---------------------------------------------------------------------------
313 # No-prose pollution
314 # ---------------------------------------------------------------------------
315
316 class TestNoProsePollution:
317 def test_stdout_valid_json_on_success(self, tmp_path: pathlib.Path) -> None:
318 repo = _init_repo(tmp_path)
319 _commit_files(repo, {"a.py": b"a"})
320 r = _invoke(repo, "HEAD", "--json")
321 json.loads(r.output) # must not raise
322
323 def test_no_emoji_in_json_stdout(self, tmp_path: pathlib.Path) -> None:
324 repo = _init_repo(tmp_path)
325 _commit_files(repo, {"a.py": b"a"})
326 r = _invoke(repo, "HEAD", "--json")
327 assert "❌" not in r.output
328
329 def test_error_stdout_valid_json(self, tmp_path: pathlib.Path) -> None:
330 repo = _init_repo(tmp_path)
331 r = _invoke(repo, "HEAD", "--json")
332 json.loads(r.output) # must not raise
333
334 def test_no_traceback_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
335 repo = _init_repo(tmp_path)
336 _commit_files(repo, {"a.py": b"a"})
337 r = _invoke(repo, "ghost-branch", "--json")
338 assert "Traceback" not in r.output
339 assert "Traceback" not in r.stderr
340
341 def test_ansi_in_output_encoded_in_json(self, tmp_path: pathlib.Path) -> None:
342 """File paths with ANSI sequences must be JSON-encoded, not emitted raw."""
343 repo = _init_repo(tmp_path)
344 evil = "src/\x1b[31mevil\x1b[0m.py"
345 _commit_files(repo, {evil: b"bad"})
346 r = _invoke(repo, "-r", "HEAD", "--json")
347 assert r.exit_code == 0
348 assert "\x1b" not in r.output
349
350
351 # ---------------------------------------------------------------------------
352 # TypedDicts
353 # ---------------------------------------------------------------------------
354
355 class TestTypedDicts:
356 def test_ls_tree_json_typeddict_exists(self) -> None:
357 from muse.cli.commands.ls_tree import _LsTreeJson
358 assert _LsTreeJson is not None
359
360 def test_ls_tree_error_json_typeddict_exists(self) -> None:
361 from muse.cli.commands.ls_tree import _LsTreeErrorJson
362 assert _LsTreeErrorJson is not None
363
364 def test_ls_tree_json_has_status_annotation(self) -> None:
365 from muse.cli.commands.ls_tree import _LsTreeJson
366 hints = get_type_hints(_LsTreeJson)
367 assert "status" in hints
368
369 def test_ls_tree_json_has_all_new_fields(self) -> None:
370 from muse.cli.commands.ls_tree import _LsTreeJson
371 hints = get_type_hints(_LsTreeJson)
372 for field in ("status", "error", "entry_count", "path_prefix", "recursive",
373 "duration_ms", "exit_code"):
374 assert field in hints, f"Missing annotation: {field!r}"
375
376
377 # ---------------------------------------------------------------------------
378 # Docstring coverage
379 # ---------------------------------------------------------------------------
380
381 class TestDocstring:
382 def _doc(self) -> str:
383 import muse.cli.commands.ls_tree as mod
384 return mod.__doc__ or ""
385
386 def test_docstring_documents_status(self) -> None:
387 assert "status" in self._doc()
388
389 def test_docstring_documents_error(self) -> None:
390 assert "error" in self._doc()
391
392 def test_docstring_documents_entry_count(self) -> None:
393 assert "entry_count" in self._doc()
394
395 def test_docstring_documents_path_prefix(self) -> None:
396 assert "path_prefix" in self._doc()
397
398 def test_docstring_documents_duration_ms(self) -> None:
399 assert "duration_ms" in self._doc()
400
401 def test_docstring_documents_exit_code(self) -> None:
402 assert "exit_code" in self._doc()
403
404 def test_docstring_documents_error_schema(self) -> None:
405 doc = self._doc()
406 assert "error" in doc and "exit_code" in doc
407
408
409 # ---------------------------------------------------------------------------
410 # TestRegisterFlags — argparse-level verification
411 # ---------------------------------------------------------------------------
412
413
414 class TestRegisterFlags:
415 """Verify that register() wires --json / -j correctly."""
416
417 def _make_parser(self):
418 import argparse
419 from muse.cli.commands.ls_tree import register
420 ap = argparse.ArgumentParser()
421 subs = ap.add_subparsers()
422 register(subs)
423 return ap
424
425 def test_json_flag_long(self):
426 ns = self._make_parser().parse_args(["ls-tree", "--json"])
427 assert ns.json_out is True
428
429 def test_j_alias(self):
430 ns = self._make_parser().parse_args(["ls-tree", "-j"])
431 assert ns.json_out is True
432
433 def test_default_is_text(self):
434 ns = self._make_parser().parse_args(["ls-tree"])
435 assert ns.json_out is False
436
437 def test_dest_is_json_out(self):
438 ns = self._make_parser().parse_args(["ls-tree", "-j"])
439 assert hasattr(ns, "json_out")
440 assert not hasattr(ns, "fmt")
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 141 days ago