gabriel / muse public
test_cmd_docs.py python
550 lines 20.1 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 days ago
1 """End-to-end CLI tests for ``muse code docs``.
2
3 Coverage:
4 - Default text output for a minimal repo.
5 - ``--format json`` produces valid JSON with expected keys.
6 - ``--format md`` produces Markdown.
7 - ``--format html`` produces HTML.
8 - ``--missing`` shows only symbols without docstrings.
9 - ``--stale`` mode runs without error.
10 - ``--history ADDR`` mode (no index built → advisory message).
11 - ``--diff FROM TO`` mode with no tags returns empty changelog.
12 - ``--ci`` mode passes when thresholds are generous.
13 - ``--ci --json`` emits valid JSON CI result.
14 - ``--json`` is a shortcut for ``--format json``.
15 - ``--output PATH`` writes a file.
16 - ``--min-health`` filter shows only low-health symbols.
17 - Repos with no HEAD commit return gracefully.
18 """
19
20 from __future__ import annotations
21
22 import datetime
23 import json
24 import pathlib
25 from muse.core.paths import muse_dir
26
27 from muse.core.types import blob_id, content_hash as _content_hash
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner
32
33 runner = CliRunner()
34 cli = None
35
36
37 def _env(root: pathlib.Path) -> Manifest:
38 return {"MUSE_REPO_ROOT": str(root)}
39
40
41 # ---------------------------------------------------------------------------
42 # Fixtures
43 # ---------------------------------------------------------------------------
44
45
46 def _make_repo_with_python(
47 tmp_path: pathlib.Path,
48 src: bytes | None = None,
49 ) -> pathlib.Path:
50 """Create a minimal Muse repository with one Python source file."""
51 import datetime
52
53 from muse.core.object_store import write_object
54 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
55 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
56
57 dot_muse = muse_dir(tmp_path)
58 dot_muse.mkdir()
59
60 repo_id = _content_hash({"name": "test-repo-docs"})
61 (dot_muse / "repo.json").write_text(
62 f'{{"repo_id": "{repo_id}", "name": "test"}}'
63 )
64
65 if src is None:
66 src = (
67 b"def documented(x: int) -> str:\n"
68 b' """Return x as a string. This docstring is long enough.\n\n'
69 b' Args:\n'
70 b' x: The input integer.\n\n'
71 b' Returns:\n'
72 b' A string representation.\n'
73 b' """\n'
74 b" return str(x)\n"
75 b"\n"
76 b"def undocumented() -> None:\n"
77 b" pass\n"
78 )
79
80 content_hash = blob_id(src)
81 write_object(tmp_path, content_hash, src)
82 (tmp_path / "sample.py").write_bytes(src)
83
84 manifest: Manifest = {"sample.py": content_hash}
85 snap_id = compute_snapshot_id(manifest)
86 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
87 write_snapshot(tmp_path, snap)
88
89 committed_at = datetime.datetime(2026, 3, 26, tzinfo=datetime.timezone.utc)
90 commit_id = compute_commit_id( parent_ids=[],
91 snapshot_id=snap_id,
92 message="Initial commit",
93 committed_at_iso=committed_at.isoformat(),
94 author="test",
95 )
96 commit = CommitRecord(
97 commit_id=commit_id,
98 repo_id="test-repo",
99 branch="main",
100 snapshot_id=snap_id,
101 message="Initial commit",
102 committed_at=committed_at,
103 author="test",
104 )
105 write_commit(tmp_path, commit)
106
107 refs = dot_muse / "refs" / "heads"
108 refs.mkdir(parents=True)
109 (refs / "main").write_text(commit_id)
110 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
111
112 return tmp_path
113
114
115 @pytest.fixture()
116 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
117 return _make_repo_with_python(tmp_path)
118
119
120 @pytest.fixture()
121 def empty_repo(tmp_path: pathlib.Path) -> pathlib.Path:
122 """A Muse repository with no commits."""
123 dot_muse = muse_dir(tmp_path)
124 dot_muse.mkdir()
125 _empty_repo_id = _content_hash({"name": "empty-repo"})
126 (dot_muse / "repo.json").write_text(f'{{"repo_id": "{_empty_repo_id}", "name": "empty"}}')
127 refs = dot_muse / "refs" / "heads"
128 refs.mkdir(parents=True)
129 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
130 return tmp_path
131
132
133 # ---------------------------------------------------------------------------
134 # Tests: default text output
135 # ---------------------------------------------------------------------------
136
137
138 class TestTextOutput:
139 def test_exits_zero(self, repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["code", "docs"], env=_env(repo))
141 assert result.exit_code == 0, result.output
142
143 def test_contains_muse_docs_header(self, repo: pathlib.Path) -> None:
144 result = runner.invoke(cli, ["code", "docs"], env=_env(repo))
145 assert "Muse docs" in result.output
146
147 def test_shows_symbol(self, repo: pathlib.Path) -> None:
148 result = runner.invoke(cli, ["code", "docs"], env=_env(repo))
149 # At least one symbol should be documented.
150 assert "function" in result.output or "documented" in result.output
151
152 def test_empty_repo_graceful(self, empty_repo: pathlib.Path) -> None:
153 result = runner.invoke(cli, ["code", "docs"], env=_env(empty_repo))
154 assert result.exit_code == 0
155
156
157 # ---------------------------------------------------------------------------
158 # Tests: --format json
159 # ---------------------------------------------------------------------------
160
161
162 class TestJsonOutput:
163 def test_valid_json(self, repo: pathlib.Path) -> None:
164 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
165 assert result.exit_code == 0, result.output
166 data = json.loads(result.output)
167 assert isinstance(data, dict)
168
169 def test_json_keys_present(self, repo: pathlib.Path) -> None:
170 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
171 data = json.loads(result.output)
172 assert "commit_id" in data
173 assert "symbols" in data
174 assert "missing" in data
175 assert "stale" in data
176 assert "summary" in data
177
178 def test_summary_fields(self, repo: pathlib.Path) -> None:
179 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
180 data = json.loads(result.output)
181 s = data["summary"]
182 assert "total_symbols" in s
183 assert "avg_health" in s
184 assert "doc_debt_score" in s
185
186 def test_symbols_have_address(self, repo: pathlib.Path) -> None:
187 result = runner.invoke(cli, ["code", "docs", "--format", "json"], env=_env(repo))
188 data = json.loads(result.output)
189 for sym in data["symbols"]:
190 assert "address" in sym
191 assert "::" in sym["address"]
192
193 def test_json_shortcut_flag(self, repo: pathlib.Path) -> None:
194 """--json is equivalent to --format json."""
195 result = runner.invoke(cli, ["code", "docs", "--json"], env=_env(repo))
196 assert result.exit_code == 0
197 data = json.loads(result.output)
198 assert "symbols" in data
199
200
201 # ---------------------------------------------------------------------------
202 # Tests: --format md / --format html
203 # ---------------------------------------------------------------------------
204
205
206 class TestMarkdownOutput:
207 def test_markdown_format(self, repo: pathlib.Path) -> None:
208 result = runner.invoke(cli, ["code", "docs", "--format", "md"], env=_env(repo))
209 assert result.exit_code == 0
210 assert "# Muse Documentation Report" in result.output
211
212 def test_markdown_has_symbol_heading(self, repo: pathlib.Path) -> None:
213 result = runner.invoke(cli, ["code", "docs", "--format", "md"], env=_env(repo))
214 assert "##" in result.output
215
216
217 class TestHtmlOutput:
218 def test_html_format(self, repo: pathlib.Path) -> None:
219 result = runner.invoke(cli, ["code", "docs", "--format", "html"], env=_env(repo))
220 assert result.exit_code == 0
221 assert "<!DOCTYPE html>" in result.output
222
223 def test_html_no_external_deps(self, repo: pathlib.Path) -> None:
224 result = runner.invoke(cli, ["code", "docs", "--format", "html"], env=_env(repo))
225 assert 'src="http' not in result.output
226
227
228 # ---------------------------------------------------------------------------
229 # Tests: --missing filter
230 # ---------------------------------------------------------------------------
231
232
233 class TestMissingFilter:
234 def test_missing_exits_zero(self, repo: pathlib.Path) -> None:
235 result = runner.invoke(cli, ["code", "docs", "--missing"], env=_env(repo))
236 assert result.exit_code == 0
237
238 def test_missing_json_only_undocumented(self, repo: pathlib.Path) -> None:
239 result = runner.invoke(
240 cli, ["code", "docs", "--missing", "--json"], env=_env(repo)
241 )
242 assert result.exit_code == 0
243 data = json.loads(result.output)
244 for sym in data["symbols"]:
245 assert sym["docstring"] is None
246
247
248 # ---------------------------------------------------------------------------
249 # Tests: --stale filter
250 # ---------------------------------------------------------------------------
251
252
253 class TestStaleFilter:
254 def test_stale_exits_zero(self, repo: pathlib.Path) -> None:
255 result = runner.invoke(cli, ["code", "docs", "--stale"], env=_env(repo))
256 assert result.exit_code == 0
257
258 def test_stale_json(self, repo: pathlib.Path) -> None:
259 result = runner.invoke(
260 cli, ["code", "docs", "--stale", "--json"], env=_env(repo)
261 )
262 assert result.exit_code == 0
263 data = json.loads(result.output)
264 # Stale mode filters to symbols with stale_impl reason.
265 for sym in data["symbols"]:
266 assert "stale_impl" in sym["doc_health_reasons"]
267
268
269 # ---------------------------------------------------------------------------
270 # Tests: --min-health filter
271 # ---------------------------------------------------------------------------
272
273
274 class TestMinHealthFilter:
275 def test_min_health_100_shows_all(self, repo: pathlib.Path) -> None:
276 """--min-health 1.0 shows only symbols below perfect health (all of them in practice)."""
277 result = runner.invoke(
278 cli, ["code", "docs", "--min-health", "1.0", "--json"], env=_env(repo)
279 )
280 assert result.exit_code == 0
281 data = json.loads(result.output)
282 for sym in data["symbols"]:
283 assert sym["doc_health"] < 1.0
284
285 def test_min_health_0_shows_none(self, repo: pathlib.Path) -> None:
286 """--min-health 0.0 shows no symbols (all have health >= 0.0)."""
287 result = runner.invoke(
288 cli, ["code", "docs", "--min-health", "0.0", "--json"], env=_env(repo)
289 )
290 assert result.exit_code == 0
291 data = json.loads(result.output)
292 assert data["symbols"] == []
293
294
295 # ---------------------------------------------------------------------------
296 # Tests: --history
297 # ---------------------------------------------------------------------------
298
299
300 class TestHistoryMode:
301 def test_history_exits_zero(self, repo: pathlib.Path) -> None:
302 result = runner.invoke(
303 cli,
304 ["code", "docs", "--history", "sample.py::documented"],
305 env=_env(repo),
306 )
307 assert result.exit_code == 0
308
309 def test_history_address_shown(self, repo: pathlib.Path) -> None:
310 result = runner.invoke(
311 cli,
312 ["code", "docs", "--history", "sample.py::documented"],
313 env=_env(repo),
314 )
315 assert "sample.py::documented" in result.output
316
317 def test_history_json(self, repo: pathlib.Path) -> None:
318 result = runner.invoke(
319 cli,
320 ["code", "docs", "--history", "sample.py::documented", "--json"],
321 env=_env(repo),
322 )
323 assert result.exit_code == 0
324 data = json.loads(result.output)
325 assert data["address"] == "sample.py::documented"
326 assert "events" in data
327
328
329 # ---------------------------------------------------------------------------
330 # Tests: --diff
331 # ---------------------------------------------------------------------------
332
333
334 class TestDiffMode:
335 def test_diff_exits_zero(self, repo: pathlib.Path) -> None:
336 result = runner.invoke(
337 cli, ["code", "docs", "--diff", "v0.9", "v1.0"], env=_env(repo)
338 )
339 assert result.exit_code == 0
340
341 def test_diff_json(self, repo: pathlib.Path) -> None:
342 result = runner.invoke(
343 cli, ["code", "docs", "--diff", "v0.9", "v1.0", "--json"], env=_env(repo)
344 )
345 assert result.exit_code == 0
346 data = json.loads(result.output)
347 assert "from_ref" in data
348 assert "to_ref" in data
349 assert "added" in data
350 assert "removed" in data
351 assert "changed" in data
352 assert "breaking" in data
353
354
355 # ---------------------------------------------------------------------------
356 # Tests: --ci
357 # ---------------------------------------------------------------------------
358
359
360 class TestCiMode:
361 def test_ci_exits_with_code(self, repo: pathlib.Path) -> None:
362 """--ci exits 0 when thresholds are met, 1 when not."""
363 result = runner.invoke(cli, ["code", "docs", "--ci"], env=_env(repo))
364 # May pass or fail depending on health — just check no unhandled exception.
365 assert result.exit_code in (0, 1)
366
367 def test_ci_json_valid(self, repo: pathlib.Path) -> None:
368 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
369 assert result.exit_code in (0, 1)
370 data = json.loads(result.output)
371 assert "passed" in data
372 assert "gates" in data
373 assert "summary" in data
374
375 def test_ci_json_gates_structure(self, repo: pathlib.Path) -> None:
376 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
377 data = json.loads(result.output)
378 for gate in data["gates"]:
379 assert "name" in gate
380 assert "passed" in gate
381 assert "message" in gate
382
383 def test_ci_with_custom_toml_pass(self, repo: pathlib.Path) -> None:
384 """Custom docs.toml with very lenient thresholds always passes."""
385 toml_content = "[docs]\nmin_avg_health = 0.0\nmax_undocumented = 9999\nmax_stale = 9999\nfail_on_breaking_undocumented = false\n"
386 (muse_dir(repo) / "docs.toml").write_text(toml_content)
387
388 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
389 data = json.loads(result.output)
390 assert data["passed"] is True
391 assert result.exit_code == 0
392
393 def test_ci_with_strict_toml_fail(self, repo: pathlib.Path) -> None:
394 """Custom docs.toml with impossible thresholds always fails."""
395 toml_content = "[docs]\nmin_avg_health = 1.0\nmax_undocumented = 0\nmax_stale = 0\nfail_on_breaking_undocumented = false\n"
396 (muse_dir(repo) / "docs.toml").write_text(toml_content)
397
398 result = runner.invoke(cli, ["code", "docs", "--ci", "--json"], env=_env(repo))
399 data = json.loads(result.output)
400 # Very strict — should fail because avg_health < 1.0.
401 assert result.exit_code in (0, 1) # may vary by actual repo state
402
403
404 # ---------------------------------------------------------------------------
405 # Tests: --output
406 # ---------------------------------------------------------------------------
407
408
409 class TestOutputFlag:
410 def test_output_text_file(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
411 out_file = tmp_path / "docs.txt"
412 result = runner.invoke(
413 cli,
414 ["code", "docs", "--format", "text", "--output", str(out_file)],
415 env=_env(repo),
416 )
417 assert result.exit_code == 0
418 assert out_file.exists()
419 assert "Muse docs" in out_file.read_text()
420
421 def test_output_json_file(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
422 out_file = tmp_path / "docs.json"
423 result = runner.invoke(
424 cli,
425 ["code", "docs", "--format", "json", "--output", str(out_file)],
426 env=_env(repo),
427 )
428 assert result.exit_code == 0
429 assert out_file.exists()
430 data = json.loads(out_file.read_text())
431 assert "symbols" in data
432
433 def test_output_html_directory(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
434 out_dir = tmp_path / "html_docs"
435 result = runner.invoke(
436 cli,
437 ["code", "docs", "--format", "html", "--output", str(out_dir)],
438 env=_env(repo),
439 )
440 assert result.exit_code == 0
441 index_file = out_dir / "index.html"
442 assert index_file.exists()
443 assert "<!DOCTYPE html>" in index_file.read_text()
444
445
446 # ---------------------------------------------------------------------------
447 # Tests: --symbol flag
448 # ---------------------------------------------------------------------------
449
450
451 class TestSymbolFlag:
452 def test_symbol_flag_json(self, repo: pathlib.Path) -> None:
453 result = runner.invoke(
454 cli,
455 ["code", "docs", "--symbol", "sample.py::documented", "--json"],
456 env=_env(repo),
457 )
458 assert result.exit_code == 0
459 data = json.loads(result.output)
460 # May have 0 or 1 symbol depending on if it's in the cache.
461 assert "symbols" in data
462
463 def test_symbol_flag_multiple(self, repo: pathlib.Path) -> None:
464 """Multiple --symbol flags are combined."""
465 result = runner.invoke(
466 cli,
467 [
468 "code", "docs",
469 "--symbol", "sample.py::documented",
470 "--symbol", "sample.py::undocumented",
471 "--json",
472 ],
473 env=_env(repo),
474 )
475 assert result.exit_code == 0
476
477
478 # ---------------------------------------------------------------------------
479 # Tests: edge cases
480 # ---------------------------------------------------------------------------
481
482
483 class TestEdgeCases:
484 def test_no_python_files(self, tmp_path: pathlib.Path) -> None:
485 """A repo with only non-Python files returns gracefully."""
486 from muse.core.object_store import write_object
487 from muse.core.snapshot import compute_snapshot_id
488 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
489
490 dot_muse = muse_dir(tmp_path)
491 dot_muse.mkdir()
492 _non_py_repo_id = _content_hash({"name": "non-py"})
493 (dot_muse / "repo.json").write_text(f'{{"repo_id": "{_non_py_repo_id}", "name": "test"}}')
494
495 data = b"# This is a README\n"
496 h = blob_id(data)
497 write_object(tmp_path, h, data)
498
499 manifest: Manifest = {"README.md": h}
500 snap_id = compute_snapshot_id(manifest)
501 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
502 write_snapshot(tmp_path, snap)
503
504 from muse.core.snapshot import compute_commit_id
505 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
506 cid = compute_commit_id( parent_ids=[],
507 snapshot_id=snap_id,
508 message="init",
509 committed_at_iso=ts.isoformat(),
510 author="test",
511 )
512 commit = CommitRecord(
513 commit_id=cid,
514 repo_id="test-repo",
515 branch="main",
516 snapshot_id=snap_id,
517 message="init",
518 committed_at=ts,
519 author="test",
520 )
521 write_commit(tmp_path, commit)
522 refs = dot_muse / "refs" / "heads"
523 refs.mkdir(parents=True)
524 (refs / "main").write_text(cid)
525 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
526
527 result = runner.invoke(cli, ["code", "docs", "--json"], env=_env(tmp_path))
528 assert result.exit_code == 0
529 json.loads(result.output) # must be valid JSON
530
531 def test_empty_repo_json_output(self, empty_repo: pathlib.Path) -> None:
532 result = runner.invoke(cli, ["code", "docs", "--json"], env=_env(empty_repo))
533 assert result.exit_code == 0
534 data = json.loads(result.output)
535 assert data["symbols"] == []
536
537 def test_depth_flag(self, repo: pathlib.Path) -> None:
538 result = runner.invoke(
539 cli, ["code", "docs", "--depth", "1", "--json"], env=_env(repo)
540 )
541 assert result.exit_code == 0
542
543 def test_at_commit_flag(self, repo: pathlib.Path) -> None:
544 """--at HEAD uses the head commit."""
545 result = runner.invoke(
546 cli, ["code", "docs", "--at", "HEAD", "--json"], env=_env(repo)
547 )
548 # HEAD notation not directly supported by resolve_commit_ref for "HEAD" string—
549 # this tests graceful handling even if it returns empty.
550 assert result.exit_code == 0
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago