gabriel / muse public
test_ls_files_supercharge.py python
412 lines 16.1 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Supercharge tests for ``muse ls-files``.
2
3 Coverage tiers
4 --------------
5 I JSON envelope schema — status, error, branch, path_prefix, duration_ms, exit_code
6 II Error payload shape — consistent {status, error, exit_code}; no prose in JSON mode
7 III branch field — reflects the branch HEAD resolved to
8 IV path_prefix echoed — agents can verify which filter was applied
9 V TypedDicts — _LsFilesJson and _LsFilesErrorJson exist with correct annotations
10 VI Docstring — documents all envelope fields
11 VII Data integrity — object_ids are sha256:-prefixed in all output modes
12 VIII No prose pollution in JSON mode
13 """
14 from __future__ import annotations
15
16 import datetime
17 import hashlib
18 import json
19 import pathlib
20
21 import pytest
22
23 from muse.core.object_store import write_object
24 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
25 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
26 from muse.core._types import Manifest, long_id
27 from tests.cli_test_helper import CliRunner, InvokeResult
28
29 runner = CliRunner()
30
31 _REQUIRED_SUCCESS_KEYS = {
32 "status", "error", "commit_id", "snapshot_id", "branch",
33 "path_prefix", "file_count", "files", "duration_ms", "exit_code",
34 }
35 _REQUIRED_ERROR_KEYS = {"status", "error", "exit_code"}
36
37 _TS = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44 def _oid(content: bytes) -> str:
45 return long_id(hashlib.sha256(content).hexdigest())
46
47
48 def _make_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
49 repo = tmp_path / "repo"
50 muse = repo / ".muse"
51 for sub in ("objects", "commits", "snapshots", "refs/heads"):
52 (muse / sub).mkdir(parents=True)
53 (muse / "HEAD").write_text(f"ref: refs/heads/{branch}")
54 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
55 return repo
56
57
58 def _add_commit(
59 repo: pathlib.Path,
60 files: dict[str, bytes],
61 *,
62 branch: str = "main",
63 set_head: bool = True,
64 ) -> str:
65 stored: Manifest = {}
66 for path, content in files.items():
67 oid = _oid(content)
68 write_object(repo, oid, content)
69 stored[path] = oid
70 snap_id = compute_snapshot_id(stored)
71 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=stored, created_at=_TS))
72 commit_id = compute_commit_id([], snap_id, "test", _TS.isoformat())
73 write_commit(repo, CommitRecord(
74 commit_id=commit_id, repo_id="test", branch=branch,
75 snapshot_id=snap_id, message="test", committed_at=_TS,
76 author="tester", parent_commit_id=None,
77 ))
78 if set_head:
79 ref = repo / ".muse" / "refs" / "heads" / branch
80 ref.parent.mkdir(parents=True, exist_ok=True)
81 ref.write_text(commit_id)
82 return commit_id
83
84
85 def _ls(repo: pathlib.Path, *args: str) -> InvokeResult:
86 from muse.cli.app import main as cli
87 return runner.invoke(cli, ["ls-files", *args], env={"MUSE_REPO_ROOT": str(repo)})
88
89
90 def _ls_json(repo: pathlib.Path, *args: str) -> dict:
91 result = _ls(repo, "--json", *args)
92 assert result.exit_code == 0, f"ls-files --json failed:\n{result.output}"
93 return json.loads(result.output.strip())
94
95
96 # ---------------------------------------------------------------------------
97 # I JSON envelope schema
98 # ---------------------------------------------------------------------------
99
100
101 class TestJsonEnvelopeSchema:
102 def test_all_required_keys_present(self, tmp_path: pathlib.Path) -> None:
103 repo = _make_repo(tmp_path)
104 _add_commit(repo, {"a.py": b"a"})
105 data = _ls_json(repo)
106 missing = _REQUIRED_SUCCESS_KEYS - set(data.keys())
107 assert not missing, f"Missing envelope keys: {missing}"
108
109 def test_no_extra_undocumented_keys(self, tmp_path: pathlib.Path) -> None:
110 repo = _make_repo(tmp_path)
111 _add_commit(repo, {"a.py": b"a"})
112 data = _ls_json(repo)
113 extra = set(data.keys()) - _REQUIRED_SUCCESS_KEYS
114 assert not extra, f"Undocumented extra keys: {extra}"
115
116 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
117 repo = _make_repo(tmp_path)
118 _add_commit(repo, {"a.py": b"a"})
119 data = _ls_json(repo)
120 assert data["status"] == "ok"
121
122 def test_error_empty_string_on_success(self, tmp_path: pathlib.Path) -> None:
123 repo = _make_repo(tmp_path)
124 _add_commit(repo, {"a.py": b"a"})
125 data = _ls_json(repo)
126 assert data["error"] == ""
127
128 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
129 repo = _make_repo(tmp_path)
130 _add_commit(repo, {"a.py": b"a"})
131 data = _ls_json(repo)
132 assert data["exit_code"] == 0
133
134 def test_duration_ms_nonnegative_float(self, tmp_path: pathlib.Path) -> None:
135 repo = _make_repo(tmp_path)
136 _add_commit(repo, {"a.py": b"a"})
137 data = _ls_json(repo)
138 assert isinstance(data["duration_ms"], float)
139 assert data["duration_ms"] >= 0.0
140
141 def test_file_count_matches_files_length(self, tmp_path: pathlib.Path) -> None:
142 repo = _make_repo(tmp_path)
143 _add_commit(repo, {"a.py": b"a", "b.py": b"b", "c.py": b"c"})
144 data = _ls_json(repo)
145 assert data["file_count"] == len(data["files"])
146
147 def test_files_is_list(self, tmp_path: pathlib.Path) -> None:
148 repo = _make_repo(tmp_path)
149 _add_commit(repo, {"a.py": b"a"})
150 data = _ls_json(repo)
151 assert isinstance(data["files"], list)
152
153 def test_path_prefix_none_when_not_filtered(self, tmp_path: pathlib.Path) -> None:
154 repo = _make_repo(tmp_path)
155 _add_commit(repo, {"a.py": b"a"})
156 data = _ls_json(repo)
157 assert data["path_prefix"] is None
158
159 def test_path_prefix_echoed_when_filtered(self, tmp_path: pathlib.Path) -> None:
160 repo = _make_repo(tmp_path)
161 _add_commit(repo, {"src/a.py": b"a", "tests/b.py": b"b"})
162 data = _ls_json(repo, "--path-prefix", "src/")
163 assert data["path_prefix"] == "src/"
164
165 def test_commit_id_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
166 repo = _make_repo(tmp_path)
167 _add_commit(repo, {"a.py": b"a"})
168 data = _ls_json(repo)
169 assert data["commit_id"].startswith("sha256:")
170
171 def test_snapshot_id_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
172 repo = _make_repo(tmp_path)
173 _add_commit(repo, {"a.py": b"a"})
174 data = _ls_json(repo)
175 assert data["snapshot_id"].startswith("sha256:")
176
177
178 # ---------------------------------------------------------------------------
179 # II Error payload shape
180 # ---------------------------------------------------------------------------
181
182
183 class TestErrorPayloadShape:
184 def test_error_keys_exactly_three(self, tmp_path: pathlib.Path) -> None:
185 repo = _make_repo(tmp_path) # no commits
186 result = _ls(repo, "--json")
187 assert result.exit_code != 0
188 data = json.loads(result.output.strip())
189 assert set(data.keys()) == _REQUIRED_ERROR_KEYS
190
191 def test_error_status_is_error(self, tmp_path: pathlib.Path) -> None:
192 repo = _make_repo(tmp_path)
193 result = _ls(repo, "--json")
194 assert result.exit_code != 0
195 data = json.loads(result.output.strip())
196 assert data["status"] == "error"
197
198 def test_error_message_nonempty(self, tmp_path: pathlib.Path) -> None:
199 repo = _make_repo(tmp_path)
200 result = _ls(repo, "--json")
201 data = json.loads(result.output.strip())
202 assert isinstance(data["error"], str) and len(data["error"]) > 0
203
204 def test_error_exit_code_nonzero(self, tmp_path: pathlib.Path) -> None:
205 repo = _make_repo(tmp_path)
206 result = _ls(repo, "--json")
207 data = json.loads(result.output.strip())
208 assert isinstance(data["exit_code"], int) and data["exit_code"] != 0
209
210 def test_invalid_commit_error_payload(self, tmp_path: pathlib.Path) -> None:
211 repo = _make_repo(tmp_path)
212 result = _ls(repo, "--json", "--commit", "not-valid")
213 assert result.exit_code != 0
214 data = json.loads(result.output.strip())
215 assert data["status"] == "error"
216 assert "exit_code" in data
217
218 def test_nonexistent_commit_error_payload(self, tmp_path: pathlib.Path) -> None:
219 repo = _make_repo(tmp_path)
220 result = _ls(repo, "--json", "--commit", long_id("f" * 64))
221 assert result.exit_code != 0
222 data = json.loads(result.output.strip())
223 assert data["status"] == "error"
224
225
226 # ---------------------------------------------------------------------------
227 # III branch field
228 # ---------------------------------------------------------------------------
229
230
231 class TestBranchField:
232 def test_branch_is_main_when_on_main(self, tmp_path: pathlib.Path) -> None:
233 repo = _make_repo(tmp_path, branch="main")
234 _add_commit(repo, {"a.py": b"a"}, branch="main")
235 data = _ls_json(repo)
236 assert data["branch"] == "main"
237
238 def test_branch_is_dev_when_on_dev(self, tmp_path: pathlib.Path) -> None:
239 repo = _make_repo(tmp_path, branch="dev")
240 _add_commit(repo, {"a.py": b"a"}, branch="dev")
241 data = _ls_json(repo)
242 assert data["branch"] == "dev"
243
244 def test_branch_is_none_when_explicit_commit_given(
245 self, tmp_path: pathlib.Path
246 ) -> None:
247 """When --commit is given explicitly, no branch resolution occurs."""
248 repo = _make_repo(tmp_path)
249 cid = _add_commit(repo, {"a.py": b"a"})
250 data = _ls_json(repo, "--commit", cid)
251 # branch is null when commit was specified directly, not via HEAD
252 assert data["branch"] is None
253
254
255 # ---------------------------------------------------------------------------
256 # IV path_prefix echoed
257 # ---------------------------------------------------------------------------
258
259
260 class TestPathPrefixEchoed:
261 def test_path_prefix_null_without_filter(self, tmp_path: pathlib.Path) -> None:
262 repo = _make_repo(tmp_path)
263 _add_commit(repo, {"a.py": b"a"})
264 data = _ls_json(repo)
265 assert data["path_prefix"] is None
266
267 def test_path_prefix_echoed_src(self, tmp_path: pathlib.Path) -> None:
268 repo = _make_repo(tmp_path)
269 _add_commit(repo, {"src/a.py": b"a"})
270 data = _ls_json(repo, "--path-prefix", "src/")
271 assert data["path_prefix"] == "src/"
272
273 def test_path_prefix_echoed_nested(self, tmp_path: pathlib.Path) -> None:
274 repo = _make_repo(tmp_path)
275 _add_commit(repo, {"a/b/c.py": b"c"})
276 data = _ls_json(repo, "--path-prefix", "a/b/")
277 assert data["path_prefix"] == "a/b/"
278
279
280 # ---------------------------------------------------------------------------
281 # V TypedDicts
282 # ---------------------------------------------------------------------------
283
284
285 class TestTypedDicts:
286 def test_ls_files_json_typed_dict_exists(self) -> None:
287 from muse.cli.commands.ls_files import _LsFilesJson # type: ignore[attr-defined]
288 assert _LsFilesJson is not None
289
290 def test_ls_files_error_json_typed_dict_exists(self) -> None:
291 from muse.cli.commands.ls_files import _LsFilesErrorJson # type: ignore[attr-defined]
292 assert _LsFilesErrorJson is not None
293
294 def test_ls_files_json_has_all_annotations(self) -> None:
295 from muse.cli.commands.ls_files import _LsFilesJson # type: ignore[attr-defined]
296 hints = _LsFilesJson.__annotations__
297 required = {"status", "error", "commit_id", "snapshot_id", "branch",
298 "path_prefix", "file_count", "files", "duration_ms", "exit_code"}
299 assert not (required - set(hints)), f"Missing: {required - set(hints)}"
300
301 def test_ls_files_error_json_has_all_annotations(self) -> None:
302 from muse.cli.commands.ls_files import _LsFilesErrorJson # type: ignore[attr-defined]
303 hints = _LsFilesErrorJson.__annotations__
304 assert not ({"status", "error", "exit_code"} - set(hints))
305
306
307 # ---------------------------------------------------------------------------
308 # VI Docstring
309 # ---------------------------------------------------------------------------
310
311
312 class TestDocstring:
313 def test_docstring_documents_status(self) -> None:
314 import muse.cli.commands.ls_files as m
315 assert '"status"' in (m.__doc__ or "")
316
317 def test_docstring_documents_branch(self) -> None:
318 import muse.cli.commands.ls_files as m
319 assert '"branch"' in (m.__doc__ or "")
320
321 def test_docstring_documents_path_prefix(self) -> None:
322 import muse.cli.commands.ls_files as m
323 assert '"path_prefix"' in (m.__doc__ or "")
324
325 def test_docstring_documents_duration_ms(self) -> None:
326 import muse.cli.commands.ls_files as m
327 assert "duration_ms" in (m.__doc__ or "")
328
329 def test_docstring_documents_exit_code(self) -> None:
330 import muse.cli.commands.ls_files as m
331 assert "exit_code" in (m.__doc__ or "")
332
333 def test_docstring_documents_error(self) -> None:
334 import muse.cli.commands.ls_files as m
335 assert '"error"' in (m.__doc__ or "")
336
337
338 # ---------------------------------------------------------------------------
339 # VII Data integrity
340 # ---------------------------------------------------------------------------
341
342
343 class TestDataIntegrity:
344 def test_object_ids_sha256_prefixed_in_json(self, tmp_path: pathlib.Path) -> None:
345 repo = _make_repo(tmp_path)
346 _add_commit(repo, {"a.py": b"content", "b.py": b"more"})
347 data = _ls_json(repo)
348 for f in data["files"]:
349 assert f["object_id"].startswith("sha256:"), (
350 f"object_id not sha256:-prefixed: {f['object_id']!r}"
351 )
352
353 def test_object_ids_sha256_prefixed_in_text(self, tmp_path: pathlib.Path) -> None:
354 repo = _make_repo(tmp_path)
355 _add_commit(repo, {"a.py": b"content"})
356 result = _ls(repo, "--format", "text")
357 assert result.exit_code == 0
358 for line in result.output.strip().splitlines():
359 oid = line.split("\t")[0]
360 assert oid.startswith("sha256:"), f"text OID not prefixed: {oid!r}"
361
362 def test_commit_id_matches_stored(self, tmp_path: pathlib.Path) -> None:
363 repo = _make_repo(tmp_path)
364 cid = _add_commit(repo, {"a.py": b"a"})
365 data = _ls_json(repo)
366 assert data["commit_id"] == cid
367
368 def test_files_sorted_alphabetically(self, tmp_path: pathlib.Path) -> None:
369 repo = _make_repo(tmp_path)
370 _add_commit(repo, {"z.py": b"z", "a.py": b"a", "m.py": b"m"})
371 data = _ls_json(repo)
372 paths = [f["path"] for f in data["files"]]
373 assert paths == sorted(paths)
374
375 def test_path_prefix_file_count_consistent(self, tmp_path: pathlib.Path) -> None:
376 repo = _make_repo(tmp_path)
377 _add_commit(repo, {"src/a.py": b"a", "src/b.py": b"b", "tests/c.py": b"c"})
378 data = _ls_json(repo, "--path-prefix", "src/")
379 assert data["file_count"] == len(data["files"]) == 2
380
381
382 # ---------------------------------------------------------------------------
383 # VIII No prose pollution in JSON mode
384 # ---------------------------------------------------------------------------
385
386
387 class TestNoProsePollution:
388 def test_success_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
389 repo = _make_repo(tmp_path)
390 _add_commit(repo, {"a.py": b"a"})
391 result = _ls(repo, "--json")
392 json.loads(result.output.strip()) # must not raise
393
394 def test_no_emoji_in_json_success_output(self, tmp_path: pathlib.Path) -> None:
395 repo = _make_repo(tmp_path)
396 _add_commit(repo, {"a.py": b"a"})
397 result = _ls(repo, "--json")
398 assert "❌" not in result.output
399 assert "✅" not in result.output
400
401 def test_no_emoji_in_json_error_output(self, tmp_path: pathlib.Path) -> None:
402 """Errors in --json mode must not emit prose emoji to stdout."""
403 repo = _make_repo(tmp_path) # no commits
404 result = _ls(repo, "--json")
405 assert "❌" not in result.output
406 data = json.loads(result.output.strip())
407 assert data["status"] == "error"
408
409 def test_error_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
410 repo = _make_repo(tmp_path)
411 result = _ls(repo, "--json")
412 json.loads(result.output.strip()) # must not raise
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago