Files
hermes-webui/tests/test_499_tts_playback.py
T
nesquena-hermes 0e9bd651a4 fix: TTS toggle CSS specificity collision (#1409) + Ollama env var bleed (#1410)
Two unrelated UX/Settings bugs, both small surgical fixes with regression
tests.

Issue #1409 — TTS toggle has no effect
=======================================
Reported via Discord: ticking Settings → Voice → "Text-to-Speech for
responses" did nothing. The speaker icon never appeared on assistant
messages despite the checkbox saving to localStorage correctly.

Root cause (CSS specificity collision):
  static/panels.js _applyTtsEnabled() set
    btn.style.display = enabled ? '' : 'none'
  on every .msg-tts-btn. The '' branch removes the inline override, after
  which the .msg-tts-btn { display:none; } rule from style.css re-hides the
  button. Both branches left the icon hidden, so the toggle has been
  silently broken since #499 first shipped the TTS feature.

Fix (body-class toggle, Option B from the issue):
  - panels.js: _applyTtsEnabled now toggles body.classList('tts-enabled')
  - style.css: new compound selector
      body.tts-enabled .msg-tts-btn { display:inline-flex; align-items:center; }
  - default-hidden rule (.msg-tts-btn{display:none;}) preserved so the icon
    stays hidden by default (CSS-only state)
  - boot.js paths that already call _applyTtsEnabled(localStorage…) work
    unchanged — the new function applies state at the body level instead of
    inline-styling individual buttons, so the rule survives renderMd()
    re-renders without re-querying every button

Verified end-to-end against live server: getComputedStyle on a probe
.msg-tts-btn returns display:flex when body has tts-enabled, display:none
when it doesn't. Two regression tests in TestIssue1409TtsToggleBodyClass
explicitly check for the body-class shape and forbid the broken inline-style
pattern.

Issue #1410 — Ollama (local) shows "API key configured" when only
              Ollama Cloud key is set
=================================================================
Reported via Discord: configuring Ollama Cloud lit up the local Ollama card
too. Both providers were mapped to OLLAMA_API_KEY in api/providers.py
_PROVIDER_ENV_VAR.

Root cause:
  api/providers.py:47-48
    "ollama":       "OLLAMA_API_KEY",
    "ollama-cloud": "OLLAMA_API_KEY",
  _provider_has_key("ollama") found the value the user set for Ollama Cloud
  and returned True. But the runtime code path in
  hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the base
  URL hostname is ollama.com (Ollama Cloud) — local Ollama is keyless by
  default and reaches a custom base URL with no auth. The WebUI was
  reporting "configured" for a key local Ollama doesn't even read.

Fix (Option A from the issue body, preferred):
  - Drop bare "ollama" from _PROVIDER_ENV_VAR with an inline comment
    explaining why
  - _provider_has_key("ollama") falls through to the config.yaml branch,
    which already supports providers.ollama.api_key for local users who
    genuinely need to set a token
  - ollama-cloud retains its OLLAMA_API_KEY mapping unchanged

Verified end-to-end against live server with OLLAMA_API_KEY=sk-cloud-key-test
in env: GET /api/providers reports has_key=True only for ollama-cloud, and
has_key=False for bare ollama. Two regression tests in
TestIssue1410OllamaEnvVarBleed cover the bleed-prevention case AND the
"local user with config.yaml api_key still reports configured" case to
guard against over-correction.

Tests
-----
3572 passed, 2 skipped, 3 xpassed (was 3567 — added 5 new regression tests).

Closes #1409
Closes #1410

Reported by @AvidFuturist (Discord, May 1 2026)
2026-05-01 17:14:51 +00:00

250 lines
9.6 KiB
Python

"""
Tests for #499: TTS playback of agent responses via Web Speech API.
Verifies that TTS utility functions, speaker button rendering, and
settings controls are present in the WebUI codebase.
"""
import os
import re
STATIC_DIR = os.path.join(os.path.dirname(__file__), '..', 'static')
def _read(filename):
return open(os.path.join(STATIC_DIR, filename), encoding='utf-8').read()
class TestTtsUtilityFunctions:
"""TTS core functions exist in ui.js."""
def test_strip_for_tts_exists(self):
src = _read('ui.js')
assert 'function _stripForTTS(' in src, \
"_stripForTTS function not found in ui.js"
def test_speak_message_exists(self):
src = _read('ui.js')
assert 'function speakMessage(' in src, \
"speakMessage function not found in ui.js"
def test_stop_tts_exists(self):
src = _read('ui.js')
assert 'function stopTTS(' in src, \
"stopTTS function not found in ui.js"
def test_auto_read_exists(self):
src = _read('ui.js')
assert 'function autoReadLastAssistant(' in src, \
"autoReadLastAssistant function not found in ui.js"
def test_strip_code_blocks(self):
"""_stripForTTS must remove ``` code blocks."""
src = _read('ui.js')
assert re.search(r'_stripForTTS.*```', src, re.DOTALL), \
"_stripForTTS must handle fenced code blocks"
def test_strip_media_paths(self):
"""_stripForTTS must replace MEDIA: paths."""
src = _read('ui.js')
assert 'MEDIA:' in src and 'a file' in src, \
"_stripForTTS must replace MEDIA: paths"
def test_uses_speech_synthesis(self):
"""speakMessage must use window.speechSynthesis."""
src = _read('ui.js')
assert 'SpeechSynthesisUtterance' in src, \
"speakMessage must create SpeechSynthesisUtterance"
assert 'speechSynthesis.speak' in src, \
"speakMessage must call speechSynthesis.speak"
class TestTtsSpeakerButton:
"""Speaker button is rendered on assistant messages."""
def test_tts_button_rendered(self):
"""ttsBtn must be generated for non-user messages."""
src = _read('ui.js')
assert 'msg-tts-btn' in src, \
"TTS button class not found in ui.js"
def test_tts_button_not_on_user_messages(self):
"""ttsBtn must only be added for non-user (assistant) messages."""
src = _read('ui.js')
# Find the ttsBtn definition — it should have !isUser guard
tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
assert '!isUser' in tts_line or 'isUser' in tts_line, \
"TTS button should have user-check guard"
def test_tts_button_in_footer(self):
"""ttsBtn must be included in the msg-actions span."""
src = _read('ui.js')
# The footHtml line should include ttsBtn
foot_lines = [l for l in src.splitlines() if 'footHtml' in l and 'msg-actions' in l]
assert any('ttsBtn' in l for l in foot_lines), \
"ttsBtn not included in footHtml msg-actions"
def test_tts_button_uses_volume_icon(self):
"""Speaker button should use volume-2 icon."""
src = _read('ui.js')
tts_line = [l for l in src.splitlines() if 'msg-tts-btn' in l][0]
assert 'volume-2' in tts_line, \
"TTS button should use volume-2 icon"
class TestTtsSettings:
"""TTS settings controls exist in the HTML and are wired in panels.js."""
def test_tts_enabled_checkbox(self):
src = _read('index.html')
assert 'settingsTtsEnabled' in src, \
"TTS enabled checkbox not found in index.html"
def test_tts_auto_read_checkbox(self):
src = _read('index.html')
assert 'settingsTtsAutoRead' in src, \
"TTS auto-read checkbox not found in index.html"
def test_tts_voice_selector(self):
src = _read('index.html')
assert 'settingsTtsVoice' in src, \
"TTS voice selector not found in index.html"
def test_tts_rate_slider(self):
src = _read('index.html')
assert 'settingsTtsRate' in src, \
"TTS rate slider not found in index.html"
def test_tts_pitch_slider(self):
src = _read('index.html')
assert 'settingsTtsPitch' in src, \
"TTS pitch slider not found in index.html"
def test_tts_settings_wired_in_panels(self):
"""TTS settings must be initialized in loadSettingsPanel."""
src = _read('panels.js')
assert 'settingsTtsEnabled' in src, \
"TTS enabled setting not wired in panels.js"
assert '_applyTtsEnabled' in src, \
"_applyTtsEnabled not called in panels.js"
def test_apply_tts_enabled_function(self):
"""_applyTtsEnabled must toggle msg-tts-btn display."""
src = _read('panels.js')
assert 'function _applyTtsEnabled(' in src, \
"_applyTtsEnabled function not found in panels.js"
class TestTtsI18n:
"""TTS i18n keys exist in the English locale."""
def test_tts_listen_key(self):
src = _read('i18n.js')
assert "tts_listen:" in src, \
"tts_listen key not found in i18n.js"
def test_tts_not_supported_key(self):
src = _read('i18n.js')
assert "tts_not_supported:" in src, \
"tts_not_supported key not found in i18n.js"
def test_tts_settings_keys(self):
src = _read('i18n.js')
for key in ['settings_label_tts', 'settings_label_tts_auto_read',
'settings_label_tts_voice', 'settings_label_tts_rate',
'settings_label_tts_pitch']:
assert f"{key}:" in src, f"{key} not found in i18n.js"
class TestTtsAutoRead:
"""Auto-read is triggered after SSE done event."""
def test_auto_read_called_in_messages(self):
src = _read('messages.js')
assert 'autoReadLastAssistant' in src, \
"autoReadLastAssistant not called in messages.js"
def test_tts_pause_on_composer_focus(self):
"""Speech should pause when user focuses the composer."""
src = _read('messages.js')
assert 'speechSynthesis.pause' in src, \
"speechSynthesis.pause not called in messages.js"
assert 'speechSynthesis.resume' in src, \
"speechSynthesis.resume not called in messages.js"
class TestTtsBoot:
"""TTS enabled state is applied on page load."""
def test_apply_tts_on_boot(self):
src = _read('boot.js')
assert '_applyTtsEnabled' in src, \
"_applyTtsEnabled not called in boot.js"
class TestTtsStyles:
"""TTS CSS styles exist."""
def test_tts_button_hidden_default(self):
src = _read('style.css')
assert '.msg-tts-btn' in src, \
".msg-tts-btn CSS class not found in style.css"
def test_tts_pulse_animation(self):
src = _read('style.css')
assert 'tts-pulse' in src, \
"tts-pulse animation not found in style.css"
class TestIssue1409TtsToggleBodyClass:
"""Regression: #1409 — TTS toggle had no effect because of CSS specificity collision.
Original bug: ``_applyTtsEnabled`` set ``btn.style.display=enabled?'':'none'``.
The empty-string branch removes the inline override, after which the
``.msg-tts-btn { display:none; }`` rule from style.css applies — so both
"enabled" and "disabled" states left the button hidden.
Fix: toggle a body-level class (``body.tts-enabled``) and gate the speaker
icon on a compound selector ``body.tts-enabled .msg-tts-btn``. This bypasses
the inline-style cascade collision and survives ``renderMd()`` re-renders.
"""
def test_apply_tts_enabled_uses_body_class(self):
"""_applyTtsEnabled must toggle the document body's `tts-enabled` class."""
src = _read('panels.js')
# The new shape: toggle body class instead of writing inline display
assert "document.body.classList.toggle('tts-enabled'" in src, (
"_applyTtsEnabled must toggle the body.tts-enabled class — see #1409. "
"Reverting to inline `style.display` will silently break the toggle "
"again because of the .msg-action-btn / .msg-tts-btn cascade."
)
def test_apply_tts_enabled_does_not_use_inline_display(self):
"""_applyTtsEnabled must NOT set inline `style.display` on .msg-tts-btn."""
src = _read('panels.js')
# Find the function body and check it doesn't set inline display
# on individual buttons (the broken pattern).
m = re.search(
r'function _applyTtsEnabled\([^)]*\)\s*\{(?P<body>[^}]*)\}',
src,
)
assert m, "_applyTtsEnabled function body not found in panels.js"
body = m.group('body')
assert '.style.display' not in body, (
"_applyTtsEnabled body must not set inline style.display — that's "
"the #1409 bug. Use body.classList.toggle('tts-enabled') instead."
)
def test_body_class_selector_in_css(self):
"""style.css must show .msg-tts-btn only when body.tts-enabled is set."""
src = _read('style.css')
assert 'body.tts-enabled .msg-tts-btn' in src, (
"Missing `body.tts-enabled .msg-tts-btn` selector in style.css — "
"without this rule the body class has no visual effect (#1409)."
)
# The default-hidden rule must still be present (so no body class = no icon).
assert '.msg-tts-btn{display:none;}' in src or \
re.search(r'\.msg-tts-btn\s*\{[^}]*display\s*:\s*none', src), (
"Default `.msg-tts-btn{display:none;}` rule must remain so the "
"icon is hidden by default (#1409)."
)