diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index a81fd52501..a68ed36cae 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -16,6 +16,12 @@ import pytest # Helpers # --------------------------------------------------------------------------- +# Host-native managed-uv binary name: managed_uv_path() installs `uv` on +# POSIX and `uv.exe` on Windows. Fixtures must build what the real host +# resolves — no platform fake. +_UV_BINARY_NAME = "uv.exe" if sys.platform == "win32" else "uv" + + def _make_executable(path: Path) -> None: """Create a minimal fake uv binary at *path*.""" path.parent.mkdir(parents=True, exist_ok=True) @@ -158,11 +164,12 @@ class TestMacOSManagedPythonSigning: class TestResolveUv: def test_existing_executable(self, tmp_path): - _make_executable(tmp_path / "bin" / "uv") + uv = tmp_path / "bin" / _UV_BINARY_NAME + _make_executable(uv) with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): from hermes_cli.managed_uv import resolve_uv result = resolve_uv() - assert result == str(tmp_path / "bin" / "uv") + assert result == str(uv) def test_non_executable_file_returns_none(self, tmp_path): uv = tmp_path / "bin" / "uv" @@ -182,17 +189,20 @@ class TestResolveUv: class TestEnsureUv: def test_installs_if_missing(self, tmp_path): + uv = tmp_path / "bin" / _UV_BINARY_NAME with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \ + patch("hermes_cli.managed_uv._uv_version", return_value="uv 0.1.2"), \ patch("hermes_cli.managed_uv._install_uv") as mock_install: - # Simulate the installer creating the binary + # Simulate the installer creating the binary (host-native name: + # uv.exe on Windows, uv on POSIX). def fake_install(target): _make_executable(target) mock_install.side_effect = fake_install from hermes_cli.managed_uv import ensure_uv path = ensure_uv() - assert path == str(tmp_path / "bin" / "uv") + assert path == str(uv) mock_install.assert_called_once() def test_install_reports_runtime_repair_to_observer(self, tmp_path): @@ -217,13 +227,16 @@ class TestEnsureUv: ), patch( "hermes_cli.managed_uv._install_uv", side_effect=fake_install, + ), patch( + "hermes_cli.managed_uv._uv_version", + return_value="uv 0.1.2", ), patch( "hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=repair, ): path = ensure_uv(repair_observer=observed.append) - assert path == str(tmp_path / "bin" / "uv") + assert path == str(tmp_path / "bin" / _UV_BINARY_NAME) assert observed == [repair] @@ -329,25 +342,30 @@ class TestUpdateManagedUv: - def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path, monkeypatch): + def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path): """A recent success stamp must skip `uv self update` entirely while the vulnerable-runtime repair probe still runs (CVE repair is never gated).""" + import time + from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv - uv = tmp_path / "bin" / "uv" + uv = tmp_path / "bin" / _UV_BINARY_NAME _make_executable(uv) - # Fresh stamp under the isolated HERMES_HOME. - import hermes_constants - stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp" + # The stamp reader imports get_hermes_home separately from the binary + # resolver. Give both paths the same explicit test root. + stamp = tmp_path / "cache" / ".uv_self_update_stamp" stamp.parent.mkdir(parents=True, exist_ok=True) stamp.touch() + # File timestamps can lead time.time() briefly on Windows. Stay well + # inside the freshness window instead of racing its age >= 0 boundary. + recent = time.time() - 60 + os.utime(stamp, (recent, recent)) with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ - patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \ - patch( - "hermes_cli.managed_uv.repair_vulnerable_runtime", - return_value=RuntimeRepairResult("skipped"), - ) as mock_repair: + patch("hermes_cli.managed_uv._uv_self_update_stamp", return_value=stamp), \ + patch("hermes_cli.managed_uv.repair_vulnerable_runtime", + return_value=RuntimeRepairResult("skipped")) as mock_repair, \ + patch("hermes_cli.managed_uv.subprocess.run") as mock_run: result = update_managed_uv() assert result == str(uv) @@ -361,17 +379,19 @@ class TestUpdateManagedUv: from hermes_cli.managed_uv import UV_SELF_UPDATE_INTERVAL_SECONDS, update_managed_uv - uv = tmp_path / "bin" / "uv" + uv = tmp_path / "bin" / _UV_BINARY_NAME _make_executable(uv) - import hermes_constants - stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp" + # Keep the stamp and binary resolver in the same test root. + stamp = tmp_path / "cache" / ".uv_self_update_stamp" stamp.parent.mkdir(parents=True, exist_ok=True) stamp.touch() old = _time.time() - UV_SELF_UPDATE_INTERVAL_SECONDS - 60 _os.utime(stamp, (old, old)) with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv._uv_self_update_stamp", return_value=stamp), \ patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \ + patch("hermes_cli.managed_uv._uv_version", return_value="uv 0.2.0"), \ patch("hermes_cli.managed_uv.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0") update_managed_uv() @@ -464,47 +484,6 @@ class TestRuntimeRepair: assert not (root / ".hermes-runtime").exists() mock_install.assert_not_called() - def test_stage_candidate_sync_keeps_uv_project_config(self, tmp_path): - from hermes_cli.managed_uv import _stage_candidate_venv - - root = tmp_path / "checkout" - root.mkdir() - (root / "uv.lock").write_text("# lock\n", encoding="utf-8") - generation = root / ".hermes-runtime" / "python" / "gen" - python = generation / "bin" / "python" - python.parent.mkdir(parents=True) - python.write_text("py", encoding="utf-8") - - calls = [] - - def fake_run(argv, **kwargs): - calls.append((list(argv), kwargs.get("env"))) - return MagicMock(returncode=0) - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch( - "hermes_cli.managed_uv._smoke_candidate_venv", - return_value=(True, "", None), - ): - candidate = _stage_candidate_venv( - "uv", - project_root=root, - generation=generation, - python=python, - ) - - assert candidate is not None - assert len(calls) == 2 - venv_argv, venv_env = calls[0] - sync_argv, sync_env = calls[1] - assert venv_argv[:2] == ["uv", "venv"] - assert "--no-config" in venv_argv - assert venv_env.get("UV_NO_CONFIG") == "1" - assert sync_argv[:2] == ["uv", "sync"] - assert "--locked" in sync_argv - assert "--no-config" not in sync_argv - assert "UV_NO_CONFIG" not in sync_env - def test_failed_candidate_preserves_live_venv(self, tmp_path): from hermes_cli.managed_uv import ( _acquire_repair_lock, @@ -620,6 +599,54 @@ class TestRuntimeRepair: assert leftovers == [], f"no stale markers may remain: {leftovers}" +class TestStageCandidateVenvCrossPlatform: + """Candidate sync preserves project config and streams progress on every host.""" + + def test_sync_keeps_uv_project_config_and_merges_stderr(self, tmp_path): + import subprocess + + from hermes_cli.managed_uv import _stage_candidate_venv + + root = tmp_path / "checkout" + root.mkdir() + (root / "uv.lock").write_text("# lock\n", encoding="utf-8") + generation = root / ".hermes-runtime" / "python" / "gen" + python = generation / "bin" / "python" + python.parent.mkdir(parents=True) + python.write_text("py", encoding="utf-8") + + calls = [] + + def fake_run(argv, **kwargs): + calls.append((list(argv), kwargs)) + return MagicMock(returncode=0) + + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ + patch( + "hermes_cli.managed_uv._smoke_candidate_venv", + return_value=(True, "", None), + ): + candidate = _stage_candidate_venv( + "uv", + project_root=root, + generation=generation, + python=python, + ) + + assert candidate is not None + assert len(calls) == 2 + venv_argv, venv_kwargs = calls[0] + sync_argv, sync_kwargs = calls[1] + assert venv_argv[:2] == ["uv", "venv"] + assert "--no-config" in venv_argv + assert venv_kwargs["env"].get("UV_NO_CONFIG") == "1" + assert sync_argv[:2] == ["uv", "sync"] + assert "--locked" in sync_argv + assert "--no-config" not in sync_argv + assert "UV_NO_CONFIG" not in sync_kwargs["env"] + assert sync_kwargs["stderr"] == subprocess.STDOUT + + class TestRuntimeCutover: def test_os_lock_blocks_concurrent_repair_and_releases(self, tmp_path): from hermes_cli.managed_uv import _acquire_repair_lock, _release_repair_lock @@ -674,13 +701,23 @@ class TestRuntimeCutover: # --------------------------------------------------------------------------- class TestInstallUvInternals: - def test_posix_sets_uv_unmanaged_install(self, tmp_path): - target = tmp_path / "bin" / "uv" - with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix: - from hermes_cli.managed_uv import _install_uv - _install_uv(target) - mock_posix.assert_called_once() - call_env = mock_posix.call_args[0][0] + def test_installer_uses_host_branch_and_managed_directory(self, tmp_path): + """The native installer receives the managed directory, not a PATH default.""" + import hermes_cli.managed_uv as managed_uv + + target = tmp_path / "bin" / _UV_BINARY_NAME + with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix, \ + patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows: + managed_uv._install_uv(target) + + host_installer, other_installer = ( + (mock_windows, mock_posix) if sys.platform == "win32" + else (mock_posix, mock_windows)) + host_installer.assert_called_once() + other_installer.assert_not_called() + call_env = host_installer.call_args[0][0] + assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin") + if sys.platform != "win32": assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin") @@ -1252,10 +1289,16 @@ class TestDefaultLiveVenv: root = tmp_path / "checkout" root.mkdir() (root / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + # Host-native venv layout: bin/python on POSIX, Scripts/python.exe on + # Windows — what _venv_python() resolves on the real host. + if sys.platform == "win32": + bin_dir_name, python_name = "Scripts", "python.exe" + else: + bin_dir_name, python_name = "bin", "python" for d in dirs: - bin_dir = root / d / "bin" + bin_dir = root / d / bin_dir_name bin_dir.mkdir(parents=True) - (bin_dir / "python").write_text("py", encoding="utf-8") + (bin_dir / python_name).write_text("py", encoding="utf-8") return root def test_dot_venv_only_is_targeted(self, tmp_path):