"""Regression coverage for #2905 — Windows upgrade stranding WebUI state. v0.51.134 (PR #2897) moved the Windows default Hermes home from ``%USERPROFILE%\\.hermes`` to ``%LOCALAPPDATA%\\hermes`` to match the agent. Upgrading users whose WebUI sessions/pins/settings still lived at the old location opened the app to an empty state — the data was intact on disk but at an address the new build no longer read. The fix makes ``_platform_default_hermes_home()`` prefer the populated legacy ``%USERPROFILE%\\.hermes`` ONLY when the new ``%LOCALAPPDATA%\\hermes`` location is not yet established. It is: * non-destructive — no files are moved (a move would be its own data-loss risk) * self-healing — affected users find their data on next launch, no action needed * surgical — fresh installs / already-migrated users / explicit overrides are completely unaffected. These tests fake Windows semantics on a POSIX CI host by swapping ``config.os`` for a shim whose ``name`` is ``'nt'`` and pointing HOME / LOCALAPPDATA at temp dirs. They assert the full truth table plus the no-regression guards. """ from __future__ import annotations import os from pathlib import Path import pytest import api.config as config import api.paths as paths class _WindowsOSShim: """Stand-in for the ``os`` module that reports ``name == 'nt'``.""" name = "nt" environ = os.environ def __getattr__(self, key): # delegate everything else to the real os return getattr(os, key) def getenv(self, *args, **kwargs): return os.getenv(*args, **kwargs) def _populate_webui_state(base: Path) -> None: (base / "webui" / "sessions").mkdir(parents=True, exist_ok=True) (base / "webui" / "settings.json").write_text('{"pinned":["a"]}', encoding="utf-8") (base / "config.yaml").write_text("model: x\n", encoding="utf-8") @pytest.fixture def windows_env(monkeypatch, tmp_path): """Yield (legacy_home, new_home) with Windows path semantics faked. Returns the two candidate base homes; the caller populates whichever it needs before calling ``config._platform_default_hermes_home()``. """ home = tmp_path / "userprofile" # %USERPROFILE% localappdata = tmp_path / "localappdata" # %LOCALAPPDATA% home.mkdir() localappdata.mkdir() legacy_home = home / ".hermes" new_home = localappdata / "hermes" monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "os", _WindowsOSShim()) monkeypatch.setenv("LOCALAPPDATA", str(localappdata)) monkeypatch.delenv("HERMES_HOME", raising=False) monkeypatch.delenv("HERMES_BASE_HOME", raising=False) return legacy_home, new_home def test_upgrade_fingerprint_prefers_populated_legacy_home(windows_env): """The #2905 bug case: legacy populated, new empty → resolve to legacy.""" legacy_home, new_home = windows_env _populate_webui_state(legacy_home) # new_home intentionally left empty result = config._platform_default_hermes_home() assert result == legacy_home, ( "Windows upgrade must not strand WebUI state: when %LOCALAPPDATA%/hermes " "is empty but %USERPROFILE%/.hermes holds the user's sessions/pins, the " "default home must resolve to the legacy location (#2905)." ) def test_fresh_install_uses_new_localappdata_home(windows_env): """Neither location populated → use the new %LOCALAPPDATA% default (no regression on #2840).""" legacy_home, new_home = windows_env # both empty result = config._platform_default_hermes_home() assert result == new_home def test_already_migrated_uses_new_home(windows_env): """New location populated → never reach back to legacy.""" legacy_home, new_home = windows_env _populate_webui_state(new_home) result = config._platform_default_hermes_home() assert result == new_home def test_both_populated_trusts_new_home(windows_env): """If both exist, the new location wins — we never silently divert an established %LOCALAPPDATA% install back to a stale legacy dir.""" legacy_home, new_home = windows_env _populate_webui_state(legacy_home) _populate_webui_state(new_home) result = config._platform_default_hermes_home() assert result == new_home def test_legacy_dir_present_but_empty_does_not_divert(windows_env): """An empty/initialized-but-stateless legacy dir must NOT trigger the fallback — otherwise a stray empty %USERPROFILE%/.hermes would shadow a fresh install.""" legacy_home, new_home = windows_env legacy_home.mkdir(parents=True) # exists but no webui/config/auth markers result = config._platform_default_hermes_home() assert result == new_home def test_does_nothing_on_posix(monkeypatch, tmp_path): """On POSIX (os.name != 'nt') the resolver always returns ~/.hermes, regardless of any LOCALAPPDATA value — the fix is Windows-only.""" home = tmp_path / "home" home.mkdir() monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "os", os) # real os.name is 'posix' on CI; do NOT swap in the Windows shim monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "lad")) monkeypatch.delenv("HERMES_HOME", raising=False) result = config._platform_default_hermes_home() assert result == home / ".hermes" def test_no_files_are_moved_by_resolution(windows_env): """The fix is non-destructive: resolving the home must not create, move, or delete anything at either location.""" legacy_home, new_home = windows_env _populate_webui_state(legacy_home) legacy_sessions = legacy_home / "webui" / "sessions" before_new_exists = new_home.exists() config._platform_default_hermes_home() # Legacy data untouched, new location not fabricated. assert legacy_sessions.is_dir() assert (legacy_home / "webui" / "settings.json").exists() assert new_home.exists() == before_new_exists class TestHermesHomeHasWebuiState: """Unit coverage for the marker-detection helper.""" def test_empty_or_missing_dir_is_not_state(self, tmp_path): assert paths._hermes_home_has_webui_state(tmp_path / "nope") is False empty = tmp_path / "empty" empty.mkdir() assert paths._hermes_home_has_webui_state(empty) is False def test_webui_sessions_marker_counts(self, tmp_path): (tmp_path / "webui" / "sessions").mkdir(parents=True) assert paths._hermes_home_has_webui_state(tmp_path) is True def test_webui_settings_marker_counts(self, tmp_path): (tmp_path / "webui").mkdir() (tmp_path / "webui" / "settings.json").write_text("{}", encoding="utf-8") assert paths._hermes_home_has_webui_state(tmp_path) is True def test_webui_dir_alone_counts(self, tmp_path): (tmp_path / "webui").mkdir() assert paths._hermes_home_has_webui_state(tmp_path) is True def test_agent_only_artifacts_do_not_count(self, tmp_path): """A home with ONLY agent files (config.yaml / auth.json) and no webui/ dir is NOT treated as WebUI state — otherwise a long-time agent user installing WebUI fresh would be wrongly diverted to the legacy dir.""" (tmp_path / "config.yaml").write_text("model: x\n", encoding="utf-8") (tmp_path / "auth.json").write_text("{}", encoding="utf-8") assert paths._hermes_home_has_webui_state(tmp_path) is False def test_profiles_base_home_uses_shared_path_helper(monkeypatch, tmp_path): """profiles._resolve_base_hermes_home() must share config's path helper so the active-profile pointer never diverges from config.STATE_DIR (#2905).""" import api.profiles as profiles sentinel = tmp_path / "sentinel-home" monkeypatch.delenv("HERMES_HOME", raising=False) monkeypatch.delenv("HERMES_BASE_HOME", raising=False) monkeypatch.setattr(paths, "_platform_default_hermes_home", lambda: sentinel) assert profiles._resolve_base_hermes_home() == sentinel