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

1"""Tests for the ``lintro doctor`` CLI command.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import subprocess 

7from typing import Any 

8from unittest.mock import MagicMock, patch 

9 

10import pytest 

11from assertpy import assert_that 

12from click.testing import CliRunner 

13 

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 

27 

28# ── Helpers ────────────────────────────────────────────────────────── 

29 

30 

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 ) 

53 

54 

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 ) 

77 

78 

79# ── _compare_versions ──────────────────────────────────────────────── 

80 

81 

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) 

96 

97 

98# ── _check_tool ────────────────────────────────────────────────────── 

99 

100 

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() 

105 

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) 

116 

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") 

120 

121 

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() 

126 

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) 

137 

138 assert_that(result.status).is_equal_to(ToolStatus.OUTDATED) 

139 assert_that(result.installed_version).is_equal_to("0.5.0") 

140 

141 

142def test_check_tool_missing_not_in_path() -> None: 

143 """Tool executable not found in PATH.""" 

144 tool = _make_tool() 

145 ctx = _make_context() 

146 

147 with patch("shutil.which", return_value=None): 

148 result = _check_tool(tool, ctx) 

149 

150 assert_that(result.status).is_equal_to(ToolStatus.MISSING) 

151 assert_that(result.error).is_equal_to("not_in_path") 

152 

153 

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() 

158 

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) 

169 

170 assert_that(result.status).is_equal_to(ToolStatus.MISSING) 

171 assert_that(result.error).is_equal_to("command_failed") 

172 

173 

174def test_check_tool_missing_timeout() -> None: 

175 """Tool version command times out.""" 

176 tool = _make_tool() 

177 ctx = _make_context() 

178 

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) 

187 

188 assert_that(result.status).is_equal_to(ToolStatus.MISSING) 

189 assert_that(result.error).is_equal_to("timeout") 

190 

191 

192def test_check_tool_missing_os_error() -> None: 

193 """Tool version command raises OSError.""" 

194 tool = _make_tool() 

195 ctx = _make_context() 

196 

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) 

202 

203 assert_that(result.status).is_equal_to(ToolStatus.MISSING) 

204 assert_that(result.error).is_equal_to("os_error") 

205 

206 

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() 

211 

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) 

222 

223 assert_that(result.status).is_equal_to(ToolStatus.UNKNOWN) 

224 assert_that(result.error).is_equal_to("no_version") 

225 

226 

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() 

231 

232 result = _check_tool(tool, ctx) 

233 

234 assert_that(result.status).is_equal_to(ToolStatus.MISSING) 

235 assert_that(result.error).is_equal_to("no_command") 

236 

237 

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() 

242 

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) 

253 

254 assert_that(result.install_hint).is_not_empty() 

255 assert_that(result.upgrade_hint).is_not_empty() 

256 

257 

258# ── _output_json ───────────────────────────────────────────────────── 

259 

260 

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() 

272 

273 from io import StringIO 

274 

275 output = StringIO() 

276 with patch("click.echo", side_effect=output.write): 

277 _output_json([result], ctx, None, 1, 0, 0, 0) 

278 

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) 

282 

283 

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() 

295 

296 from io import StringIO 

297 

298 output = StringIO() 

299 with patch("click.echo", side_effect=output.write): 

300 _output_json([result], ctx, None, 0, 0, 0, 1) 

301 

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") 

305 

306 

307# ── _generate_markdown_report ──────────────────────────────────────── 

308 

309 

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 

319 

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 } 

331 

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") 

336 

337 

338# ── CLI invocation ─────────────────────────────────────────────────── 

339 

340 

341def _patch_doctor_deps() -> tuple[Any, Any]: 

342 """Patch ToolRegistry.load and RuntimeContext.detect for CLI tests. 

343 

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() 

353 

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 ) 

364 

365 

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() 

370 

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, []) 

379 

380 assert_that(result.exit_code).is_equal_to(0) 

381 

382 

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() 

387 

388 with p1, p2, patch("shutil.which", return_value=None): 

389 result = runner.invoke(doctor_command, []) 

390 

391 assert_that(result.exit_code).is_equal_to(1) 

392 

393 

394def test_doctor_json_output_valid() -> None: 

395 """--json produces valid JSON.""" 

396 runner = CliRunner() 

397 p1, p2 = _patch_doctor_deps() 

398 

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"]) 

411 

412 data = json.loads(result.output) 

413 assert_that(data).contains_key("tools", "summary") 

414 

415 

416def test_doctor_fix_incompatible_with_json() -> None: 

417 """--fix --json raises a usage error.""" 

418 runner = CliRunner() 

419 p1, p2 = _patch_doctor_deps() 

420 

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"]) 

433 

434 assert_that(result.exit_code).is_not_equal_to(0) 

435 assert_that(result.output).contains("--fix cannot be combined") 

436 

437 

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() 

442 

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"]) 

451 

452 assert_that(result.exit_code).is_equal_to(0) 

453 assert_that(result.output).contains("ruff") 

454 

455 

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() 

460 

461 with p1, p2: 

462 result = runner.invoke(doctor_command, ["--tools", "nonexistent"]) 

463 

464 assert_that(result.exit_code).is_equal_to(1) 

465 assert_that(result.output).contains("Unknown tools")