Files
hermes-webui/tests/test_499_tts_playback.py
T
nesquena-hermes 3f838fc31a release: v0.50.244 (#1308)
release: v0.50.244

Batch release of 4 PRs:

- #1303 (@fecolinhares) — TTS playback of agent responses via Web Speech API.
  Per-message speaker button + auto-read toggle + voice/rate/pitch in
  Settings. localStorage-only state. Closes #499.

- #1304 — Stale saved session 404 cleanup + structured api() errors.
  Salvaged from #1084. Independently approved on 358275e.

- #1306 — Cmd/Ctrl+K works while a conversation is busy.
  Salvaged from #1084. Independently approved on 2e8a239.

- #1307 — Sienna skin (warm clay & sand earth palette).
  Salvaged from #1084. Independently approved on 5cd79c8.

Tests: 3290 passed, 2 skipped, 3 xpassed, 0 failures (was 3254; +36 tests).

Independently reviewed and approved by nesquena (commit 47f0e0d). End-to-end
trace verified the TTS flow; security audit confirmed SpeechSynthesisUtterance
is plain-text-only with no XSS surface; behavioural harness confirmed
_stripForTTS handles all 12 markdown-stripping cases; bounds clamping on
rate/pitch verified; opt-in behavior verified.
2026-04-29 21:34:27 -07:00

196 lines
6.9 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"