"""Tests for the batch of fixes from PRs #506-#521 (v0.50.47). Covers: - /root workspace unblocking (#510/#521) - Attached-files split guard (#521) - custom_providers model visibility (#515/#519) - Cron skill cache invalidation (#507/#508) - System (auto) theme (#504/#506/#509/#514) """ import pathlib import re REPO = pathlib.Path(__file__).parent.parent def read(rel): return (REPO / rel).read_text() # ── Group A: /root workspace ────────────────────────────────────────────────── class TestRootWorkspaceUnblocked: def test_root_not_in_blocked_system_roots(self): src = read("api/workspace.py") assert "Path('/root')" not in src, ( "/root must not be in _BLOCKED_SYSTEM_ROOTS — " "breaks deployments where Hermes runs as root" ) def test_etc_still_blocked(self): """Sanity: other dangerous paths remain blocked.""" src = read("api/workspace.py") assert "Path('/etc')" in src assert "Path('/proc')" in src def test_split_guard_present(self): src = read("api/streaming.py") assert "'\\n\\n[Attached files:' in msg_text" in src, ( "base_text split must guard against missing '[Attached files:' " "to avoid empty-string on plain messages" ) # ── Group B: custom_providers visibility ───────────────────────────────────── class TestCustomProvidersVisibility: def test_has_custom_providers_variable_present(self): src = read("api/config.py") assert "_has_custom_providers" in src, ( "_has_custom_providers variable must exist in get_available_models()" ) def test_discard_custom_conditional_on_no_custom_providers(self): src = read("api/config.py") assert "not _has_custom_providers" in src, ( "detected_providers.discard('custom') must be gated on " "'not _has_custom_providers'" ) def test_custom_providers_isinstance_check(self): src = read("api/config.py") assert "isinstance(_custom_providers_cfg, list)" in src, ( "_has_custom_providers must check isinstance(..., list)" ) # ── Group C: cron skill cache ───────────────────────────────────────────────── class TestCronSkillCacheInvalidation: def _panels_src(self): return read("static/panels.js") def test_cache_busted_on_form_open(self): src = self._panels_src() # toggleCronForm should set cache to null unconditionally m = re.search( r'function toggleCronForm\(\)\{.*?_cronSkillsCache=null', src, re.DOTALL ) assert m, ( "toggleCronForm must unconditionally null _cronSkillsCache " "before fetching skills" ) def test_cache_not_guarded_by_if_on_open(self): src = self._panels_src() # The old guard should be gone assert "if(!_cronSkillsCache)" not in src, ( "toggleCronForm should not use 'if(!_cronSkillsCache)' guard — " "cache must always be busted on open" ) def test_cache_busted_on_skill_save(self): src = self._panels_src() # After submitSkillSave's api() call, _cronSkillsCache must be nulled m = re.search( r'async function submitSkillSave\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null', src, re.DOTALL ) assert m, ( "_cronSkillsCache must be set to null in submitSkillSave() " "right after _skillsData = null" ) # ── Group D: System (auto) theme ────────────────────────────────────────────── class TestSystemTheme: def test_apply_theme_helper_in_boot_js(self): src = read("static/boot.js") assert "function _applyTheme(" in src, ( "_applyTheme helper function must be defined in boot.js" ) def test_apply_theme_resolves_system(self): src = read("static/boot.js") assert "normalized.theme==='system'" in src or "=== 'system'" in src, ( "_applyTheme must branch on 'system' to resolve via matchMedia" ) def test_apply_theme_uses_matchmedia(self): src = read("static/boot.js") assert "prefers-color-scheme" in src, ( "_applyTheme must use matchMedia('(prefers-color-scheme:dark)')" ) def test_load_settings_calls_apply_theme(self): src = read("static/boot.js") assert "_applyTheme(appearance.theme)" in src, ( "loadSettings must call _applyTheme() instead of direct data-theme assignment" ) def test_system_option_in_theme_picker(self): html = read("static/index.html") assert "_pickTheme('system')" in html, ( "Theme picker must include a system theme button" ) assert ">System<" in html, ( "Theme picker must show 'System' label" ) def test_theme_picker_uses_pick_theme(self): html = read("static/index.html") assert "_pickTheme(" in html, ( "Theme buttons must call _pickTheme()" ) def test_flicker_script_resolves_system(self): html = read("static/index.html") # The head flicker-prevention IIFE must handle 'system' assert "==='system'" in html or "=== 'system'" in html, ( "Flicker-prevention head script must resolve 'system' before setting data-theme" ) assert "legacy={slate:['dark','slate']" in html, ( "Flicker-prevention head script must normalize legacy theme names on first paint" ) def test_system_in_commands_themes_list(self): src = read("static/commands.js") assert "'system'" in src, ( "/theme command must include 'system' in the valid themes array" ) def test_commands_uses_apply_theme(self): src = read("static/commands.js") assert "_applyTheme(appearance.theme)" in src, ( "cmdTheme must call _applyTheme() with the normalized canonical theme" ) def test_commands_accept_legacy_theme_aliases(self): src = read("static/commands.js") assert "const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});" in src, ( "cmdTheme must accept legacy theme aliases and map them onto canonical appearance values" ) def test_panels_reverts_via_apply_theme(self): src = read("static/panels.js") assert "_applyTheme(_settingsThemeOnOpen)" in src or \ "_applyTheme(" in src, ( "_revertSettingsPreview must call _applyTheme() so 'system' " "is correctly re-activated on settings discard" ) def test_panels_saves_system_string_not_resolved(self): src = read("static/panels.js") assert "localStorage.getItem('hermes-theme')" in src, ( "_settingsThemeOnOpen must read from localStorage to preserve " "the 'system' string, not the resolved 'dark'/'light'" ) def test_i18n_cmd_theme_includes_system_english(self): src = read("static/i18n.js") assert "system/dark/light" in src, ( "English cmd_theme i18n key must include 'system' in the theme list" ) def test_i18n_cmd_theme_all_locales(self): src = read("static/i18n.js") count = src.count("system/dark/light") assert count >= 5, ( f"cmd_theme description should mention 'system' in all 5 locales; " f"found {count}" ) def test_theme_listener_cleanup_uses_stable_handler(self): src = read("static/boot.js") assert "_systemThemeMq&&_onSystemThemeChange" in src, ( "_applyTheme must track the active OS-theme listener so it can be removed cleanly" ) assert "removeEventListener('change',_onSystemThemeChange)" in src, ( "_applyTheme must remove the previous OS-theme listener before adding a new one" ) def test_panels_hydrates_appearance_before_models_fetch(self): src = read("static/panels.js") skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();") models_idx = src.index("const models=await api('/api/models');") assert skin_idx < models_idx, ( "loadSettingsPanel must hydrate theme/skin before awaiting /api/models, " "otherwise a slow model fetch can clobber an in-progress skin selection" )