2026-04-30 04:34:27 +00:00
|
|
|
"""
|
|
|
|
|
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"
|
|
|
|
|
|
2026-06-28 02:09:22 +00:00
|
|
|
def test_speak_message_routes_openai(self):
|
|
|
|
|
src = _read('ui.js')
|
|
|
|
|
assert "if(engine==='openai')" in src, \
|
|
|
|
|
"speakMessage must branch for the OpenAI TTS engine"
|
|
|
|
|
assert '_playOpenaiTts(clean, btn);' in src, \
|
|
|
|
|
"speakMessage must route OpenAI TTS through _playOpenaiTts"
|
|
|
|
|
|
|
|
|
|
def test_auto_read_routes_openai(self):
|
|
|
|
|
src = _read('ui.js')
|
|
|
|
|
assert '_playOpenaiTts(clean, null);' in src, \
|
|
|
|
|
"autoReadLastAssistant must route OpenAI TTS through _playOpenaiTts"
|
|
|
|
|
|
2026-04-30 04:34:27 +00:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-06-28 02:09:22 +00:00
|
|
|
def test_tts_engine_includes_openai_option(self):
|
|
|
|
|
src = _read('index.html')
|
|
|
|
|
assert '<option value="openai">OpenAI TTS (server)</option>' in src, \
|
|
|
|
|
"settingsTtsEngine must expose the OpenAI server TTS option"
|
|
|
|
|
|
2026-04-30 04:34:27 +00:00
|
|
|
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"
|
|
|
|
|
|
2026-06-28 02:09:22 +00:00
|
|
|
def test_openai_voice_placeholder_in_panels(self):
|
|
|
|
|
src = _read('panels.js')
|
|
|
|
|
assert "engine==='openai'" in src, \
|
|
|
|
|
"panels.js must branch for the OpenAI TTS engine"
|
|
|
|
|
assert 'OpenAI voice (server-configured)' in src, \
|
|
|
|
|
"OpenAI TTS must present a server-configured voice placeholder"
|
|
|
|
|
|
2026-04-30 04:34:27 +00:00
|
|
|
|
|
|
|
|
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"
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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)."
|
|
|
|
|
)
|