Coverage for tests / unit / cli_utils / commands / test_doctor_command.py: 99%
181 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tests for the ``lintro doctor`` CLI command."""
3from __future__ import annotations
5import json
6import subprocess
7from typing import Any
8from unittest.mock import MagicMock, patch
10import pytest
11from assertpy import assert_that
12from click.testing import CliRunner
14from lintro.cli_utils.commands.doctor import (
15 ToolCheckResult,
16 _check_tool,
17 _compare_versions,
18 _generate_markdown_report,
19 _output_json,
20 doctor_command,
21)
22from lintro.enums.install_context import InstallContext, PackageManager
23from lintro.enums.tool_status import ToolStatus
24from lintro.tools.core.install_context import RuntimeContext
25from lintro.tools.core.install_strategies.environment import InstallEnvironment
26from lintro.tools.core.tool_registry import ManifestTool
28# ── Helpers ──────────────────────────────────────────────────────────
31def _make_tool(
32 name: str = "ruff",
33 version: str = "0.14.0",
34 *,
35 install_type: str = "pip",
36 tier: str = "tools",
37 category: str = "bundled",
38 version_command: tuple[str, ...] | None = None,
39) -> ManifestTool:
40 """Build a ManifestTool for testing."""
41 return ManifestTool(
42 name=name,
43 version=version,
44 install_type=install_type,
45 tier=tier,
46 category=category,
47 version_command=(
48 (name, "--version") if version_command is None else version_command
49 ),
50 languages=("python",),
51 tags=("linter",),
52 )
55def _make_context(*, has_brew: bool = False) -> RuntimeContext:
56 """Build a RuntimeContext for testing."""
57 managers = frozenset(
58 {
59 PackageManager.UV,
60 PackageManager.PIP,
61 PackageManager.NPM,
62 PackageManager.CARGO,
63 PackageManager.RUSTUP,
64 },
65 )
66 if has_brew:
67 managers = managers | {PackageManager.BREW}
68 return RuntimeContext(
69 install_context=InstallContext.PIP,
70 platform_label="Linux x86_64",
71 environment=InstallEnvironment(
72 install_context=InstallContext.PIP,
73 available_managers=managers,
74 ),
75 is_ci=False,
76 )
79# ── _compare_versions ────────────────────────────────────────────────
82@pytest.mark.parametrize(
83 ("installed", "expected", "want"),
84 [
85 ("1.2.3", "1.0.0", ToolStatus.OK),
86 ("1.0.0", "1.0.0", ToolStatus.OK),
87 ("1.0.0", "1.2.0", ToolStatus.OUTDATED),
88 ("0.14.0", "0.15.0", ToolStatus.OUTDATED),
89 ("invalid", "1.0.0", ToolStatus.UNKNOWN),
90 ],
91 ids=["above", "equal", "below", "minor_below", "invalid"],
92)
93def test_compare_versions(installed: str, expected: str, want: ToolStatus) -> None:
94 """Compare two version strings and return the correct ToolStatus."""
95 assert_that(_compare_versions(installed, expected)).is_equal_to(want)
98# ── _check_tool ──────────────────────────────────────────────────────
101def test_check_tool_ok() -> None:
102 """Tool found in PATH with version meeting minimum."""
103 tool = _make_tool(version="0.14.0")
104 ctx = _make_context()
106 with (
107 patch("shutil.which", return_value="/usr/bin/ruff"),
108 patch("subprocess.run") as mock_run,
109 ):
110 mock_run.return_value = MagicMock(
111 returncode=0,
112 stdout="ruff 0.14.4",
113 stderr="",
114 )
115 result = _check_tool(tool, ctx)
117 assert_that(result.status).is_equal_to(ToolStatus.OK)
118 assert_that(result.installed_version).is_equal_to("0.14.4")
119 assert_that(result.path).is_equal_to("/usr/bin/ruff")
122def test_check_tool_outdated() -> None:
123 """Tool found but version below minimum."""
124 tool = _make_tool(version="1.0.0")
125 ctx = _make_context()
127 with (
128 patch("shutil.which", return_value="/usr/bin/ruff"),
129 patch("subprocess.run") as mock_run,
130 ):
131 mock_run.return_value = MagicMock(
132 returncode=0,
133 stdout="ruff 0.5.0",
134 stderr="",
135 )
136 result = _check_tool(tool, ctx)
138 assert_that(result.status).is_equal_to(ToolStatus.OUTDATED)
139 assert_that(result.installed_version).is_equal_to("0.5.0")
142def test_check_tool_missing_not_in_path() -> None:
143 """Tool executable not found in PATH."""
144 tool = _make_tool()
145 ctx = _make_context()
147 with patch("shutil.which", return_value=None):
148 result = _check_tool(tool, ctx)
150 assert_that(result.status).is_equal_to(ToolStatus.MISSING)
151 assert_that(result.error).is_equal_to("not_in_path")
154def test_check_tool_missing_command_failed() -> None:
155 """Tool found but version command exits non-zero."""
156 tool = _make_tool()
157 ctx = _make_context()
159 with (
160 patch("shutil.which", return_value="/usr/bin/ruff"),
161 patch("subprocess.run") as mock_run,
162 ):
163 mock_run.return_value = MagicMock(
164 returncode=1,
165 stdout="",
166 stderr="error",
167 )
168 result = _check_tool(tool, ctx)
170 assert_that(result.status).is_equal_to(ToolStatus.MISSING)
171 assert_that(result.error).is_equal_to("command_failed")
174def test_check_tool_missing_timeout() -> None:
175 """Tool version command times out."""
176 tool = _make_tool()
177 ctx = _make_context()
179 with (
180 patch("shutil.which", return_value="/usr/bin/ruff"),
181 patch(
182 "subprocess.run",
183 side_effect=subprocess.TimeoutExpired(cmd=["ruff"], timeout=10),
184 ),
185 ):
186 result = _check_tool(tool, ctx)
188 assert_that(result.status).is_equal_to(ToolStatus.MISSING)
189 assert_that(result.error).is_equal_to("timeout")
192def test_check_tool_missing_os_error() -> None:
193 """Tool version command raises OSError."""
194 tool = _make_tool()
195 ctx = _make_context()
197 with (
198 patch("shutil.which", return_value="/usr/bin/ruff"),
199 patch("subprocess.run", side_effect=OSError("exec format error")),
200 ):
201 result = _check_tool(tool, ctx)
203 assert_that(result.status).is_equal_to(ToolStatus.MISSING)
204 assert_that(result.error).is_equal_to("os_error")
207def test_check_tool_unknown_no_version() -> None:
208 """Tool runs but output has no parseable version."""
209 tool = _make_tool()
210 ctx = _make_context()
212 with (
213 patch("shutil.which", return_value="/usr/bin/ruff"),
214 patch("subprocess.run") as mock_run,
215 ):
216 mock_run.return_value = MagicMock(
217 returncode=0,
218 stdout="no version here",
219 stderr="",
220 )
221 result = _check_tool(tool, ctx)
223 assert_that(result.status).is_equal_to(ToolStatus.UNKNOWN)
224 assert_that(result.error).is_equal_to("no_version")
227def test_check_tool_no_version_command() -> None:
228 """Tool has no version_command defined."""
229 tool = _make_tool(version_command=())
230 ctx = _make_context()
232 result = _check_tool(tool, ctx)
234 assert_that(result.status).is_equal_to(ToolStatus.MISSING)
235 assert_that(result.error).is_equal_to("no_command")
238def test_check_tool_upgrade_hint_populated() -> None:
239 """Both install_hint and upgrade_hint are populated."""
240 tool = _make_tool()
241 ctx = _make_context()
243 with (
244 patch("shutil.which", return_value="/usr/bin/ruff"),
245 patch("subprocess.run") as mock_run,
246 ):
247 mock_run.return_value = MagicMock(
248 returncode=0,
249 stdout="ruff 0.14.4",
250 stderr="",
251 )
252 result = _check_tool(tool, ctx)
254 assert_that(result.install_hint).is_not_empty()
255 assert_that(result.upgrade_hint).is_not_empty()
258# ── _output_json ─────────────────────────────────────────────────────
261def test_output_json_produces_valid_json() -> None:
262 """JSON output is valid and contains expected top-level keys."""
263 tool = _make_tool()
264 result = ToolCheckResult(
265 tool=tool,
266 status=ToolStatus.OK,
267 installed_version="0.14.4",
268 install_hint="uv pip install ruff>=0.14.0",
269 upgrade_hint="uv pip install --upgrade ruff>=0.14.0",
270 )
271 ctx = _make_context()
273 from io import StringIO
275 output = StringIO()
276 with patch("click.echo", side_effect=output.write):
277 _output_json([result], ctx, None, 1, 0, 0, 0)
279 data = json.loads(output.getvalue())
280 assert_that(data).contains_key("context", "tools", "issues", "summary")
281 assert_that(data["summary"]["ok"]).is_equal_to(1)
284def test_output_json_includes_unknown_in_issues() -> None:
285 """Unknown production tools appear in the issues list."""
286 tool = _make_tool()
287 result = ToolCheckResult(
288 tool=tool,
289 status=ToolStatus.UNKNOWN,
290 error="no_version",
291 install_hint="uv pip install ruff>=0.14.0",
292 upgrade_hint="uv pip install --upgrade ruff>=0.14.0",
293 )
294 ctx = _make_context()
296 from io import StringIO
298 output = StringIO()
299 with patch("click.echo", side_effect=output.write):
300 _output_json([result], ctx, None, 0, 0, 0, 1)
302 data = json.loads(output.getvalue())
303 assert_that(data["issues"]).is_length(1)
304 assert_that(data["issues"][0]["tool"]).is_equal_to("ruff")
307# ── _generate_markdown_report ────────────────────────────────────────
310def test_markdown_report_contains_headers() -> None:
311 """Markdown report includes Environment and Tool Versions sections."""
312 env = MagicMock()
313 env.lintro.version = "0.58.2"
314 env.system.platform_name = "macOS"
315 env.system.architecture = "arm64"
316 env.python.version = "3.13.0"
317 env.node = None
318 env.rust = None
320 ctx = _make_context()
321 tool = _make_tool()
322 results_by_cat = {
323 "bundled": [
324 ToolCheckResult(
325 tool=tool,
326 status=ToolStatus.OK,
327 installed_version="0.14.4",
328 ),
329 ],
330 }
332 md = _generate_markdown_report(env, ctx, results_by_cat, [])
333 assert_that(md).contains("### Environment")
334 assert_that(md).contains("### Tool Versions")
335 assert_that(md).contains("ruff")
338# ── CLI invocation ───────────────────────────────────────────────────
341def _patch_doctor_deps() -> tuple[Any, Any]:
342 """Patch ToolRegistry.load and RuntimeContext.detect for CLI tests.
344 Returns:
345 Tuple of two context-manager patches.
346 """
347 tool = _make_tool()
348 registry = MagicMock()
349 registry.all_tools = MagicMock(return_value=[tool])
350 registry.__contains__ = lambda self, name: name == "ruff"
351 registry.get.return_value = tool
352 ctx = _make_context()
354 return (
355 patch(
356 "lintro.cli_utils.commands.doctor.ToolRegistry.load",
357 return_value=registry,
358 ),
359 patch(
360 "lintro.cli_utils.commands.doctor.RuntimeContext.detect",
361 return_value=ctx,
362 ),
363 )
366def test_doctor_all_ok_exit_0() -> None:
367 """Exit code 0 when all tools pass."""
368 runner = CliRunner()
369 p1, p2 = _patch_doctor_deps()
371 with (
372 p1,
373 p2,
374 patch("subprocess.run") as mock_run,
375 patch("shutil.which", return_value="/usr/bin/ruff"),
376 ):
377 mock_run.return_value = MagicMock(returncode=0, stdout="ruff 0.14.4", stderr="")
378 result = runner.invoke(doctor_command, [])
380 assert_that(result.exit_code).is_equal_to(0)
383def test_doctor_missing_tool_exit_1() -> None:
384 """Exit code 1 when a tool is missing."""
385 runner = CliRunner()
386 p1, p2 = _patch_doctor_deps()
388 with p1, p2, patch("shutil.which", return_value=None):
389 result = runner.invoke(doctor_command, [])
391 assert_that(result.exit_code).is_equal_to(1)
394def test_doctor_json_output_valid() -> None:
395 """--json produces valid JSON."""
396 runner = CliRunner()
397 p1, p2 = _patch_doctor_deps()
399 with (
400 p1,
401 p2,
402 patch("subprocess.run") as mock_run,
403 patch("shutil.which", return_value="/usr/bin/ruff"),
404 patch(
405 "lintro.cli_utils.commands.doctor.collect_full_environment",
406 return_value=None,
407 ),
408 ):
409 mock_run.return_value = MagicMock(returncode=0, stdout="ruff 0.14.4", stderr="")
410 result = runner.invoke(doctor_command, ["--json"])
412 data = json.loads(result.output)
413 assert_that(data).contains_key("tools", "summary")
416def test_doctor_fix_incompatible_with_json() -> None:
417 """--fix --json raises a usage error."""
418 runner = CliRunner()
419 p1, p2 = _patch_doctor_deps()
421 with (
422 p1,
423 p2,
424 patch("subprocess.run") as mock_run,
425 patch("shutil.which", return_value="/usr/bin/ruff"),
426 patch(
427 "lintro.cli_utils.commands.doctor.collect_full_environment",
428 return_value=MagicMock(),
429 ),
430 ):
431 mock_run.return_value = MagicMock(returncode=0, stdout="ruff 0.14.4", stderr="")
432 result = runner.invoke(doctor_command, ["--fix", "--json"])
434 assert_that(result.exit_code).is_not_equal_to(0)
435 assert_that(result.output).contains("--fix cannot be combined")
438def test_doctor_tools_filter_known_tool() -> None:
439 """--tools with a known tool name succeeds."""
440 runner = CliRunner()
441 p1, p2 = _patch_doctor_deps()
443 with (
444 p1,
445 p2,
446 patch("subprocess.run") as mock_run,
447 patch("shutil.which", return_value="/usr/bin/ruff"),
448 ):
449 mock_run.return_value = MagicMock(returncode=0, stdout="ruff 0.14.4", stderr="")
450 result = runner.invoke(doctor_command, ["--tools", "ruff"])
452 assert_that(result.exit_code).is_equal_to(0)
453 assert_that(result.output).contains("ruff")
456def test_doctor_unknown_tool_name_exit_1() -> None:
457 """--tools with unknown name prints error and exits 1."""
458 runner = CliRunner()
459 p1, p2 = _patch_doctor_deps()
461 with p1, p2:
462 result = runner.invoke(doctor_command, ["--tools", "nonexistent"])
464 assert_that(result.exit_code).is_equal_to(1)
465 assert_that(result.output).contains("Unknown tools")