Coverage for tests / unit / tools / core / test_tool_installer.py: 100%
249 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"""Unit tests for the ToolInstaller class."""
3from __future__ import annotations
5import subprocess
6from collections.abc import Callable
7from unittest.mock import MagicMock, patch
9import pytest
10from assertpy import assert_that
12from lintro.enums.install_context import InstallContext, PackageManager
13from lintro.tools.core.install_context import RuntimeContext
14from lintro.tools.core.tool_installer import InstallPlan, InstallResult, ToolInstaller
15from lintro.tools.core.tool_registry import ManifestTool
17# ---------------------------------------------------------------------------
18# Fixtures
19# ---------------------------------------------------------------------------
22@pytest.fixture()
23def make_tool() -> Callable[..., ManifestTool]:
24 """Return a factory that builds ManifestTool instances with sensible defaults.
26 Returns:
27 A callable that accepts keyword overrides and produces ManifestTool.
28 """
30 def _factory(**overrides: object) -> ManifestTool:
31 """Build a ManifestTool with defaults.
33 Args:
34 **overrides: Fields to override on the dataclass.
36 Returns:
37 A ManifestTool instance.
38 """
39 defaults = {
40 "name": "faketool",
41 "version": "1.2.0",
42 "install_type": "pip",
43 "install_package": "faketool-pkg",
44 "version_command": ("faketool", "--version"),
45 }
46 defaults.update(overrides) # type: ignore[arg-type] # test factory merges heterogeneous overrides
47 return ManifestTool(**defaults) # type: ignore[arg-type] # test factory with dynamic kwargs
49 return _factory
52@pytest.fixture()
53def context() -> RuntimeContext:
54 """Return a default RuntimeContext for testing.
56 Returns:
57 A RuntimeContext with common defaults.
58 """
59 from lintro.tools.core.install_strategies.environment import InstallEnvironment
61 return RuntimeContext(
62 install_context=InstallContext.PIP,
63 platform_label="Linux x86_64",
64 environment=InstallEnvironment(
65 install_context=InstallContext.PIP,
66 available_managers=frozenset(
67 {
68 PackageManager.UV,
69 PackageManager.PIP,
70 PackageManager.NPM,
71 PackageManager.CARGO,
72 PackageManager.RUSTUP,
73 },
74 ),
75 ),
76 is_ci=False,
77 )
80@pytest.fixture()
81def registry() -> MagicMock:
82 """Return a mock ToolRegistry.
84 Returns:
85 A MagicMock standing in for ToolRegistry.
86 """
87 return MagicMock()
90@pytest.fixture()
91def installer(registry: MagicMock, context: RuntimeContext) -> ToolInstaller:
92 """Return a ToolInstaller wired to mock registry and real context.
94 Args:
95 registry: Mock ToolRegistry.
96 context: RuntimeContext fixture.
98 Returns:
99 A ToolInstaller instance.
100 """
101 return ToolInstaller(registry, context)
104# ---------------------------------------------------------------------------
105# _is_manual_hint (static)
106# ---------------------------------------------------------------------------
109@pytest.mark.parametrize(
110 ("hint", "expected"),
111 [
112 ("See https://foo", True),
113 ("Install hadolint manually", True),
114 ("Upgrade hadolint manually", True),
115 ("brew install foo", False),
116 ("pip install foo>=1.0", False),
117 ("pip install 'httpie>=3.0'", False),
118 ("Download from http://example.com", True),
119 ],
120 ids=[
121 "see_url",
122 "install_manually",
123 "upgrade_manually",
124 "brew_command",
125 "pip_command",
126 "pip_http_package",
127 "download_url",
128 ],
129)
130def test_is_manual_hint(hint: str, expected: bool) -> None:
131 """Classify install hints as manual or executable.
133 Args:
134 hint: The hint string to classify.
135 expected: Whether it should be treated as manual.
136 """
137 assert_that(ToolInstaller._is_manual_hint(hint)).is_equal_to(expected)
140# ---------------------------------------------------------------------------
141# _version_meets_minimum (static)
142# ---------------------------------------------------------------------------
145@pytest.mark.parametrize(
146 ("installed", "minimum", "expected"),
147 [
148 ("1.2.3", "1.0.0", True),
149 ("1.0.0", "1.2.0", False),
150 ("1.2.3", "1.2.3", True),
151 ("invalid", "1.0.0", False),
152 ],
153 ids=[
154 "newer_than_minimum",
155 "older_than_minimum",
156 "equal_to_minimum",
157 "invalid_version",
158 ],
159)
160def test_version_meets_minimum(
161 installed: str,
162 minimum: str,
163 expected: bool,
164) -> None:
165 """Compare installed version against a minimum requirement.
167 Args:
168 installed: The installed version string.
169 minimum: The required minimum version string.
170 expected: Whether the installed version should meet the minimum.
171 """
172 assert_that(
173 ToolInstaller._version_meets_minimum(installed, minimum),
174 ).is_equal_to(expected)
177# ---------------------------------------------------------------------------
178# _plan_tool
179# ---------------------------------------------------------------------------
182def test_plan_tool_already_ok(
183 installer: ToolInstaller,
184 make_tool: Callable[..., ManifestTool],
185) -> None:
186 """Place tool in already_ok when installed at the current version.
188 Args:
189 installer: ToolInstaller fixture.
190 make_tool: ManifestTool factory.
191 """
192 tool = make_tool(version="1.2.0")
193 plan = InstallPlan()
195 with patch.object(installer, "_get_installed_version", return_value="1.2.0"):
196 installer._plan_tool(plan, tool, upgrade=False)
198 assert_that(plan.already_ok).contains(tool)
199 assert_that(plan.to_install).is_empty()
200 assert_that(plan.to_upgrade).is_empty()
201 assert_that(plan.outdated).is_empty()
202 assert_that(plan.skipped).is_empty()
205def test_plan_tool_outdated_no_upgrade(
206 installer: ToolInstaller,
207 make_tool: Callable[..., ManifestTool],
208) -> None:
209 """Place tool in outdated when version is old and upgrade is False.
211 Args:
212 installer: ToolInstaller fixture.
213 make_tool: ManifestTool factory.
214 """
215 tool = make_tool(version="2.0.0")
216 plan = InstallPlan()
218 with patch.object(installer, "_get_installed_version", return_value="1.0.0"):
219 installer._plan_tool(plan, tool, upgrade=False)
221 assert_that(plan.outdated).is_length(1)
222 assert_that(plan.outdated[0][0]).is_equal_to(tool)
223 assert_that(plan.outdated[0][1]).is_equal_to("1.0.0")
224 assert_that(plan.to_upgrade).is_empty()
227def test_plan_tool_outdated_with_upgrade(
228 installer: ToolInstaller,
229 make_tool: Callable[..., ManifestTool],
230) -> None:
231 """Place tool in to_upgrade when version is old and upgrade is True.
233 Args:
234 installer: ToolInstaller fixture.
235 make_tool: ManifestTool factory.
236 """
237 tool = make_tool(version="2.0.0")
238 plan = InstallPlan()
240 with (
241 patch.object(installer, "_get_installed_version", return_value="1.0.0"),
242 patch.object(
243 installer,
244 "_get_install_command",
245 return_value="pip install --upgrade faketool-pkg>=2.0.0",
246 ),
247 ):
248 installer._plan_tool(plan, tool, upgrade=True)
250 assert_that(plan.to_upgrade).is_length(1)
251 assert_that(plan.to_upgrade[0][0]).is_equal_to(tool)
252 assert_that(plan.to_upgrade[0][1]).is_equal_to("1.0.0")
253 assert_that(plan.outdated).is_empty()
256def test_plan_tool_missing_installable(
257 installer: ToolInstaller,
258 make_tool: Callable[..., ManifestTool],
259) -> None:
260 """Place tool in to_install when not installed and hint is executable.
262 Args:
263 installer: ToolInstaller fixture.
264 make_tool: ManifestTool factory.
265 """
266 tool = make_tool()
267 plan = InstallPlan()
269 with (
270 patch.object(installer, "_get_installed_version", return_value=None),
271 patch.object(installer, "_check_prerequisites", return_value=None),
272 patch.object(
273 installer,
274 "_get_install_command",
275 return_value="pip install faketool-pkg>=1.2.0",
276 ),
277 ):
278 installer._plan_tool(plan, tool, upgrade=False)
280 assert_that(plan.to_install).is_length(1)
281 assert_that(plan.to_install[0][0]).is_equal_to(tool)
282 assert_that(plan.skipped).is_empty()
285def test_plan_tool_missing_manual_hint(
286 installer: ToolInstaller,
287 make_tool: Callable[..., ManifestTool],
288) -> None:
289 """Place tool in skipped when not installed and hint is manual.
291 Args:
292 installer: ToolInstaller fixture.
293 make_tool: ManifestTool factory.
294 """
295 tool = make_tool(install_type="binary")
296 plan = InstallPlan()
298 with (
299 patch.object(installer, "_get_installed_version", return_value=None),
300 patch.object(installer, "_check_prerequisites", return_value=None),
301 patch.object(
302 installer,
303 "_get_install_command",
304 return_value="See https://github.com/example/releases",
305 ),
306 patch.object(installer, "_has_install_script", return_value=False),
307 ):
308 installer._plan_tool(plan, tool, upgrade=False)
310 assert_that(plan.skipped).is_length(1)
311 assert_that(plan.skipped[0][0]).is_equal_to(tool)
312 assert_that(plan.to_install).is_empty()
315def test_plan_tool_missing_with_install_script(
316 installer: ToolInstaller,
317 make_tool: Callable[..., ManifestTool],
318) -> None:
319 """Place binary tool in to_install with script hint when script exists.
321 Args:
322 installer: ToolInstaller fixture.
323 make_tool: ManifestTool factory.
324 """
325 tool = make_tool(install_type="binary")
326 plan = InstallPlan()
328 with (
329 patch.object(installer, "_get_installed_version", return_value=None),
330 patch.object(installer, "_check_prerequisites", return_value=None),
331 patch.object(
332 installer,
333 "_get_install_command",
334 return_value="See https://github.com/example/releases",
335 ),
336 patch.object(installer, "_has_install_script", return_value=True),
337 ):
338 installer._plan_tool(plan, tool, upgrade=False)
340 assert_that(plan.to_install).is_length(1)
341 assert_that(plan.to_install[0][0]).is_equal_to(tool)
342 assert_that(plan.to_install[0][1]).contains("install-tools.sh")
343 assert_that(plan.skipped).is_empty()
346def test_plan_tool_upgrade_manual_hint(
347 installer: ToolInstaller,
348 make_tool: Callable[..., ManifestTool],
349) -> None:
350 """Place tool in skipped when upgrade hint is manual.
352 Args:
353 installer: ToolInstaller fixture.
354 make_tool: ManifestTool factory.
355 """
356 tool = make_tool(version="2.0.0", install_type="binary")
357 plan = InstallPlan()
359 with (
360 patch.object(installer, "_get_installed_version", return_value="1.0.0"),
361 patch.object(
362 installer,
363 "_get_install_command",
364 return_value="See https://github.com/example/releases",
365 ),
366 patch.object(installer, "_has_install_script", return_value=False),
367 ):
368 installer._plan_tool(plan, tool, upgrade=True)
370 assert_that(plan.skipped).is_length(1)
371 assert_that(plan.skipped[0][0]).is_equal_to(tool)
372 assert_that(plan.to_upgrade).is_empty()
375def test_plan_tool_skipped_no_cargo(
376 registry: MagicMock,
377 make_tool: Callable[..., ManifestTool],
378) -> None:
379 """Skip cargo tool when cargo is not available.
381 Args:
382 registry: Mock ToolRegistry.
383 make_tool: ManifestTool factory.
384 """
385 from lintro.tools.core.install_strategies.environment import InstallEnvironment
387 ctx = RuntimeContext(
388 install_context=InstallContext.PIP,
389 platform_label="Linux x86_64",
390 environment=InstallEnvironment(
391 install_context=InstallContext.PIP,
392 available_managers=frozenset(
393 {
394 PackageManager.UV,
395 PackageManager.PIP,
396 PackageManager.NPM,
397 PackageManager.RUSTUP,
398 },
399 ),
400 ),
401 is_ci=False,
402 )
403 inst = ToolInstaller(registry, ctx)
404 tool = make_tool(install_type="cargo")
405 plan = InstallPlan()
407 with patch.object(inst, "_get_installed_version", return_value=None):
408 inst._plan_tool(plan, tool, upgrade=False)
410 assert_that(plan.skipped).is_length(1)
411 assert_that(plan.skipped[0][1]).contains("cargo")
414def test_plan_tool_skipped_no_npm(
415 registry: MagicMock,
416 make_tool: Callable[..., ManifestTool],
417) -> None:
418 """Skip npm tool when bun and npm are both unavailable.
420 Args:
421 registry: Mock ToolRegistry.
422 make_tool: ManifestTool factory.
423 """
424 from lintro.tools.core.install_strategies.environment import InstallEnvironment
426 ctx = RuntimeContext(
427 install_context=InstallContext.PIP,
428 platform_label="Linux x86_64",
429 environment=InstallEnvironment(
430 install_context=InstallContext.PIP,
431 available_managers=frozenset(
432 {
433 PackageManager.UV,
434 PackageManager.PIP,
435 PackageManager.CARGO,
436 PackageManager.RUSTUP,
437 },
438 ),
439 ),
440 is_ci=False,
441 )
442 inst = ToolInstaller(registry, ctx)
443 tool = make_tool(install_type="npm", name="eslint")
444 plan = InstallPlan()
446 with patch.object(inst, "_get_installed_version", return_value=None):
447 inst._plan_tool(plan, tool, upgrade=False)
449 assert_that(plan.skipped).is_length(1)
450 assert_that(plan.skipped[0][1]).contains("npm")
453# ---------------------------------------------------------------------------
454# plan()
455# ---------------------------------------------------------------------------
458def test_plan_with_tools_list(
459 installer: ToolInstaller,
460 registry: MagicMock,
461 make_tool: Callable[..., ManifestTool],
462) -> None:
463 """Plan resolves specific tool names via the registry.
465 Args:
466 installer: ToolInstaller fixture.
467 registry: Mock ToolRegistry.
468 make_tool: ManifestTool factory.
469 """
470 tool_a = make_tool(name="tool_a")
471 tool_b = make_tool(name="tool_b")
472 registry.__contains__ = MagicMock(side_effect=lambda n: n in {"tool_a", "tool_b"})
473 registry.get = MagicMock(
474 side_effect=lambda n: {"tool_a": tool_a, "tool_b": tool_b}[n],
475 )
477 with patch.object(installer, "_plan_tool") as mock_plan_tool:
478 installer.plan(tools=["tool_a", "tool_b"])
480 assert_that(mock_plan_tool.call_count).is_equal_to(2)
483def test_plan_deduplicates_tools(
484 installer: ToolInstaller,
485 registry: MagicMock,
486 make_tool: Callable[..., ManifestTool],
487) -> None:
488 """Plan deduplicates tool names while preserving order.
490 Args:
491 installer: ToolInstaller fixture.
492 registry: Mock ToolRegistry.
493 make_tool: ManifestTool factory.
494 """
495 tool_a = make_tool(name="tool_a")
496 registry.__contains__ = MagicMock(return_value=True)
497 registry.get = MagicMock(return_value=tool_a)
499 with patch.object(installer, "_plan_tool") as mock_plan_tool:
500 installer.plan(tools=["tool_a", "tool_a", "tool_a"])
502 assert_that(mock_plan_tool.call_count).is_equal_to(1)
505def test_plan_with_profile(
506 installer: ToolInstaller,
507 registry: MagicMock,
508 make_tool: Callable[..., ManifestTool],
509) -> None:
510 """Plan resolves tools from a profile via registry.tools_for_profile.
512 Args:
513 installer: ToolInstaller fixture.
514 registry: Mock ToolRegistry.
515 make_tool: ManifestTool factory.
516 """
517 tool_a = make_tool(name="profiled_tool")
518 registry.tools_for_profile = MagicMock(return_value=[tool_a])
520 with patch.object(installer, "_plan_tool") as mock_plan_tool:
521 installer.plan(profile="recommended")
523 registry.tools_for_profile.assert_called_once_with("recommended", None)
524 assert_that(mock_plan_tool.call_count).is_equal_to(1)
527def test_plan_with_upgrade_flag(
528 installer: ToolInstaller,
529 registry: MagicMock,
530 make_tool: Callable[..., ManifestTool],
531) -> None:
532 """Plan passes the upgrade flag through to _plan_tool.
534 Args:
535 installer: ToolInstaller fixture.
536 registry: Mock ToolRegistry.
537 make_tool: ManifestTool factory.
538 """
539 tool_a = make_tool(name="upgradable")
540 registry.__contains__ = MagicMock(return_value=True)
541 registry.get = MagicMock(return_value=tool_a)
543 with patch.object(installer, "_plan_tool") as mock_plan_tool:
544 installer.plan(tools=["upgradable"], upgrade=True)
546 call_kwargs = mock_plan_tool.call_args
547 assert_that(call_kwargs.kwargs["upgrade"]).is_true()
550# ---------------------------------------------------------------------------
551# _check_prerequisites
552# ---------------------------------------------------------------------------
555def test_check_prerequisites_cargo_missing(
556 registry: MagicMock,
557 make_tool: Callable[..., ManifestTool],
558) -> None:
559 """Return skip reason when cargo is not available for a cargo tool.
561 Args:
562 registry: Mock ToolRegistry.
563 make_tool: ManifestTool factory.
564 """
565 from lintro.tools.core.install_strategies.environment import InstallEnvironment
567 ctx = RuntimeContext(
568 install_context=InstallContext.PIP,
569 platform_label="Linux x86_64",
570 environment=InstallEnvironment(
571 install_context=InstallContext.PIP,
572 available_managers=frozenset(
573 {
574 PackageManager.UV,
575 PackageManager.PIP,
576 PackageManager.NPM,
577 PackageManager.RUSTUP,
578 },
579 ),
580 ),
581 is_ci=False,
582 )
583 inst = ToolInstaller(registry, ctx)
584 tool = make_tool(install_type="cargo")
586 result = inst._check_prerequisites(tool)
588 assert_that(result).is_not_none()
589 assert_that(result).contains("cargo")
592def test_check_prerequisites_npm_missing(
593 registry: MagicMock,
594 make_tool: Callable[..., ManifestTool],
595) -> None:
596 """Return skip reason when npm and bun are both unavailable for npm tool.
598 Args:
599 registry: Mock ToolRegistry.
600 make_tool: ManifestTool factory.
601 """
602 from lintro.tools.core.install_strategies.environment import InstallEnvironment
604 ctx = RuntimeContext(
605 install_context=InstallContext.PIP,
606 platform_label="Linux x86_64",
607 environment=InstallEnvironment(
608 install_context=InstallContext.PIP,
609 available_managers=frozenset(
610 {
611 PackageManager.UV,
612 PackageManager.PIP,
613 PackageManager.CARGO,
614 PackageManager.RUSTUP,
615 },
616 ),
617 ),
618 is_ci=False,
619 )
620 inst = ToolInstaller(registry, ctx)
621 tool = make_tool(install_type="npm", name="eslint")
623 result = inst._check_prerequisites(tool)
625 assert_that(result).is_not_none()
626 assert_that(result).contains("npm")
629def test_check_prerequisites_pip_missing(
630 registry: MagicMock,
631 make_tool: Callable[..., ManifestTool],
632) -> None:
633 """Return skip reason when uv and pip are both unavailable for pip tool.
635 Args:
636 registry: Mock ToolRegistry.
637 make_tool: ManifestTool factory.
638 """
639 from lintro.tools.core.install_strategies.environment import InstallEnvironment
641 ctx = RuntimeContext(
642 install_context=InstallContext.PIP,
643 platform_label="Linux x86_64",
644 environment=InstallEnvironment(
645 install_context=InstallContext.PIP,
646 available_managers=frozenset(
647 {PackageManager.NPM, PackageManager.CARGO, PackageManager.RUSTUP},
648 ),
649 ),
650 is_ci=False,
651 )
652 inst = ToolInstaller(registry, ctx)
653 tool = make_tool(install_type="pip", name="ruff")
655 result = inst._check_prerequisites(tool)
657 assert_that(result).is_not_none()
658 assert_that(result).contains("uv/pip")
661def test_check_prerequisites_all_met(
662 installer: ToolInstaller,
663 make_tool: Callable[..., ManifestTool],
664) -> None:
665 """Return None when all prerequisites are met.
667 Args:
668 installer: ToolInstaller fixture.
669 make_tool: ManifestTool factory.
670 """
671 tool = make_tool(install_type="pip")
673 result = installer._check_prerequisites(tool)
675 assert_that(result).is_none()
678# ---------------------------------------------------------------------------
679# _get_install_command
680# ---------------------------------------------------------------------------
683def test_get_install_command_delegates_to_context(
684 installer: ToolInstaller,
685 make_tool: Callable[..., ManifestTool],
686) -> None:
687 """Generate an install command by delegating to context.install_hint_for_tool.
689 Args:
690 installer: ToolInstaller fixture.
691 make_tool: ManifestTool factory.
692 """
693 tool = make_tool(install_type="pip", install_package="mypkg", version="1.0.0")
695 result = installer._get_install_command(tool)
697 # Context has_uv=True so prefix is "uv pip install"
698 assert_that(result).contains("pip install")
699 assert_that(result).contains("mypkg")
702def test_get_install_command_upgrade_pip(
703 installer: ToolInstaller,
704 make_tool: Callable[..., ManifestTool],
705) -> None:
706 """Add --upgrade flag for pip tools when upgrade=True.
708 Args:
709 installer: ToolInstaller fixture.
710 make_tool: ManifestTool factory.
711 """
712 tool = make_tool(install_type="pip", install_package="mypkg", version="2.0.0")
714 result = installer._get_install_command(tool, upgrade=True)
716 assert_that(result).contains("--upgrade")
717 assert_that(result).contains("mypkg")
720def test_get_install_command_upgrade_cargo(
721 installer: ToolInstaller,
722 make_tool: Callable[..., ManifestTool],
723) -> None:
724 """Add --force flag for cargo tools when upgrade=True.
726 Args:
727 installer: ToolInstaller fixture.
728 make_tool: ManifestTool factory.
729 """
730 tool = make_tool(install_type="cargo", install_package="cargo-pkg")
732 result = installer._get_install_command(tool, upgrade=True)
734 assert_that(result).contains("--force")
735 assert_that(result).contains("cargo install")
738# ---------------------------------------------------------------------------
739# execute()
740# ---------------------------------------------------------------------------
743def test_execute_runs_installs_and_upgrades(
744 installer: ToolInstaller,
745 make_tool: Callable[..., ManifestTool],
746) -> None:
747 """Execute runs _run_install for each to_install and to_upgrade entry.
749 Args:
750 installer: ToolInstaller fixture.
751 make_tool: ManifestTool factory.
752 """
753 tool_a = make_tool(name="tool_a")
754 tool_b = make_tool(name="tool_b")
756 plan = InstallPlan(
757 to_install=[(tool_a, "pip install tool_a")],
758 to_upgrade=[(tool_b, "1.0.0", "pip install --upgrade tool_b")],
759 )
761 fake_result = InstallResult(
762 tool=tool_a,
763 success=True,
764 message="ok",
765 duration_seconds=0.1,
766 )
768 with patch.object(installer, "_run_install", return_value=fake_result) as mock_run:
769 results = installer.execute(plan)
771 assert_that(mock_run.call_count).is_equal_to(2)
772 assert_that(results).is_length(2)
775def test_execute_empty_plan(installer: ToolInstaller) -> None:
776 """Return an empty list when the plan has no work.
778 Args:
779 installer: ToolInstaller fixture.
780 """
781 plan = InstallPlan()
783 results = installer.execute(plan)
785 assert_that(results).is_empty()
788# ---------------------------------------------------------------------------
789# _run_install
790# ---------------------------------------------------------------------------
793def test_run_install_success(
794 installer: ToolInstaller,
795 make_tool: Callable[..., ManifestTool],
796) -> None:
797 """Return a successful InstallResult when subprocess returns 0.
799 Args:
800 installer: ToolInstaller fixture.
801 make_tool: ManifestTool factory.
802 """
803 tool = make_tool(install_type="pip")
805 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run:
806 mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
807 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0")
809 assert_that(result.success).is_true()
810 assert_that(result.message).contains("successfully")
811 assert_that(result.duration_seconds).is_greater_than_or_equal_to(0.0)
814def test_run_install_failure(
815 installer: ToolInstaller,
816 make_tool: Callable[..., ManifestTool],
817) -> None:
818 """Return a failed InstallResult when subprocess returns non-zero.
820 Args:
821 installer: ToolInstaller fixture.
822 make_tool: ManifestTool factory.
823 """
824 tool = make_tool(install_type="pip")
826 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run:
827 mock_run.return_value = MagicMock(
828 returncode=1,
829 stdout="",
830 stderr="error: package not found",
831 )
832 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0")
834 assert_that(result.success).is_false()
835 assert_that(result.message).contains("failed")
838def test_run_install_timeout(
839 installer: ToolInstaller,
840 make_tool: Callable[..., ManifestTool],
841) -> None:
842 """Return a timeout InstallResult when subprocess times out.
844 Args:
845 installer: ToolInstaller fixture.
846 make_tool: ManifestTool factory.
847 """
848 tool = make_tool(install_type="pip")
850 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run:
851 mock_run.side_effect = subprocess.TimeoutExpired(cmd="pip", timeout=300)
852 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0")
854 assert_that(result.success).is_false()
855 assert_that(result.message).contains("timed out")
858def test_run_install_os_error(
859 installer: ToolInstaller,
860 make_tool: Callable[..., ManifestTool],
861) -> None:
862 """Return an OS error InstallResult when subprocess raises OSError.
864 Args:
865 installer: ToolInstaller fixture.
866 make_tool: ManifestTool factory.
867 """
868 tool = make_tool(install_type="pip")
870 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run:
871 mock_run.side_effect = OSError("No such file or directory")
872 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0")
874 assert_that(result.success).is_false()
875 assert_that(result.message).contains("OS error")
878def test_run_install_manual_hint_rejected(
879 installer: ToolInstaller,
880 make_tool: Callable[..., ManifestTool],
881) -> None:
882 """Return failure when a manual hint slips through to _run_install.
884 Args:
885 installer: ToolInstaller fixture.
886 make_tool: ManifestTool factory.
887 """
888 tool = make_tool(install_type="binary")
890 with patch.object(installer, "_install_via_script", return_value=None):
891 result = installer._run_install(
892 tool,
893 "See https://github.com/example/releases",
894 )
896 assert_that(result.success).is_false()
897 assert_that(result.message).contains("Manual install required")
900# ---------------------------------------------------------------------------
901# _is_brew_managed (static)
902# ---------------------------------------------------------------------------
905def test_is_brew_managed_true() -> None:
906 """Return True when brew list returns exit code 0.
908 Simulates Homebrew managing the package.
909 """
910 with (
911 patch(
912 "lintro.tools.core.tool_installer.shutil.which",
913 return_value="/usr/local/bin/brew",
914 ),
915 patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run,
916 ):
917 mock_run.return_value = MagicMock(returncode=0)
918 result = ToolInstaller._is_brew_managed("faketool")
920 assert_that(result).is_true()
923def test_is_brew_managed_false() -> None:
924 """Return False when brew list returns non-zero exit code.
926 Simulates a package not managed by Homebrew.
927 """
928 with (
929 patch(
930 "lintro.tools.core.tool_installer.shutil.which",
931 return_value="/usr/local/bin/brew",
932 ),
933 patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run,
934 ):
935 mock_run.return_value = MagicMock(returncode=1)
936 result = ToolInstaller._is_brew_managed("faketool")
938 assert_that(result).is_false()
941def test_is_brew_managed_no_brew() -> None:
942 """Return False when brew is not in PATH.
944 Simulates a system without Homebrew installed.
945 """
946 with patch("lintro.tools.core.tool_installer.shutil.which", return_value=None):
947 result = ToolInstaller._is_brew_managed("faketool")
949 assert_that(result).is_false()