diff --git a/tests/test_issue_windows_git_version_detection.py b/tests/test_issue_windows_git_version_detection.py index a8b3bc62..5812d633 100644 --- a/tests/test_issue_windows_git_version_detection.py +++ b/tests/test_issue_windows_git_version_detection.py @@ -10,15 +10,26 @@ after the server restarted with fixed code. The fix adds a Windows fallback that resolves git.exe from the Git-for-Windows registry ``InstallPath`` (and common install dirs) without relying on PATH. + +These tests mock the OS-specific surfaces (winreg, os.path.exists, +os.path.expandvars) so they exercise the Windows code path deterministically on +ANY host — the CI runners are Linux, where %VAR% expansion and native path +separators differ, so nothing here may depend on the running platform's real +environment or filesystem. """ -import os import sys from unittest.mock import MagicMock, patch import api.updates as updates +# Deterministic install root used by the winreg stub. Kept as a forward-slash +# string so the expected git path is identical regardless of the host's +# os.path.join separator (CI is Linux; dev is Windows). +FAKE_GIT_ROOT = 'C:/Program Files/Git' +FAKE_GIT_EXE = FAKE_GIT_ROOT + '/cmd/git.exe' -def _fake_winreg(): + +def _fake_winreg(install_path=FAKE_GIT_ROOT): """Minimal winreg stub whose OpenKey/QueryValueEx yield a Git InstallPath.""" mod = MagicMock() mod.HKEY_LOCAL_MACHINE = 0 @@ -35,43 +46,58 @@ def _fake_winreg(): return False mod.OpenKey.return_value = _Key() - mod.QueryValueEx.return_value = (r'C:\Program Files\Git', 1) + mod.QueryValueEx.return_value = (install_path, 1) return mod +def _patch_join_forward_slash(): + """Make os.path.join deterministic (forward slash) regardless of host OS. + + The production code builds candidates with os.path.join; on Linux CI that + uses '/', on Windows '\\'. Pin it so expected paths match on both. + """ + def fake_join(*parts): + return '/'.join(p.rstrip('/\\') for p in parts) + return patch.object(updates.os.path, 'join', side_effect=fake_join) + + def test_resolve_git_executable_uses_registry_on_windows(): winreg = _fake_winreg() - expected = os.path.join(r'C:\Program Files\Git', 'cmd', 'git.exe') - - def fake_exists(p): - return p == expected with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ - patch.object(updates.os.path, 'exists', side_effect=fake_exists): + _patch_join_forward_slash(), \ + patch.object(updates.os.path, 'exists', side_effect=lambda p: p == FAKE_GIT_EXE): resolved = updates._resolve_git_executable() - assert resolved == expected + assert resolved == FAKE_GIT_EXE def test_resolve_git_executable_probes_known_dirs_when_registry_absent(): - winreg = _fake_winreg() # Registry has no value -> QueryValueEx raises -> fall through to dir probe. + winreg = _fake_winreg() winreg.QueryValueEx.side_effect = OSError('no InstallPath') - program_files = os.environ.get('ProgramFiles', r'C:\Program Files') - expected = os.path.join(program_files, 'Git', 'cmd', 'git.exe') - def fake_exists(p): - return p == expected + # The code probes os.path.expandvars('%ProgramFiles%\\Git\\cmd\\git.exe'); + # mock expandvars deterministically so the test does not depend on the + # host's %VAR% expansion (Linux CI does not expand %ProgramFiles%). + probed = 'C:/Program Files/Git/cmd/git.exe' + + def fake_expandvars(s): + if 'ProgramFiles%' in s and '(x86)' not in s: + return probed + # Other candidates resolve to a sentinel that never "exists". + return '/nonexistent/' + s with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ - patch.object(updates.os.path, 'exists', side_effect=fake_exists): + patch.object(updates.os.path, 'expandvars', side_effect=fake_expandvars), \ + patch.object(updates.os.path, 'exists', side_effect=lambda p: p == probed): resolved = updates._resolve_git_executable() - assert resolved == expected + assert resolved == probed def test_resolve_git_executable_returns_none_on_windows_when_git_truly_absent(): @@ -81,6 +107,7 @@ def test_resolve_git_executable_returns_none_on_windows_when_git_truly_absent(): with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'expandvars', side_effect=lambda s: s), \ patch.object(updates.os.path, 'exists', return_value=False): resolved = updates._resolve_git_executable() @@ -103,14 +130,13 @@ def test_non_windows_never_touches_registry_fallback(): def test_detect_webui_version_recovers_via_windows_registry_fallback(tmp_path): winreg = _fake_winreg() - expected_git = os.path.join(r'C:\Program Files\Git', 'cmd', 'git.exe') def fake_exists(p): # git.exe resolves; no api/_version.py fallback file present. - return p == expected_git + return p == FAKE_GIT_EXE def fake_run(cmd, **kwargs): - assert cmd[0] == expected_git + assert cmd[0] == FAKE_GIT_EXE if cmd[1:] == ['describe', '--tags', '--always']: return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') if cmd[1:] == ['diff-index', '--quiet', 'HEAD', '--']: @@ -120,6 +146,7 @@ def test_detect_webui_version_recovers_via_windows_registry_fallback(tmp_path): with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ + _patch_join_forward_slash(), \ patch.object(updates.os.path, 'exists', side_effect=fake_exists), \ patch.object(updates, 'REPO_ROOT', tmp_path), \ patch.object(updates.subprocess, 'run', side_effect=fake_run):