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