diff --git a/Makefile b/Makefile index da8401d38d..0d1abe19e0 100644 --- a/Makefile +++ b/Makefile @@ -893,7 +893,7 @@ test-unit-python: install bash scripts/tests/code/wrapper.sh scripts/tests/code/run.sh .PHONY: test-unit-ruby -# Run the Ruby unit test suite (minitest, stdlib). +# Run the Ruby unit test suite (minitest). test-unit-ruby: @INFINITO_TEST_TYPE="unit-ruby" \ bash scripts/tests/code/wrapper.sh scripts/tests/unit/ruby.sh diff --git a/scripts/tests/unit/ruby.sh b/scripts/tests/unit/ruby.sh index 6de951b307..e0b6466678 100755 --- a/scripts/tests/unit/ruby.sh +++ b/scripts/tests/unit/ruby.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # shellcheck shell=bash # -# Runs the Ruby unit suite through minitest, which ships with Ruby itself. +# Runs the Ruby unit suite through minitest, which scripts/install/ruby.sh provisions. set -euo pipefail diff --git a/tests/unit/python/utils/install/test_ruby.py b/tests/unit/python/utils/install/test_ruby.py index e0c57e07f5..ee69858086 100644 --- a/tests/unit/python/utils/install/test_ruby.py +++ b/tests/unit/python/utils/install/test_ruby.py @@ -9,19 +9,75 @@ from utils.install import ruby as ruby_mod class TestEnsureRubyPresent(unittest.TestCase): - def test_delegates_to_the_shared_provisioner(self) -> None: - with mock.patch.object(ruby_mod, "ensure_command_present") as ensure: + def test_provisions_ruby_then_minitest(self) -> None: + calls = mock.Mock() + with ( + mock.patch.object(ruby_mod, "ensure_command_present", calls.ruby), + mock.patch.object(ruby_mod, "ensure_minitest_present", calls.minitest), + ): ruby_mod.ensure_ruby_present() - ensure.assert_called_once_with("ruby") + self.assertEqual( + calls.mock_calls, [mock.call.ruby("ruby"), mock.call.minitest()] + ) def test_propagates_the_hard_failure(self) -> None: with ( mock.patch.object( ruby_mod, "ensure_command_present", side_effect=RuntimeError("boom") ), + mock.patch.object(ruby_mod, "ensure_minitest_present") as minitest, self.assertRaises(RuntimeError), ): ruby_mod.ensure_ruby_present() + minitest.assert_not_called() + + +class TestEnsureMinitestPresent(unittest.TestCase): + def run_with(self, *, loadable: list[bool], manager: str) -> mock.Mock: + """Run ``ensure_minitest_present`` with stubbed probe and installer. + + Args: + loadable: successive results of the minitest probe. + manager: package manager the host reports. + + Returns: + The stubbed ``install_package_candidates``. + """ + with ( + mock.patch.object(ruby_mod, "minitest_loadable", side_effect=loadable), + mock.patch.object(ruby_mod, "detect_package_manager", return_value=manager), + mock.patch.object(ruby_mod, "install_package_candidates") as install, + mock.patch.object(ruby_mod, "log"), + ): + ruby_mod.ensure_minitest_present() + return install + + def test_a_loadable_minitest_installs_nothing(self) -> None: + install = self.run_with(loadable=[True], manager="pacman") + install.assert_not_called() + + def test_arch_installs_ruby_minitest(self) -> None: + install = self.run_with(loadable=[False, True], manager="pacman") + install.assert_called_once_with("pacman", ["ruby-minitest"]) + + def test_the_redhat_family_installs_rubygem_minitest(self) -> None: + install = self.run_with(loadable=[False, True], manager="dnf") + install.assert_called_once_with("dnf", ["rubygem-minitest"]) + + def test_still_missing_after_the_install_fails_loudly(self) -> None: + with self.assertRaises(RuntimeError): + self.run_with(loadable=[False, False], manager="pacman") + + def test_an_unmapped_manager_fails_without_installing(self) -> None: + with ( + mock.patch.object(ruby_mod, "minitest_loadable", return_value=False), + mock.patch.object(ruby_mod, "detect_package_manager", return_value="brew"), + mock.patch.object(ruby_mod, "install_package_candidates") as install, + mock.patch.object(ruby_mod, "log"), + self.assertRaises(RuntimeError), + ): + ruby_mod.ensure_minitest_present() + install.assert_not_called() if __name__ == "__main__": diff --git a/utils/install/ruby.py b/utils/install/ruby.py index 99db87f5b2..16c28d451d 100644 --- a/utils/install/ruby.py +++ b/utils/install/ruby.py @@ -2,16 +2,67 @@ from __future__ import annotations -from utils.install.system_pkg import ensure_command_present +import subprocess + +from utils.install.primitives import log +from utils.install.system_pkg import ( + detect_package_manager, + ensure_command_present, + install_package_candidates, +) + +_MINITEST_PACKAGES: dict[str, list[str]] = { + "pacman": ["ruby-minitest"], + "dnf": ["rubygem-minitest"], + "yum": ["rubygem-minitest"], + "apt-get": ["ruby-minitest"], +} + + +def minitest_loadable() -> bool: + """Return whether the installed ruby can require minitest. + + Returns: + ``True`` when ``ruby -e 'require "minitest"'`` exits 0. + """ + probe = subprocess.run( + ["ruby", "-e", 'require "minitest"'], capture_output=True, check=False + ) + return probe.returncode == 0 + + +def ensure_minitest_present() -> None: + """Make minitest loadable, installing its package if ruby lacks it. + + Raises: + RuntimeError: no package mapping for the detected package manager, or + minitest is still not loadable after the install. + """ + if minitest_loadable(): + return + + manager = detect_package_manager() + packages = _MINITEST_PACKAGES.get(manager) + if packages is None: + raise RuntimeError(f"No installer mapping defined for minitest on '{manager}'.") + + log(f"Ruby cannot load minitest. Attempting installation via {manager}.") + install_package_candidates(manager, packages) + + if not minitest_loadable(): + raise RuntimeError( + f"minitest still not loadable after installing {packages} via {manager}" + ) def ensure_ruby_present() -> None: - """Install the Ruby interpreter through the system package manager. + """Install the Ruby interpreter and minitest through the system package manager. Raises: - RuntimeError: ruby is still absent after the install attempt. + RuntimeError: ruby or minitest is still absent after the install attempt. """ ensure_command_present("ruby") + ensure_minitest_present() if __name__ == "__main__":