When SESSION_DIR has no session .json files and the index is empty/absent, print_startup_config() now scans sibling state directories and prints a diagnostic pointing the user at the populated sibling + the HERMES_WEBUI_STATE_DIR to set — fully fail-safe (try/except, warning-only, no behavior change). Closes #3915. Co-authored-by: Rod Boev <rod.boev@gmail.com>
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""Tests for STATE_DIR divergence warning (issue #3915).
|
|
|
|
When a user switches launch methods (bootstrap.py / ctl.sh / systemd), the
|
|
HERMES_WEBUI_STATE_DIR env var may differ from the previous run, leaving
|
|
the current state directory empty while session data exists in a sibling.
|
|
|
|
This test suite verifies that _warn_state_dir_divergence() detects this
|
|
condition and prints a diagnostic warning, helping users recover their sessions.
|
|
"""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
import pytest
|
|
|
|
|
|
def test_warn_when_session_dir_empty_and_sibling_has_data(capsys):
|
|
"""Warning should print when SESSION_DIR is empty but a sibling has sessions."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current (empty) state directory
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "sessions" / "_index.json"):
|
|
# Import after patching to get the patched values
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
|
assert str(current_state) in captured.out
|
|
assert str(sibling_state) in captured.out
|
|
assert "HERMES_WEBUI_STATE_DIR" in captured.out
|
|
|
|
|
|
def test_no_warn_when_session_dir_has_files(capsys):
|
|
"""No warning should print when SESSION_DIR has session files."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with session data
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
(current_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session2.json").write_text('{"id": "s2"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "sessions" / "_index.json"):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
|
|
|
|
|
def test_no_warn_when_index_file_has_valid_content(capsys):
|
|
"""No warning should print when SESSION_INDEX_FILE has valid JSON content."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with valid index file but no session files
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
index_file.write_text('{"sessions": ["s1", "s2"]}')
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
|
|
|
|
|
def test_no_warn_when_no_sibling_with_data(capsys):
|
|
"""No warning should print when no sibling has session data."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current (empty) state directory
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
|
|
# Create sibling without sessions directory
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "sessions" / "_index.json"):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
|
|
|
|
|
def test_warn_with_empty_index_file(capsys):
|
|
"""Warning should print when SESSION_INDEX_FILE is empty."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with empty index file
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
index_file.write_text("")
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
|
|
|
|
|
def test_warn_with_index_file_containing_null(capsys):
|
|
"""Warning should print when SESSION_INDEX_FILE contains only 'null'."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with null index file
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
index_file.write_text("null")
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
|
|
|
|
|
def test_warn_with_index_file_containing_empty_object(capsys):
|
|
"""Warning should print when SESSION_INDEX_FILE contains only '{}'."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with {} index file
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
index_file.write_text("{}")
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
|
|
|
|
|
def test_warn_with_index_file_containing_empty_array(capsys):
|
|
"""Warning should print when SESSION_INDEX_FILE contains only '[]'."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with [] index file
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
index_file.write_text("[]")
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
|
|
|
|
|
def test_exception_handling_missing_session_dir(capsys):
|
|
"""Function should not crash if SESSION_DIR doesn't exist."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory (without sessions subdir)
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "nonexistent"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "nonexistent" / "_index.json"):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
# Should not raise
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
# No output expected since SESSION_DIR doesn't exist and parent iteration is safe
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
|
|
|
|
|
def test_exception_handling_permission_error(capsys):
|
|
"""Function should not crash on permission errors when reading files."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory with unreadable index file
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
index_file.write_text("")
|
|
|
|
# Create sibling state directory with session data
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
|
|
|
# Mock the global config variables and patch open to raise PermissionError
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
with mock.patch("builtins.open", side_effect=PermissionError("Access denied")):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
# Should not raise
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
# Warning should print since index_file_empty stays True when open fails
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
|
|
|
|
|
def test_exception_handling_glob_error(capsys):
|
|
"""Function should not crash on exception during glob operations."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmpdir = Path(tmpdir)
|
|
|
|
# Create current state directory
|
|
current_state = tmpdir / "current"
|
|
current_state.mkdir()
|
|
(current_state / "sessions").mkdir()
|
|
index_file = current_state / "sessions" / "_index.json"
|
|
|
|
# Create sibling with sessions
|
|
sibling_state = tmpdir / "sibling"
|
|
sibling_state.mkdir()
|
|
(sibling_state / "sessions").mkdir()
|
|
|
|
# Mock the global config variables
|
|
with mock.patch("api.config.STATE_DIR", current_state):
|
|
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
|
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
|
import api.config as config
|
|
|
|
warn_prefix = "\033[33m[!!]\033[0m"
|
|
|
|
# Patch glob to raise an exception
|
|
original_glob = Path.glob
|
|
call_count = [0]
|
|
|
|
def mock_glob(self, pattern):
|
|
call_count[0] += 1
|
|
# First glob is in _warn_state_dir_divergence for SESSION_DIR
|
|
# Let it succeed for the first, fail on sibling scan
|
|
if call_count[0] > 1:
|
|
raise OSError("Simulated glob error")
|
|
return original_glob(self, pattern)
|
|
|
|
with mock.patch.object(Path, "glob", mock_glob):
|
|
# Should not raise due to outer try/except
|
|
config._warn_state_dir_divergence(warn_prefix)
|
|
|
|
captured = capsys.readouterr()
|
|
# No output expected since exception is caught in the try/except
|
|
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|