fix/feat: batch fixes v0.50.47 — root workspace, custom providers, cron cache, system theme (PR #523)
This commit is contained in:
106
CHANGELOG.md
106
CHANGELOG.md
@@ -1,5 +1,111 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.47] fix/feat: batch fixes — root workspace, custom providers, cron cache, system theme
|
||||
|
||||
Synthesized from PRs #506, #507, #508, #509, #510, #514, #515, #519, #521.
|
||||
|
||||
### Fixes
|
||||
|
||||
**Allow /root as a workspace path** (PRs #510, #521 by @ccqqlo)
|
||||
Removes `/root` from `_BLOCKED_SYSTEM_ROOTS` in `api/workspace.py`, so
|
||||
deployments running as root (Docker, VPS) can set `/root` as their workspace
|
||||
without a "system directory" rejection.
|
||||
|
||||
**Guard against split on missing [Attached files:]** (PR #521 by @ccqqlo)
|
||||
`base_text` extraction in `api/streaming.py` now guards: `msg_text.split(...)[0]
|
||||
if ... in msg_text else msg_text`. Previously split on the empty case returned
|
||||
an empty string, causing attachment-matching to silently fail on messages with
|
||||
no attachments.
|
||||
|
||||
**custom_providers models visible regardless of active provider** (#515, #519 by @shruggr, @cloudyun888)
|
||||
`get_available_models()` in `api/config.py` no longer discards the 'custom'
|
||||
provider from `detected_providers` when the user has `custom_providers` entries
|
||||
in `config.yaml`. Previously, switching active_provider away from 'custom'
|
||||
hid all custom model definitions from the picker.
|
||||
|
||||
**Cron skill picker cache invalidated on form open and skill save** (PRs #507, #508 by @armorbreak001)
|
||||
`toggleCronForm()` now unconditionally nulls `_cronSkillsCache` before fetching,
|
||||
so skills created in the same session appear immediately. `submitSkillSave()` also
|
||||
nulls `_cronSkillsCache` after a successful write, mirroring the existing
|
||||
`_skillsData = null` pattern. Fixes #502.
|
||||
|
||||
### Features
|
||||
|
||||
**System (auto) theme following OS prefers-color-scheme** (#504 / PRs #506, #509, #514 by @armorbreak001, @cloudyun888)
|
||||
New "System (auto)" option in the theme picker follows the OS dark/light preference
|
||||
via `window.matchMedia`. Changes:
|
||||
- `static/boot.js`: `_applyTheme(name)` helper resolves 'system' via matchMedia,
|
||||
sets `data-theme`, and registers a MQ change listener for live OS tracking.
|
||||
`loadSettings()` calls `_applyTheme()` instead of direct assignment.
|
||||
- `static/index.html`: flicker-prevention script resolves 'system' before first
|
||||
paint. Adds "System (auto)" as first theme option. onchange calls `_applyTheme()`.
|
||||
- `static/commands.js`: adds 'system' to valid `/theme` names.
|
||||
- `static/panels.js`: `_settingsThemeOnOpen` reads from localStorage (preserves
|
||||
'system' string). `_revertSettingsPreview` calls `_applyTheme()`.
|
||||
- `static/i18n.js`: cmd_theme description lists 'system' first in all 5 locales.
|
||||
|
||||
### Tests
|
||||
|
||||
22 new tests in `tests/test_batch_fixes.py`.
|
||||
|
||||
Total tests: 1268 (was 1246)
|
||||
|
||||
|
||||
## [v0.50.46] feat: clarify dialog flow and refresh recovery (#520)
|
||||
|
||||
Adds a full clarify dialog UX for interactive agent questions — modeled after
|
||||
the approval card but for free-form clarification prompts.
|
||||
|
||||
### Backend
|
||||
|
||||
New `api/clarify.py` module with a per-session pending queue backed by
|
||||
`threading.Event` unblocking, gateway notify callbacks, duplicate deduplication
|
||||
while unresolved, and resolve/clear helpers.
|
||||
|
||||
Three new HTTP endpoints in `api/routes.py`:
|
||||
- `GET /api/clarify/pending` — poll for pending clarify prompt
|
||||
- `POST /api/clarify/respond` — resolve the pending prompt
|
||||
- `GET /api/clarify/inject_test` — loopback-only, for automated tests
|
||||
|
||||
`api/streaming.py` wires `clarify_callback` into `AIAgent.run_conversation()`.
|
||||
Emits `clarify` SSE events; blocks the tool flow until the user responds, times
|
||||
out (120s), or the stream is cancelled. Also adds a 409 guard on `chat/start` so
|
||||
page-refresh races return the active stream id instead of starting a duplicate.
|
||||
|
||||
### Frontend
|
||||
|
||||
`static/messages.js`: clarify card with numbered choices, Other button, and
|
||||
free-text input. Composer is locked while clarify is active. DOM self-heals if
|
||||
the card node is removed during a rerender. SSE `clarify` event listener plus
|
||||
1.5s fallback polling. Session switch and reconnect start/stop clarify polling.
|
||||
409 conflict flow reattaches to the active stream and queues the user message.
|
||||
`CLARIFY_MIN_VISIBLE_MS = 30000` timer dedup mirrors the approval card pattern.
|
||||
|
||||
`static/ui.js`: `lockComposerForClarify()` / `unlockComposerForClarify()` with
|
||||
saved-state restore. `updateSendBtn()` respects the disabled state.
|
||||
|
||||
`static/sessions.js`: `loadSession()` starts/stops clarify polling on switch
|
||||
and inflight reattach.
|
||||
|
||||
`static/index.html` / `static/style.css`: clarify card markup with ARIA roles
|
||||
and full responsive/mobile styles.
|
||||
|
||||
`static/i18n.js`: 6 new keys in all 5 locales (en, es, de, zh-Hans, zh-Hant).
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_clarify_unblock.py`: 14 new tests covering queue resolution,
|
||||
notify callbacks, clear-on-cancel, and all three HTTP endpoints.
|
||||
- `tests/test_sprint30.py`: 31 new clarify tests (HTML markup, CSS classes,
|
||||
i18n keys, messages.js functions, streaming registration flags).
|
||||
- `tests/test_sprint36.py`: expand search window for `setBusy` check after
|
||||
additional `stopClarifyPolling()` calls push it past the old 800-char limit.
|
||||
|
||||
Total tests: 1246 (was 1209)
|
||||
|
||||
Co-authored-by: franksong2702
|
||||
|
||||
|
||||
## [v0.50.45] fix: suppress N/A source_tag in session list (#429)
|
||||
|
||||
Feishu and WeChat sessions (and any session with an unrecognised or legacy
|
||||
|
||||
@@ -949,8 +949,11 @@ def get_available_models() -> dict:
|
||||
# THAT provider, not to a separate "Custom" group. hermes_cli reports
|
||||
# 'custom' as authenticated whenever base_url is set, which would otherwise
|
||||
# build a phantom "Custom" bucket next to the real provider's group. Drop
|
||||
# it unless the user explicitly chose 'custom' as their active provider.
|
||||
if active_provider and active_provider != "custom":
|
||||
# it unless (a) the user explicitly chose 'custom' as their active provider,
|
||||
# or (b) the user has custom_providers entries in config.yaml (those models
|
||||
# were already added above and should still be shown).
|
||||
_has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
|
||||
if active_provider and active_provider != "custom" and not _has_custom_providers:
|
||||
detected_providers.discard("custom")
|
||||
|
||||
# 5. Build model groups
|
||||
|
||||
@@ -595,7 +595,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
if m.get('role') == 'user':
|
||||
content = str(m.get('content', ''))
|
||||
# Match if content is part of the sent message or vice-versa
|
||||
base_text = msg_text.split('\n\n[Attached files:')[0].strip()
|
||||
base_text = msg_text.split('\n\n[Attached files:')[0].strip() if '\n\n[Attached files:' in msg_text else msg_text
|
||||
if base_text[:60] in content or content[:60] in msg_text:
|
||||
m['attachments'] = attachments
|
||||
break
|
||||
|
||||
@@ -243,7 +243,7 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
_BLOCKED_SYSTEM_ROOTS = {
|
||||
# Linux / macOS
|
||||
Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'),
|
||||
Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'), Path('/root'),
|
||||
Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'),
|
||||
Path('/lib'), Path('/lib64'), Path('/opt/homebrew'),
|
||||
}
|
||||
|
||||
|
||||
@@ -568,6 +568,21 @@ window.addEventListener('resize',()=>{
|
||||
};
|
||||
})();
|
||||
|
||||
// ── System theme helper ──────────────────────────────────────────────────────
|
||||
function _applyTheme(name){
|
||||
const resolved=(name==='system')
|
||||
?(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')
|
||||
:name;
|
||||
document.documentElement.dataset.theme=resolved||'dark';
|
||||
// Re-register OS change listener whenever system theme is active
|
||||
if(name==='system'){
|
||||
const mq=window.matchMedia('(prefers-color-scheme:dark)');
|
||||
const _onOsChange=()=>{ document.documentElement.dataset.theme=mq.matches?'dark':'light'; };
|
||||
mq.removeEventListener('change',_onOsChange);
|
||||
mq.addEventListener('change',_onOsChange);
|
||||
}
|
||||
}
|
||||
|
||||
function applyBotName(){
|
||||
const name=window._botName||'Hermes';
|
||||
document.title=name;
|
||||
@@ -594,8 +609,8 @@ function applyBotName(){
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
const _theme=s.theme||'dark';
|
||||
document.documentElement.dataset.theme=_theme;
|
||||
localStorage.setItem('hermes-theme',_theme);
|
||||
_applyTheme(_theme);
|
||||
document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
|
||||
@@ -121,14 +121,14 @@ async function cmdUsage(){
|
||||
}
|
||||
|
||||
async function cmdTheme(args){
|
||||
const themes=['dark','light','slate','solarized','monokai','nord','oled'];
|
||||
const themes=['system','dark','light','slate','solarized','monokai','nord','oled'];
|
||||
if(!args||!themes.includes(args.toLowerCase())){
|
||||
showToast(t('theme_usage')+themes.join('|'));
|
||||
return;
|
||||
}
|
||||
const themeName=args.toLowerCase();
|
||||
document.documentElement.dataset.theme=themeName;
|
||||
localStorage.setItem('hermes-theme',themeName);
|
||||
_applyTheme(themeName);
|
||||
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
|
||||
// Update settings dropdown if panel is open
|
||||
const sel=$('settingsTheme');
|
||||
|
||||
@@ -66,7 +66,7 @@ const LOCALES = {
|
||||
cmd_workspace: 'Switch workspace by name',
|
||||
cmd_new: 'Start a new chat session',
|
||||
cmd_usage: 'Toggle token usage display on/off',
|
||||
cmd_theme: 'Switch theme (dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_theme: 'Switch theme (system/dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_personality: 'Switch agent personality',
|
||||
cmd_skills: 'List available Hermes skills',
|
||||
available_commands: 'Available commands:',
|
||||
@@ -480,7 +480,7 @@ const LOCALES = {
|
||||
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
|
||||
cmd_new: 'Iniciar una nueva sesión de chat',
|
||||
cmd_usage: 'Activar o desactivar el uso de tokens',
|
||||
cmd_theme: 'Cambiar tema (dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_theme: 'Cambiar tema (system/dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_personality: 'Cambiar la personalidad del agente',
|
||||
cmd_skills: 'Listar las skills de Hermes disponibles',
|
||||
available_commands: 'Comandos disponibles:',
|
||||
@@ -884,7 +884,7 @@ const LOCALES = {
|
||||
cmd_workspace: 'Workspace nach Namen wechseln',
|
||||
cmd_new: 'Neue Chat-Sitzung starten',
|
||||
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
|
||||
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_theme: 'Theme wechseln (system/dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_personality: 'Agenten-Persönlichkeit wechseln',
|
||||
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
|
||||
available_commands: 'Verfügbare Befehle:',
|
||||
@@ -1096,7 +1096,7 @@ const LOCALES = {
|
||||
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
|
||||
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
|
||||
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
|
||||
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
|
||||
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08system/dark/light/slate/solarized/monokai/nord/oled\uff09',
|
||||
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
|
||||
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
|
||||
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
||||
@@ -1499,7 +1499,7 @@ const LOCALES = {
|
||||
cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340',
|
||||
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
|
||||
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
|
||||
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
|
||||
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08system/dark/light/slate/solarized/monokai/nord/oled\uff09',
|
||||
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
|
||||
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
|
||||
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hermes</title>
|
||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
||||
@@ -475,7 +475,8 @@
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
|
||||
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
|
||||
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="_applyTheme(this.value)">
|
||||
<option value="system">System (auto)</option>
|
||||
<option value="dark">Dark (default)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="slate">Slate (charcoal)</option>
|
||||
@@ -551,7 +552,7 @@
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.45</span>
|
||||
<span class="settings-version-badge">v0.50.47</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
|
||||
@@ -97,10 +97,9 @@ function toggleCronForm(){
|
||||
_renderCronSkillTags();
|
||||
const search=$('cronFormSkillSearch');
|
||||
if(search)search.value='';
|
||||
// Pre-fetch skills for the picker
|
||||
if(!_cronSkillsCache){
|
||||
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[];}).catch(()=>{});
|
||||
}
|
||||
// Always re-fetch skills to avoid stale cache
|
||||
_cronSkillsCache=null;
|
||||
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[];}).catch(()=>{});
|
||||
$('cronFormName').focus();
|
||||
}
|
||||
}
|
||||
@@ -485,6 +484,7 @@ async function submitSkillSave() {
|
||||
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
|
||||
showToast(_editingSkillName ? t('skill_updated') : t('skill_created'));
|
||||
_skillsData = null;
|
||||
_cronSkillsCache = null;
|
||||
toggleSkillForm();
|
||||
await loadSkills();
|
||||
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
|
||||
@@ -1112,7 +1112,7 @@ function toggleSettings(){
|
||||
if(!overlay) return;
|
||||
if(overlay.style.display==='none'){
|
||||
_settingsDirty = false;
|
||||
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark';
|
||||
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || document.documentElement.dataset.theme || 'dark';
|
||||
_settingsSection = 'conversation';
|
||||
overlay.style.display='';
|
||||
loadSettingsPanel();
|
||||
@@ -1150,8 +1150,9 @@ function _closeSettingsPanel(){
|
||||
// Revert live DOM/localStorage to what they were when the panel opened
|
||||
function _revertSettingsPreview(){
|
||||
if(_settingsThemeOnOpen){
|
||||
document.documentElement.dataset.theme = _settingsThemeOnOpen;
|
||||
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
|
||||
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
|
||||
else document.documentElement.dataset.theme = _settingsThemeOnOpen;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
199
tests/test_batch_fixes.py
Normal file
199
tests/test_batch_fixes.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""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 "name==='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(_theme)" in src, (
|
||||
"loadSettings must call _applyTheme() instead of direct data-theme assignment"
|
||||
)
|
||||
|
||||
def test_system_option_in_theme_select(self):
|
||||
html = read("static/index.html")
|
||||
assert 'value="system"' in html, (
|
||||
"Theme <select> must include <option value=\"system\">"
|
||||
)
|
||||
assert "System (auto)" in html, (
|
||||
"Theme picker must show 'System (auto)' label"
|
||||
)
|
||||
|
||||
def test_theme_select_uses_apply_theme_onchange(self):
|
||||
html = read("static/index.html")
|
||||
assert "_applyTheme(this.value)" in html, (
|
||||
"Theme <select> onchange must call _applyTheme(this.value)"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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(themeName)" in src, (
|
||||
"cmdTheme must call _applyTheme() to handle system resolution"
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
Reference in New Issue
Block a user