gabriel / muse public
test_ls_tree_supercharge.py python
400 lines 15.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 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
15 import datetime
16 import hashlib
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, long_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 long_id(hashlib.sha256(data).hexdigest())
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: dict[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([], snap_id, f"commit {_counter}", committed_at.isoformat())
70 write_commit(root, CommitRecord(
71 commit_id=commit_id,
72 repo_id=_REPO_ID,
73 branch=branch,
74 snapshot_id=snap_id,
75 message=f"commit {_counter}",
76 committed_at=committed_at,
77 ))
78 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
79 return commit_id
80
81
82 def _invoke(repo: pathlib.Path, *args: str):
83 from muse.cli.app import main as cli
84 return runner.invoke(cli, ["ls-tree", *args], env={"MUSE_REPO_ROOT": str(repo)})
85
86
87 # ---------------------------------------------------------------------------
88 # JSON envelope schema
89 # ---------------------------------------------------------------------------
90
91 class TestJsonEnvelopeSchema:
92 """Every required key is present in the success envelope."""
93
94 _REQUIRED = {
95 "status", "error", "treeish", "commit_id",
96 "path_prefix", "recursive", "entry_count", "entries",
97 "duration_ms", "exit_code",
98 }
99
100 def test_all_required_keys_present(self, tmp_path: pathlib.Path) -> None:
101 repo = _init_repo(tmp_path)
102 _commit_files(repo, {"a.py": b"# a\n"})
103 r = _invoke(repo, "HEAD", "--json")
104 assert r.exit_code == 0
105 d = json.loads(r.output)
106 missing = self._REQUIRED - d.keys()
107 assert not missing, f"Missing keys: {missing}"
108
109 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
110 repo = _init_repo(tmp_path)
111 _commit_files(repo, {"a.py": b"# a\n"})
112 r = _invoke(repo, "HEAD", "--json")
113 assert json.loads(r.output)["status"] == "ok"
114
115 def test_error_empty_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)["error"] == ""
120
121 def test_exit_code_zero_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)["exit_code"] == 0
126
127 def test_duration_ms_is_nonneg_float(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 d = json.loads(r.output)
132 assert isinstance(d["duration_ms"], float)
133 assert d["duration_ms"] >= 0.0
134
135 def test_entry_count_matches_entries_length(self, tmp_path: pathlib.Path) -> None:
136 repo = _init_repo(tmp_path)
137 _commit_files(repo, {"a.py": b"a", "b.py": b"b", "src/c.py": b"c"})
138 r = _invoke(repo, "HEAD", "--json")
139 d = json.loads(r.output)
140 assert d["entry_count"] == len(d["entries"])
141
142 def test_path_prefix_null_when_not_given(self, tmp_path: pathlib.Path) -> None:
143 repo = _init_repo(tmp_path)
144 _commit_files(repo, {"a.py": b"a"})
145 r = _invoke(repo, "HEAD", "--json")
146 d = json.loads(r.output)
147 assert d["path_prefix"] is None
148
149 def test_path_prefix_echoed_when_given(self, tmp_path: pathlib.Path) -> None:
150 repo = _init_repo(tmp_path)
151 _commit_files(repo, {"src/a.py": b"a"})
152 r = _invoke(repo, "HEAD", "src/", "--json")
153 d = json.loads(r.output)
154 assert d["path_prefix"] == "src/"
155
156 def test_recursive_false_by_default(self, tmp_path: pathlib.Path) -> None:
157 repo = _init_repo(tmp_path)
158 _commit_files(repo, {"src/a.py": b"a"})
159 r = _invoke(repo, "HEAD", "--json")
160 d = json.loads(r.output)
161 assert d["recursive"] is False
162
163 def test_recursive_true_when_flag_given(self, tmp_path: pathlib.Path) -> None:
164 repo = _init_repo(tmp_path)
165 _commit_files(repo, {"src/a.py": b"a"})
166 r = _invoke(repo, "-r", "HEAD", "--json")
167 d = json.loads(r.output)
168 assert d["recursive"] is True
169
170 def test_treeish_echoed(self, tmp_path: pathlib.Path) -> None:
171 repo = _init_repo(tmp_path)
172 _commit_files(repo, {"a.py": b"a"})
173 r = _invoke(repo, "HEAD", "--json")
174 d = json.loads(r.output)
175 assert d["treeish"] == "HEAD"
176
177 def test_commit_id_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
178 repo = _init_repo(tmp_path)
179 _commit_files(repo, {"a.py": b"a"})
180 r = _invoke(repo, "HEAD", "--json")
181 d = json.loads(r.output)
182 assert d["commit_id"].startswith("sha256:")
183
184
185 # ---------------------------------------------------------------------------
186 # Error payload shape
187 # ---------------------------------------------------------------------------
188
189 class TestErrorPayloadShape:
190 """In --json mode, errors go to stdout as {status, error, exit_code}."""
191
192 def test_error_on_empty_repo_is_json(self, tmp_path: pathlib.Path) -> None:
193 repo = _init_repo(tmp_path)
194 r = _invoke(repo, "HEAD", "--json")
195 assert r.exit_code != 0
196 d = json.loads(r.output) # must be valid JSON
197 assert d["status"] == "error"
198
199 def test_error_payload_has_exactly_three_keys(self, tmp_path: pathlib.Path) -> None:
200 repo = _init_repo(tmp_path)
201 r = _invoke(repo, "HEAD", "--json")
202 d = json.loads(r.output)
203 assert set(d.keys()) == {"status", "error", "exit_code"}
204
205 def test_error_message_nonempty(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 d["error"]
210
211 def test_exit_code_nonzero_on_error(self, tmp_path: pathlib.Path) -> None:
212 repo = _init_repo(tmp_path)
213 r = _invoke(repo, "HEAD", "--json")
214 assert r.exit_code != 0
215 d = json.loads(r.output)
216 assert d["exit_code"] != 0
217
218 def test_ansi_in_ref_error_is_json(self, tmp_path: pathlib.Path) -> None:
219 repo = _init_repo(tmp_path)
220 _commit_files(repo, {"a.py": b"a"})
221 r = _invoke(repo, "\x1b[31mbad\x1b[0m", "--json")
222 assert r.exit_code != 0
223 d = json.loads(r.output)
224 assert d["status"] == "error"
225
226 def test_bad_ref_error_is_json(self, tmp_path: pathlib.Path) -> None:
227 repo = _init_repo(tmp_path)
228 _commit_files(repo, {"a.py": b"a"})
229 r = _invoke(repo, "no-such-branch", "--json")
230 assert r.exit_code != 0
231 d = json.loads(r.output)
232 assert d["status"] == "error"
233
234 def test_path_traversal_error_is_json(self, tmp_path: pathlib.Path) -> None:
235 repo = _init_repo(tmp_path)
236 _commit_files(repo, {"a.py": b"a"})
237 r = _invoke(repo, "HEAD", "../../../etc/", "--json")
238 assert r.exit_code != 0
239 d = json.loads(r.output)
240 assert d["status"] == "error"
241
242
243 # ---------------------------------------------------------------------------
244 # OID data integrity
245 # ---------------------------------------------------------------------------
246
247 class TestOidIntegrity:
248 """All object IDs in output carry the sha256: prefix."""
249
250 def test_blob_object_ids_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
251 repo = _init_repo(tmp_path)
252 _commit_files(repo, {"a.py": b"content"})
253 r = _invoke(repo, "-r", "HEAD", "--json")
254 d = json.loads(r.output)
255 for e in d["entries"]:
256 if e["type"] == "blob":
257 assert e["object_id"].startswith("sha256:"), (
258 f"blob OID not prefixed: {e['object_id']!r}"
259 )
260
261 def test_synthetic_tree_object_ids_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
262 repo = _init_repo(tmp_path)
263 _commit_files(repo, {"src/a.py": b"a", "lib/b.py": b"b"})
264 r = _invoke(repo, "HEAD", "--json")
265 d = json.loads(r.output)
266 for e in d["entries"]:
267 if e["type"] == "tree":
268 assert e["object_id"].startswith("sha256:"), (
269 f"tree OID not prefixed: {e['object_id']!r}"
270 )
271
272 def test_blob_oid_hex_part_is_64_chars(self, tmp_path: pathlib.Path) -> None:
273 repo = _init_repo(tmp_path)
274 _commit_files(repo, {"a.py": b"content"})
275 r = _invoke(repo, "-r", "HEAD", "--json")
276 d = json.loads(r.output)
277 for e in d["entries"]:
278 if e["type"] == "blob":
279 hex_part = e["object_id"][7:] # after "sha256:"
280 assert len(hex_part) == 64
281 assert all(c in "0123456789abcdef" for c in hex_part)
282
283 def test_tree_oid_hex_part_is_64_chars(self, tmp_path: pathlib.Path) -> None:
284 repo = _init_repo(tmp_path)
285 _commit_files(repo, {"src/a.py": b"a"})
286 r = _invoke(repo, "HEAD", "--json")
287 d = json.loads(r.output)
288 for e in d["entries"]:
289 if e["type"] == "tree":
290 hex_part = e["object_id"][7:]
291 assert len(hex_part) == 64
292 assert all(c in "0123456789abcdef" for c in hex_part)
293
294 def test_text_format_blob_oid_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
295 repo = _init_repo(tmp_path)
296 _commit_files(repo, {"a.py": b"content"})
297 r = _invoke(repo, "-r", "HEAD")
298 assert r.exit_code == 0
299 for line in r.output.strip().splitlines():
300 meta, _ = line.split("\t", 1)
301 parts = meta.split()
302 oid = parts[2]
303 assert oid.startswith("sha256:"), f"text OID not prefixed: {oid!r}"
304
305
306 # ---------------------------------------------------------------------------
307 # No-prose pollution
308 # ---------------------------------------------------------------------------
309
310 class TestNoProsePollution:
311 def test_stdout_valid_json_on_success(self, tmp_path: pathlib.Path) -> None:
312 repo = _init_repo(tmp_path)
313 _commit_files(repo, {"a.py": b"a"})
314 r = _invoke(repo, "HEAD", "--json")
315 json.loads(r.output) # must not raise
316
317 def test_no_emoji_in_json_stdout(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 assert "❌" not in r.output
322
323 def test_error_stdout_valid_json(self, tmp_path: pathlib.Path) -> None:
324 repo = _init_repo(tmp_path)
325 r = _invoke(repo, "HEAD", "--json")
326 json.loads(r.output) # must not raise
327
328 def test_no_traceback_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
329 repo = _init_repo(tmp_path)
330 _commit_files(repo, {"a.py": b"a"})
331 r = _invoke(repo, "ghost-branch", "--json")
332 assert "Traceback" not in r.output
333 assert "Traceback" not in r.stderr
334
335 def test_ansi_in_output_encoded_in_json(self, tmp_path: pathlib.Path) -> None:
336 """File paths with ANSI sequences must be JSON-encoded, not emitted raw."""
337 repo = _init_repo(tmp_path)
338 evil = "src/\x1b[31mevil\x1b[0m.py"
339 _commit_files(repo, {evil: b"bad"})
340 r = _invoke(repo, "-r", "HEAD", "--json")
341 assert r.exit_code == 0
342 assert "\x1b" not in r.output
343
344
345 # ---------------------------------------------------------------------------
346 # TypedDicts
347 # ---------------------------------------------------------------------------
348
349 class TestTypedDicts:
350 def test_ls_tree_json_typeddict_exists(self) -> None:
351 from muse.cli.commands.ls_tree import _LsTreeJson
352 assert _LsTreeJson is not None
353
354 def test_ls_tree_error_json_typeddict_exists(self) -> None:
355 from muse.cli.commands.ls_tree import _LsTreeErrorJson
356 assert _LsTreeErrorJson is not None
357
358 def test_ls_tree_json_has_status_annotation(self) -> None:
359 from muse.cli.commands.ls_tree import _LsTreeJson
360 hints = get_type_hints(_LsTreeJson)
361 assert "status" in hints
362
363 def test_ls_tree_json_has_all_new_fields(self) -> None:
364 from muse.cli.commands.ls_tree import _LsTreeJson
365 hints = get_type_hints(_LsTreeJson)
366 for field in ("status", "error", "entry_count", "path_prefix", "recursive",
367 "duration_ms", "exit_code"):
368 assert field in hints, f"Missing annotation: {field!r}"
369
370
371 # ---------------------------------------------------------------------------
372 # Docstring coverage
373 # ---------------------------------------------------------------------------
374
375 class TestDocstring:
376 def _doc(self) -> str:
377 import muse.cli.commands.ls_tree as mod
378 return mod.__doc__ or ""
379
380 def test_docstring_documents_status(self) -> None:
381 assert "status" in self._doc()
382
383 def test_docstring_documents_error(self) -> None:
384 assert "error" in self._doc()
385
386 def test_docstring_documents_entry_count(self) -> None:
387 assert "entry_count" in self._doc()
388
389 def test_docstring_documents_path_prefix(self) -> None:
390 assert "path_prefix" in self._doc()
391
392 def test_docstring_documents_duration_ms(self) -> None:
393 assert "duration_ms" in self._doc()
394
395 def test_docstring_documents_exit_code(self) -> None:
396 assert "exit_code" in self._doc()
397
398 def test_docstring_documents_error_schema(self) -> None:
399 doc = self._doc()
400 assert "error" in doc and "exit_code" in doc
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago