| 1 | """Tests for the uniform dependency-probe taxonomy in scripts/lib/health.py. |
| 2 | |
| 3 | Covers the doctor-command probe layer (issue #692 class): every probed external |
| 4 | dependency (yt-dlp, Printing Press CLIs, node, ffmpeg) must report |
| 5 | ok | missing | broken | timeout with a package-manager-aware prescription. |
| 6 | The stale-shim false negative — shutil.which resolves a binary that cannot |
| 7 | exec — must classify as ``broken`` with a *reinstall* prescription, never as |
| 8 | available. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import os |
| 14 | import stat |
| 15 | import subprocess |
| 16 | from pathlib import Path |
| 17 | from unittest import mock |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | from lib import health |
| 22 | |
| 23 | |
| 24 | @pytest.fixture(autouse=True) |
| 25 | def _fresh_probe_cache(): |
| 26 | """Probes memoize per process; isolate every test.""" |
| 27 | health.clear_dependency_probe_cache() |
| 28 | yield |
| 29 | health.clear_dependency_probe_cache() |
| 30 | |
| 31 | |
| 32 | def _which_map(mapping): |
| 33 | """shutil.which side_effect resolving only the names in ``mapping``.""" |
| 34 | def _which(name, *args, **kwargs): |
| 35 | return mapping.get(name) |
| 36 | return _which |
| 37 | |
| 38 | |
| 39 | def _completed(rc=0, stdout="", stderr=""): |
| 40 | return subprocess.CompletedProcess(args=["x"], returncode=rc, stdout=stdout, stderr=stderr) |
| 41 | |
| 42 | |
| 43 | class TestMissing: |
| 44 | """Scenario 1: binary absent from PATH -> missing + installer prescription.""" |
| 45 | |
| 46 | def test_ytdlp_missing_brew_prescription(self): |
| 47 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"brew": "/opt/homebrew/bin/brew"})), \ |
| 48 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 49 | probe = health.probe_dependency("yt-dlp") |
| 50 | assert probe.status == health.MISSING |
| 51 | assert probe.off_path is False # genuinely absent, not merely off-PATH |
| 52 | assert probe.prescription == "brew install yt-dlp" |
| 53 | assert probe.owner_pkg_manager == "brew" |
| 54 | assert "not found on PATH" in probe.detail |
| 55 | |
| 56 | def test_ytdlp_missing_pipx_prescription_when_no_brew(self): |
| 57 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"pipx": "/usr/local/bin/pipx"})), \ |
| 58 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 59 | probe = health.probe_dependency("yt-dlp") |
| 60 | assert probe.status == health.MISSING |
| 61 | assert probe.prescription == "pipx install yt-dlp" |
| 62 | assert probe.owner_pkg_manager == "pipx" |
| 63 | |
| 64 | def test_pp_cli_missing_prescribes_printing_press_install(self): |
| 65 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({})), \ |
| 66 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 67 | probe = health.probe_dependency("digg-pp-cli") |
| 68 | assert probe.status == health.MISSING |
| 69 | assert "printing-press-library" in probe.prescription |
| 70 | assert "install digg --cli-only" in probe.prescription |
| 71 | assert probe.owner_pkg_manager == "npx" |
| 72 | |
| 73 | def test_node_missing_nvm_prescription_when_no_brew(self, monkeypatch, tmp_path): |
| 74 | monkeypatch.setenv("NVM_DIR", str(tmp_path)) |
| 75 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({})), \ |
| 76 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 77 | probe = health.probe_dependency("node") |
| 78 | assert probe.status == health.MISSING |
| 79 | assert "nvm install" in probe.prescription |
| 80 | assert probe.owner_pkg_manager == "nvm" |
| 81 | |
| 82 | def test_ffmpeg_missing_apt_prescription_when_no_brew(self): |
| 83 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"apt-get": "/usr/bin/apt-get"})), \ |
| 84 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 85 | probe = health.probe_dependency("ffmpeg") |
| 86 | assert probe.status == health.MISSING |
| 87 | assert "apt-get install" in probe.prescription |
| 88 | assert probe.owner_pkg_manager == "apt" |
| 89 | |
| 90 | def test_missing_with_no_manager_still_prescribes_something(self, monkeypatch): |
| 91 | monkeypatch.delenv("NVM_DIR", raising=False) |
| 92 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({})), \ |
| 93 | mock.patch.object(health, "_nvm_present", return_value=False), \ |
| 94 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 95 | probe = health.probe_dependency("yt-dlp") |
| 96 | assert probe.status == health.MISSING |
| 97 | assert probe.prescription # never an empty prescription for missing |
| 98 | assert probe.owner_pkg_manager == "" |
| 99 | |
| 100 | |
| 101 | class TestBroken: |
| 102 | """Scenario 2: which resolves but exec fails -> broken + REINSTALL prescription.""" |
| 103 | |
| 104 | def test_stale_shim_exec_oserror_is_broken_not_ok(self): |
| 105 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"yt-dlp": "/x/yt-dlp", "brew": "/x/brew"})), \ |
| 106 | mock.patch.object(health.subprocess, "run", side_effect=OSError("exec format error")): |
| 107 | probe = health.probe_dependency("yt-dlp") |
| 108 | assert probe.status == health.BROKEN |
| 109 | assert probe.prescription == "brew reinstall yt-dlp" |
| 110 | assert "reinstall" in probe.prescription.lower() |
| 111 | |
| 112 | def test_nonzero_version_exit_is_broken(self): |
| 113 | fake = _completed(rc=1, stderr="ModuleNotFoundError: No module named 'yt_dlp'") |
| 114 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"yt-dlp": "/x/yt-dlp", "brew": "/x/brew"})), \ |
| 115 | mock.patch.object(health.subprocess, "run", return_value=fake): |
| 116 | probe = health.probe_dependency("yt-dlp") |
| 117 | assert probe.status == health.BROKEN |
| 118 | assert "ModuleNotFoundError" in probe.detail |
| 119 | assert "reinstall" in probe.prescription.lower() |
| 120 | |
| 121 | def test_broken_pp_cli_prescribes_rerunning_install(self): |
| 122 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"digg-pp-cli": "/x/digg-pp-cli"})), \ |
| 123 | mock.patch.object(health.subprocess, "run", side_effect=OSError("bad exec")): |
| 124 | probe = health.probe_dependency("digg-pp-cli") |
| 125 | assert probe.status == health.BROKEN |
| 126 | assert "printing-press-library" in probe.prescription |
| 127 | assert "install digg --cli-only" in probe.prescription |
| 128 | |
| 129 | def test_real_stale_shim_on_disk(self, tmp_path, monkeypatch): |
| 130 | """Integration: a real file whose shebang interpreter is gone (#692).""" |
| 131 | shim = tmp_path / "fake-pp-cli" |
| 132 | shim.write_text("#!/nonexistent-interpreter/python3\nprint('hi')\n") |
| 133 | shim.chmod(shim.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) |
| 134 | monkeypatch.setenv("PATH", str(tmp_path)) |
| 135 | probe = health.probe_dependency("fake-pp-cli") |
| 136 | assert probe.status == health.BROKEN |
| 137 | fix = probe.prescription.lower() |
| 138 | assert "reinstall" in fix or "re-run" in fix |
| 139 | |
| 140 | |
| 141 | class TestTimeout: |
| 142 | """Scenario 3: slow probe -> timeout, distinct message, bounded budget.""" |
| 143 | |
| 144 | def test_timeout_status_and_message(self): |
| 145 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"ffmpeg": "/x/ffmpeg", "brew": "/x/brew"})), \ |
| 146 | mock.patch.object( |
| 147 | health.subprocess, "run", |
| 148 | side_effect=subprocess.TimeoutExpired(cmd=["ffmpeg"], timeout=health.PROBE_TIMEOUT), |
| 149 | ): |
| 150 | probe = health.probe_dependency("ffmpeg") |
| 151 | assert probe.status == health.TIMEOUT |
| 152 | assert "timed out" in probe.detail |
| 153 | |
| 154 | def test_probe_budget_is_bounded(self): |
| 155 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"node": "/x/node", "brew": "/x/brew"})), \ |
| 156 | mock.patch.object(health.subprocess, "run", return_value=_completed(stdout="v22.1.0")) as run: |
| 157 | health.probe_dependency("node") |
| 158 | assert run.call_args.kwargs["timeout"] <= health.PROBE_TIMEOUT |
| 159 | |
| 160 | |
| 161 | class TestOk: |
| 162 | """Scenario 4: healthy binary -> ok, no prescription.""" |
| 163 | |
| 164 | def test_healthy_binary_ok_no_prescription(self): |
| 165 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"yt-dlp": "/x/yt-dlp", "brew": "/x/brew"})), \ |
| 166 | mock.patch.object(health.subprocess, "run", return_value=_completed(stdout="2026.06.09\n")): |
| 167 | probe = health.probe_dependency("yt-dlp") |
| 168 | assert probe.status == health.OK |
| 169 | assert probe.ok |
| 170 | assert probe.prescription == "" |
| 171 | assert "2026.06.09" in probe.detail |
| 172 | |
| 173 | def test_real_healthy_binary_end_to_end(self, tmp_path, monkeypatch): |
| 174 | """Integration: a real executable on a real PATH, no mocks.""" |
| 175 | binary = tmp_path / "fake-pp-cli" |
| 176 | binary.write_text("#!/bin/sh\necho 1.2.3\n") |
| 177 | binary.chmod(binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) |
| 178 | monkeypatch.setenv("PATH", f"{tmp_path}{os.pathsep}/bin{os.pathsep}/usr/bin") |
| 179 | probe = health.probe_dependency("fake-pp-cli") |
| 180 | assert probe.status == health.OK |
| 181 | assert "1.2.3" in probe.detail |
| 182 | assert probe.prescription == "" |
| 183 | |
| 184 | |
| 185 | class TestOffPath: |
| 186 | """Scenario 5: on disk but off PATH -> missing + PATH-fix, never ok.""" |
| 187 | |
| 188 | def test_off_path_binary_is_missing_with_path_fix(self, tmp_path): |
| 189 | binary = tmp_path / "digg-pp-cli" |
| 190 | binary.write_text("#!/bin/sh\necho ok\n") |
| 191 | binary.chmod(binary.stat().st_mode | stat.S_IXUSR) |
| 192 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"npx": "/x/npx"})), \ |
| 193 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[tmp_path]): |
| 194 | probe = health.probe_dependency("digg-pp-cli") |
| 195 | assert probe.status == health.MISSING |
| 196 | assert probe.off_path is True |
| 197 | assert "PATH" in probe.prescription |
| 198 | assert str(tmp_path) in probe.prescription or "$HOME" in probe.prescription |
| 199 | assert str(binary) in probe.detail or "$HOME" in probe.detail |
| 200 | |
| 201 | def test_off_path_never_reports_ok(self, tmp_path): |
| 202 | binary = tmp_path / "yt-dlp" |
| 203 | binary.write_text("#!/bin/sh\necho 2026.06.09\n") |
| 204 | binary.chmod(binary.stat().st_mode | stat.S_IXUSR) |
| 205 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"brew": "/x/brew"})), \ |
| 206 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[tmp_path]): |
| 207 | probe = health.probe_dependency("yt-dlp") |
| 208 | assert probe.status != health.OK |
| 209 | assert not probe.ok |
| 210 | |
| 211 | |
| 212 | class TestCachingAndRegistry: |
| 213 | """Probes are memoized per process; the registry covers all doctor deps.""" |
| 214 | |
| 215 | def test_probe_memoized_single_subprocess(self): |
| 216 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"node": "/x/node", "brew": "/x/brew"})), \ |
| 217 | mock.patch.object(health.subprocess, "run", return_value=_completed(stdout="v22.1.0")) as run: |
| 218 | first = health.probe_dependency("node") |
| 219 | second = health.probe_dependency("node") |
| 220 | assert run.call_count == 1 |
| 221 | assert first is second |
| 222 | |
| 223 | def test_clear_cache_reprobes(self): |
| 224 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({"node": "/x/node", "brew": "/x/brew"})), \ |
| 225 | mock.patch.object(health.subprocess, "run", return_value=_completed(stdout="v22.1.0")) as run: |
| 226 | health.probe_dependency("node") |
| 227 | health.clear_dependency_probe_cache() |
| 228 | health.probe_dependency("node") |
| 229 | assert run.call_count == 2 |
| 230 | |
| 231 | def test_probe_dependencies_covers_known_set(self): |
| 232 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({})), \ |
| 233 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 234 | probes = health.probe_dependencies() |
| 235 | assert set(probes) == set(health.KNOWN_DEPENDENCIES) |
| 236 | assert {"yt-dlp", "digg-pp-cli", "node", "ffmpeg"} <= set(probes) |
| 237 | for probe in probes.values(): |
| 238 | assert probe.status == health.MISSING |
| 239 | assert probe.prescription |
| 240 | |
| 241 | def test_any_pp_cli_name_gets_printing_press_prescription(self): |
| 242 | with mock.patch.object(health.shutil, "which", side_effect=_which_map({})), \ |
| 243 | mock.patch.object(health, "_off_path_candidate_dirs", return_value=[]): |
| 244 | probe = health.probe_dependency("espn-pp-cli") |
| 245 | assert probe.status == health.MISSING |
| 246 | assert "install espn --cli-only" in probe.prescription |
| 247 | assert probe.owner_pkg_manager == "npx" |
| 248 | |
| 249 | |
| 250 | class TestWindowsPrintingPressCandidates: |
| 251 | """F15 regression (docs/solutions/integration-issues/ |
| 252 | digg-cli-agent-path-setup-wizard.md): the Windows managed install dir |
| 253 | (%LOCALAPPDATA%/Programs/PrintingPress/bin) must be in the shared |
| 254 | candidate-dir source so an installed-but-off-PATH digg-pp-cli gets a |
| 255 | PATH fix, never "never installed". |
| 256 | |
| 257 | os.name is patched only in health's namespace (a delegating stub) — |
| 258 | patching the global os.name would flip pathlib to WindowsPath and break |
| 259 | Path construction on posix. |
| 260 | """ |
| 261 | |
| 262 | class _NtOs: |
| 263 | """Delegates to the real os module but reports name == 'nt'.""" |
| 264 | name = "nt" |
| 265 | |
| 266 | def __getattr__(self, attr): |
| 267 | return getattr(os, attr) |
| 268 | |
| 269 | def _nt(self): |
| 270 | return mock.patch.object(health, "os", self._NtOs()) |
| 271 | |
| 272 | def test_windows_dir_in_candidates_when_localappdata_set(self, tmp_path): |
| 273 | with self._nt(), \ |
| 274 | mock.patch.dict(os.environ, {"LOCALAPPDATA": str(tmp_path)}): |
| 275 | dirs = health._off_path_candidate_dirs() |
| 276 | assert tmp_path / "Programs" / "PrintingPress" / "bin" in dirs |
| 277 | |
| 278 | def test_windows_dir_absent_without_localappdata(self): |
| 279 | env_clean = {k: v for k, v in os.environ.items() |
| 280 | if k.lower() != "localappdata"} |
| 281 | with self._nt(), \ |
| 282 | mock.patch.dict(os.environ, env_clean, clear=True): |
| 283 | assert health.windows_printing_press_bin_dir() is None |
| 284 | |
| 285 | def test_posix_has_no_windows_dir(self, tmp_path): |
| 286 | with mock.patch.dict(os.environ, {"LOCALAPPDATA": str(tmp_path)}): |
| 287 | assert health.windows_printing_press_bin_dir() is None |
| 288 | |
| 289 | def test_windows_off_path_digg_reports_path_fix(self, tmp_path): |
| 290 | # Binary present ONLY in the PrintingPress dir: missing + off_path + |
| 291 | # PATH prescription — the documented failure mode said "never installed". |
| 292 | pp_dir = tmp_path / "Programs" / "PrintingPress" / "bin" |
| 293 | pp_dir.mkdir(parents=True) |
| 294 | binary = pp_dir / "digg-pp-cli.exe" |
| 295 | binary.write_text("#!/bin/sh\necho ok\n") |
| 296 | binary.chmod(binary.stat().st_mode | stat.S_IXUSR) |
| 297 | fake_home = tmp_path / "home" |
| 298 | with self._nt(), \ |
| 299 | mock.patch.dict(os.environ, |
| 300 | {"LOCALAPPDATA": str(tmp_path), |
| 301 | "GOPATH": str(tmp_path / "gopath")}), \ |
| 302 | mock.patch.object(health.Path, "home", return_value=fake_home), \ |
| 303 | mock.patch.object(health.shutil, "which", |
| 304 | side_effect=_which_map({"npx": "/x/npx"})): |
| 305 | probe = health.probe_dependency("digg-pp-cli") |
| 306 | assert probe.status == health.MISSING |
| 307 | assert probe.off_path is True |
| 308 | assert "PATH" in probe.prescription |
| 309 | assert str(pp_dir) in probe.prescription or str(pp_dir) in probe.detail |
| 310 | |
| 311 | def test_setup_wizard_digg_candidates_derive_from_shared_dirs(self, tmp_path): |
| 312 | # setup_wizard appends the Digg filename variants to the SAME shared |
| 313 | # dir list health owns — including the .exe in the Windows managed dir. |
| 314 | from lib import setup_wizard |
| 315 | |
| 316 | with self._nt(), \ |
| 317 | mock.patch.dict(os.environ, {"LOCALAPPDATA": str(tmp_path)}): |
| 318 | shared = health.installer_bin_dirs() |
| 319 | candidates = setup_wizard._digg_bin_candidate_paths() |
| 320 | pp_dir = tmp_path / "Programs" / "PrintingPress" / "bin" |
| 321 | assert pp_dir in shared |
| 322 | assert [c.parent for c in candidates] == shared |
| 323 | assert pp_dir / "digg-pp-cli.exe" in candidates |
| 324 | for candidate in candidates: |
| 325 | if candidate.parent != pp_dir: |
| 326 | assert candidate.name == "digg-pp-cli" |
| 327 |