|
11 | 11 | import os |
12 | 12 | import subprocess |
13 | 13 | import shutil |
| 14 | +import io |
| 15 | +import json |
| 16 | +import contextlib |
| 17 | +import urllib.request |
| 18 | +import urllib.error |
14 | 19 |
|
15 | 20 | # Import the module to test (handle hyphen in filename) |
16 | 21 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
@@ -215,6 +220,34 @@ def test_install_via_snap_success(self, mock_subprocess): |
215 | 220 | call_kwargs = mock_subprocess.run.call_args[1] |
216 | 221 | self.assertIn('timeout', call_kwargs) |
217 | 222 |
|
| 223 | + @patch.object(dlt, 'subprocess') |
| 224 | + @patch.object(dlt, 'shutil') |
| 225 | + def test_install_via_npm_success(self, mock_shutil, mock_subprocess): |
| 226 | + """npm already present: install the package globally.""" |
| 227 | + mock_shutil.which.return_value = '/usr/bin/npm' |
| 228 | + mock_subprocess.run.return_value = subprocess.CompletedProcess(['sudo'], 0) |
| 229 | + self.assertTrue(dlt.Installer.install_via_npm('neoss')) |
| 230 | + self.assertIn('timeout', mock_subprocess.run.call_args[1]) |
| 231 | + |
| 232 | + @patch.object(dlt.Installer, 'install_via_apt') |
| 233 | + @patch.object(dlt, 'subprocess') |
| 234 | + @patch.object(dlt, 'shutil') |
| 235 | + def test_install_via_npm_bootstraps_npm(self, mock_shutil, mock_subprocess, mock_apt): |
| 236 | + """npm missing: apt-install npm first rather than failing with 'not found'.""" |
| 237 | + mock_shutil.which.return_value = None |
| 238 | + mock_apt.return_value = True |
| 239 | + mock_subprocess.run.return_value = subprocess.CompletedProcess(['sudo'], 0) |
| 240 | + self.assertTrue(dlt.Installer.install_via_npm('neoss')) |
| 241 | + mock_apt.assert_called_once_with('npm') |
| 242 | + |
| 243 | + @patch.object(dlt.Installer, 'install_via_apt') |
| 244 | + @patch.object(dlt, 'shutil') |
| 245 | + def test_install_via_npm_gives_up_if_npm_unavailable(self, mock_shutil, mock_apt): |
| 246 | + """If npm cannot be installed, report failure instead of pressing on.""" |
| 247 | + mock_shutil.which.return_value = None |
| 248 | + mock_apt.return_value = False |
| 249 | + self.assertFalse(dlt.Installer.install_via_npm('neoss')) |
| 250 | + |
218 | 251 | @patch.object(dlt.Installer, 'install_binary_to_path') |
219 | 252 | @patch.object(dlt, 'subprocess') |
220 | 253 | @patch.object(dlt, 'os') |
@@ -382,6 +415,44 @@ def test_install_tool_eget(self, mock_shutil, mock_install): |
382 | 415 | self.assertTrue(result) |
383 | 416 | mock_install.assert_called_once() |
384 | 417 |
|
| 418 | + @patch.object(dlt.Installer, 'install_via_npm') |
| 419 | + @patch.object(dlt, 'shutil') |
| 420 | + def test_install_tool_npm(self, mock_shutil, mock_install): |
| 421 | + """Test installing tool via npm.""" |
| 422 | + mock_shutil.which.return_value = '/usr/local/bin/neoss' |
| 423 | + mock_install.return_value = True |
| 424 | + tool = dlt.ToolManager.TOOLS['neoss'] |
| 425 | + self.assertTrue(dlt.ToolManager.install_tool(tool)) |
| 426 | + mock_install.assert_called_once_with('neoss') |
| 427 | + |
| 428 | + def test_every_method_is_handled_by_install_tool(self): |
| 429 | + """Every InstallMethod a tool actually uses must have a dry-run branch. |
| 430 | +
|
| 431 | + Adding an enum member without wiring it up would otherwise leave the |
| 432 | + tool silently unhandled, which is how neoss and eg went unnoticed. |
| 433 | + """ |
| 434 | + used = {t.method for t in dlt.ToolManager.TOOLS.values()} |
| 435 | + for method in used: |
| 436 | + with self.subTest(method=method.value): |
| 437 | + tool = next(t for t in dlt.ToolManager.TOOLS.values() if t.method == method) |
| 438 | + buf = io.StringIO() |
| 439 | + with contextlib.redirect_stdout(buf): |
| 440 | + handled = dlt.ToolManager.install_tool(tool, dry_run=True) |
| 441 | + self.assertTrue(handled) |
| 442 | + self.assertTrue(buf.getvalue().strip(), |
| 443 | + f"{method.value} produced no dry-run output") |
| 444 | + |
| 445 | + def test_eget_tools_declare_a_repo_and_others_do_not(self): |
| 446 | + """A github_repo is required for eget and meaningless for anything else.""" |
| 447 | + for name, tool in dlt.ToolManager.TOOLS.items(): |
| 448 | + with self.subTest(tool=name): |
| 449 | + if tool.method == dlt.InstallMethod.EGET: |
| 450 | + self.assertTrue(tool.github_repo, |
| 451 | + f"{name} installs via eget but names no repo") |
| 452 | + else: |
| 453 | + self.assertIsNone(tool.github_repo, |
| 454 | + f"{name} does not use eget but names a repo") |
| 455 | + |
385 | 456 |
|
386 | 457 | class TestToolDataClass(unittest.TestCase): |
387 | 458 | """Test Tool dataclass.""" |
@@ -529,6 +600,109 @@ def test_main_success_flow(self, mock_print, mock_install, mock_check_installed, |
529 | 600 | self.assertEqual(mock_input.call_count, 1) |
530 | 601 |
|
531 | 602 |
|
| 603 | +@unittest.skipUnless( |
| 604 | + os.environ.get("LINUX_TOOLS_NETWORK_TESTS") == "1", |
| 605 | + "network test; set LINUX_TOOLS_NETWORK_TESTS=1 to run", |
| 606 | +) |
| 607 | +class TestEgetToolsResolveUpstream(unittest.TestCase): |
| 608 | + """Ask GitHub whether every eget tool can still actually be fetched. |
| 609 | +
|
| 610 | + The mocked tests above prove the installer calls eget correctly. They |
| 611 | + cannot prove eget will find anything at the other end, and that is the |
| 612 | + failure that reached users: neoss stopped attaching release assets when it |
| 613 | + moved to npm, and eg never cut a GitHub release at all. Both were declared |
| 614 | + as eget tools for a long time, and both failed only at install time on a |
| 615 | + real machine. |
| 616 | +
|
| 617 | + This test is off by default because it needs the network and spends |
| 618 | + GitHub's unauthenticated rate limit. Set GITHUB_TOKEN to raise that limit. |
| 619 | + """ |
| 620 | + |
| 621 | + # Files that mention Linux but are not something eget can install: update |
| 622 | + # manifests, checksums, signatures, and distro packages it will not unpack. |
| 623 | + NON_BINARY_SUFFIXES = ( |
| 624 | + ".yml", ".yaml", ".json", ".txt", ".md", ".sha256", ".sha256sum", |
| 625 | + ".sig", ".asc", ".pem", ".sbom", ".deb", ".rpm", |
| 626 | + ) |
| 627 | + |
| 628 | + @classmethod |
| 629 | + def _usable_linux_asset(cls, asset_name: str) -> bool: |
| 630 | + lowered = asset_name.lower() |
| 631 | + return "linux" in lowered and not lowered.endswith(cls.NON_BINARY_SUFFIXES) |
| 632 | + |
| 633 | + def _get(self, url: str) -> dict: |
| 634 | + headers = { |
| 635 | + "User-Agent": "Linux-Tools-test", |
| 636 | + "Accept": "application/vnd.github+json", |
| 637 | + } |
| 638 | + token = os.environ.get("GITHUB_TOKEN") |
| 639 | + if token: |
| 640 | + headers["Authorization"] = f"Bearer {token}" |
| 641 | + request = urllib.request.Request(url, headers=headers) |
| 642 | + with urllib.request.urlopen(request, timeout=30) as response: |
| 643 | + return json.load(response) |
| 644 | + |
| 645 | + def _require_api_budget(self, needed: int) -> None: |
| 646 | + """Skip before starting rather than half-checking the list. |
| 647 | +
|
| 648 | + Anonymous callers get sixty requests an hour, which one run can |
| 649 | + exhaust. Discovering that partway through would leave most tools |
| 650 | + unchecked while the run still reported success, so establish up front |
| 651 | + that the whole sweep can complete. |
| 652 | + """ |
| 653 | + try: |
| 654 | + rate = self._get("https://api.github.com/rate_limit")["rate"] |
| 655 | + except urllib.error.URLError as error: |
| 656 | + raise unittest.SkipTest(f"network unavailable: {error.reason}") |
| 657 | + if rate["remaining"] < needed: |
| 658 | + raise unittest.SkipTest( |
| 659 | + f"GitHub API budget too low: {rate['remaining']} left, {needed} " |
| 660 | + f"needed. Set GITHUB_TOKEN (export GITHUB_TOKEN=$(gh auth token))." |
| 661 | + ) |
| 662 | + |
| 663 | + def test_every_eget_tool_resolves_to_a_downloadable_asset(self): |
| 664 | + eget_tools = { |
| 665 | + name: tool.github_repo |
| 666 | + for name, tool in dlt.ToolManager.TOOLS.items() |
| 667 | + if tool.method == dlt.InstallMethod.EGET |
| 668 | + } |
| 669 | + self.assertTrue(eget_tools, "expected the installer to define eget tools") |
| 670 | + self._require_api_budget(len(eget_tools)) |
| 671 | + |
| 672 | + failures = [] |
| 673 | + for name, repo in sorted(eget_tools.items()): |
| 674 | + try: |
| 675 | + release = self._get( |
| 676 | + f"https://api.github.com/repos/{repo}/releases/latest" |
| 677 | + ) |
| 678 | + except urllib.error.HTTPError as error: |
| 679 | + if error.code in (403, 429): |
| 680 | + raise unittest.SkipTest( |
| 681 | + f"rate limited after checking {len(failures)} tools; " |
| 682 | + f"set GITHUB_TOKEN and rerun" |
| 683 | + ) |
| 684 | + failures.append( |
| 685 | + f"{name}: no published release at {repo} (HTTP {error.code}), " |
| 686 | + f"so eget has nothing to download" |
| 687 | + ) |
| 688 | + continue |
| 689 | + except urllib.error.URLError as error: |
| 690 | + raise unittest.SkipTest(f"network unavailable: {error.reason}") |
| 691 | + |
| 692 | + assets = [asset["name"] for asset in release.get("assets", [])] |
| 693 | + if not [a for a in assets if self._usable_linux_asset(a)]: |
| 694 | + failures.append( |
| 695 | + f"{name}: {repo} release {release.get('tag_name')!r} publishes " |
| 696 | + f"no Linux binary eget can install (assets: {assets or 'none'})" |
| 697 | + ) |
| 698 | + |
| 699 | + self.assertEqual( |
| 700 | + [], failures, |
| 701 | + "eget tools that cannot actually be installed:\n " |
| 702 | + + "\n ".join(failures), |
| 703 | + ) |
| 704 | + |
| 705 | + |
532 | 706 | if __name__ == '__main__': |
533 | 707 | # Run tests with verbose output |
534 | 708 | unittest.main(verbosity=2) |
0 commit comments