返回 last30days-skill
test_health_probe_taxonomy.py
根目录 / tests / test_health_probe_taxonomy.py
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
327 lines PYTHON