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