| 1 | from __future__ import annotations |
| 2 | |
| 3 | import importlib |
| 4 | import sys |
| 5 | import types |
| 6 | from pathlib import Path |
| 7 | |
| 8 | |
| 9 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 10 | if str(PROJECT_ROOT) not in sys.path: |
| 11 | sys.path.insert(0, str(PROJECT_ROOT)) |
| 12 | |
| 13 | |
| 14 | from helpers import plugins, settings |
| 15 | |
| 16 | |
| 17 | def test_builtin_speech_plugins_are_discoverable_and_toggleable() -> None: |
| 18 | discovered = { |
| 19 | item.name: item |
| 20 | for item in plugins.get_enhanced_plugins_list( |
| 21 | custom=True, |
| 22 | builtin=True, |
| 23 | plugin_names=["_kokoro_tts", "_whisper_stt"], |
| 24 | ) |
| 25 | } |
| 26 | |
| 27 | assert "_kokoro_tts" in discovered |
| 28 | assert "_whisper_stt" in discovered |
| 29 | |
| 30 | assert discovered["_kokoro_tts"].always_enabled is False |
| 31 | assert discovered["_whisper_stt"].always_enabled is False |
| 32 | assert "agent" in discovered["_kokoro_tts"].settings_sections |
| 33 | assert "agent" in discovered["_whisper_stt"].settings_sections |
| 34 | |
| 35 | |
| 36 | def test_legacy_core_speech_artifacts_are_removed() -> None: |
| 37 | removed_paths = [ |
| 38 | "api/synthesize.py", |
| 39 | "api/transcribe.py", |
| 40 | "helpers/kokoro_tts.py", |
| 41 | "helpers/whisper.py", |
| 42 | "webui/components/chat/speech/speech-store.js", |
| 43 | "webui/components/settings/agent/speech.html", |
| 44 | "webui/components/settings/speech/microphone-setting-store.js", |
| 45 | "webui/components/settings/speech/microphone.html", |
| 46 | "webui/css/speech.css", |
| 47 | "webui/js/speech_browser.js", |
| 48 | ] |
| 49 | |
| 50 | for relative_path in removed_paths: |
| 51 | assert not (PROJECT_ROOT / relative_path).exists(), relative_path |
| 52 | |
| 53 | |
| 54 | def test_plugin_owned_voice_files_exist() -> None: |
| 55 | expected_paths = [ |
| 56 | "plugins/_kokoro_tts/plugin.yaml", |
| 57 | "plugins/_kokoro_tts/api/synthesize.py", |
| 58 | "plugins/_kokoro_tts/extensions/webui/page-head/runtime.html", |
| 59 | "plugins/_kokoro_tts/extensions/webui/voice-settings-main/kokoro-card.html", |
| 60 | "plugins/_whisper_stt/plugin.yaml", |
| 61 | "plugins/_whisper_stt/api/transcribe.py", |
| 62 | "plugins/_whisper_stt/extensions/webui/page-head/runtime.html", |
| 63 | "plugins/_whisper_stt/extensions/webui/chat-input-box-end/microphone-button.html", |
| 64 | "plugins/_whisper_stt/extensions/webui/voice-settings-main/whisper-card.html", |
| 65 | "plugins/_whisper_stt/webui/whisper-stt-store.js", |
| 66 | ] |
| 67 | |
| 68 | for relative_path in expected_paths: |
| 69 | assert (PROJECT_ROOT / relative_path).exists(), relative_path |
| 70 | |
| 71 | |
| 72 | def test_core_settings_no_longer_expose_legacy_speech_keys() -> None: |
| 73 | defaults = settings.get_default_settings() |
| 74 | output = settings.convert_out(defaults) |
| 75 | |
| 76 | legacy_keys = { |
| 77 | "tts_kokoro", |
| 78 | "stt_model_size", |
| 79 | "stt_language", |
| 80 | "stt_silence_threshold", |
| 81 | "stt_silence_duration", |
| 82 | "stt_waiting_timeout", |
| 83 | } |
| 84 | |
| 85 | assert legacy_keys.isdisjoint(defaults.keys()) |
| 86 | assert legacy_keys.isdisjoint(output["settings"].keys()) |
| 87 | assert "stt_models" not in output["additional"] |
| 88 | |
| 89 | |
| 90 | def test_voice_prefix_prompt_rule_is_removed() -> None: |
| 91 | core_prompt = (PROJECT_ROOT / "prompts/agent.system.main.communication_additions.md").read_text( |
| 92 | encoding="utf-8" |
| 93 | ) |
| 94 | whisper_store = ( |
| 95 | PROJECT_ROOT / "plugins/_whisper_stt/webui/whisper-stt-store.js" |
| 96 | ).read_text(encoding="utf-8") |
| 97 | voice_surface = (PROJECT_ROOT / "webui/components/settings/agent/voice.html").read_text( |
| 98 | encoding="utf-8" |
| 99 | ) |
| 100 | |
| 101 | assert "if starts (voice) then transcribed can contain errors consider compensation" not in core_prompt |
| 102 | assert "(voice)" not in whisper_store |
| 103 | assert not ( |
| 104 | PROJECT_ROOT / "plugins/_whisper_stt/prompts/agent.system.voice_transcription.md" |
| 105 | ).exists() |
| 106 | assert not ( |
| 107 | PROJECT_ROOT |
| 108 | / "plugins/_whisper_stt/extensions/python/system_prompt/_20_voice_transcription.py" |
| 109 | ).exists() |
| 110 | assert '<x-extension id="voice-settings-start"></x-extension>' in voice_surface |
| 111 | assert '<x-extension id="voice-settings-main"></x-extension>' in voice_surface |
| 112 | assert '<x-extension id="voice-settings-end"></x-extension>' in voice_surface |
| 113 | |
| 114 | |
| 115 | def test_whisper_message_mode_defaults_to_send_and_supports_draft() -> None: |
| 116 | sys.modules.setdefault( |
| 117 | "whisper", |
| 118 | types.SimpleNamespace(load_model=lambda *args, **kwargs: None), |
| 119 | ) |
| 120 | runtime = importlib.import_module("plugins._whisper_stt.helpers.runtime") |
| 121 | |
| 122 | assert runtime.normalize_config({})["message_mode"] == "send" |
| 123 | assert runtime.normalize_config({"message_mode": "draft"})["message_mode"] == "draft" |
| 124 | assert runtime.normalize_config({"message_mode": "DRAFT"})["message_mode"] == "draft" |
| 125 | assert runtime.normalize_config({"message_mode": "invalid"})["message_mode"] == "send" |
| 126 | |
| 127 | default_config = ( |
| 128 | PROJECT_ROOT / "plugins/_whisper_stt/default_config.yaml" |
| 129 | ).read_text(encoding="utf-8") |
| 130 | migration = ( |
| 131 | PROJECT_ROOT / "plugins/_whisper_stt/helpers/migration.py" |
| 132 | ).read_text(encoding="utf-8") |
| 133 | config_ui = ( |
| 134 | PROJECT_ROOT / "plugins/_whisper_stt/webui/config.html" |
| 135 | ).read_text(encoding="utf-8") |
| 136 | status_ui = ( |
| 137 | PROJECT_ROOT / "plugins/_whisper_stt/webui/main.html" |
| 138 | ).read_text(encoding="utf-8") |
| 139 | voice_card = ( |
| 140 | PROJECT_ROOT |
| 141 | / "plugins/_whisper_stt/extensions/webui/voice-settings-main/whisper-card.html" |
| 142 | ).read_text(encoding="utf-8") |
| 143 | whisper_store = ( |
| 144 | PROJECT_ROOT / "plugins/_whisper_stt/webui/whisper-stt-store.js" |
| 145 | ).read_text(encoding="utf-8") |
| 146 | |
| 147 | assert "message_mode: send" in default_config |
| 148 | assert '"message_mode": "send"' in migration |
| 149 | assert '<option value="send">Send immediately</option>' in config_ui |
| 150 | assert '<option value="draft">Draft in composer</option>' in config_ui |
| 151 | assert "messageModeLabel" in status_ui |
| 152 | assert "messageModeLabel" in voice_card |
| 153 | assert 'message_mode: "send"' in whisper_store |
| 154 | assert 'status?.config?.message_mode === "draft" ? "draft" : "send"' in whisper_store |
| 155 | assert "updateChatInput(message)" in whisper_store |
| 156 | assert "sendMessage()" in whisper_store |
| 157 | |
| 158 | |
| 159 | def test_browser_tool_speech_action_uses_shared_tts_service() -> None: |
| 160 | browser_handler = ( |
| 161 | PROJECT_ROOT |
| 162 | / "plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js" |
| 163 | ).read_text(encoding="utf-8") |
| 164 | |
| 165 | assert "/components/chat/speech/speech-store.js" not in browser_handler |
| 166 | assert "/js/tts-service.js" in browser_handler |
| 167 | assert "ttsService.speak(contentText)" in browser_handler |
| 168 | |
| 169 | |
| 170 | def test_chat_bar_keeps_existing_send_and_mic_icon_contract() -> None: |
| 171 | chat_bar = ( |
| 172 | PROJECT_ROOT / "webui/components/chat/input/chat-bar-input.html" |
| 173 | ).read_text(encoding="utf-8") |
| 174 | mic_extension = ( |
| 175 | PROJECT_ROOT |
| 176 | / "plugins/_whisper_stt/extensions/webui/chat-input-box-end/microphone-button.html" |
| 177 | ).read_text(encoding="utf-8") |
| 178 | whisper_store = ( |
| 179 | PROJECT_ROOT / "plugins/_whisper_stt/webui/whisper-stt-store.js" |
| 180 | ).read_text(encoding="utf-8") |
| 181 | whisper_css = ( |
| 182 | PROJECT_ROOT / "plugins/_whisper_stt/webui/whisper-stt.css" |
| 183 | ).read_text(encoding="utf-8") |
| 184 | |
| 185 | assert 'id="send-button"' in chat_bar |
| 186 | assert ':name="$store.chatInput.sendButtonIcon"' in chat_bar |
| 187 | assert ':class="$store.chatInput.sendButtonClass"' in chat_bar |
| 188 | assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar |
| 189 | |
| 190 | assert 'id="microphone-button"' in mic_extension |
| 191 | assert "data-whisper-microphone" in mic_extension |
| 192 | assert "<svg" in mic_extension |
| 193 | assert "material-symbols-outlined" not in mic_extension |
| 194 | assert "buttonIcon" not in mic_extension |
| 195 | assert 'title=' not in mic_extension |
| 196 | assert 'x-effect="$store.whisperStt.updateMicrophoneButtonUI()"' in mic_extension |
| 197 | assert 'x-init="$store.whisperStt.updateMicrophoneButtonUI()"' in mic_extension |
| 198 | assert "updateMicrophoneButtonUI()" in whisper_store |
| 199 | assert "data-status" in whisper_store |
| 200 | assert 'setAttribute("title"' not in whisper_store |
| 201 | assert 'removeAttribute("title")' in whisper_store |
| 202 | assert 'removeAttribute("data-bs-original-title")' in whisper_store |
| 203 | assert "this.updateMicrophoneButtonUI();" in whisper_store |
| 204 | assert "sttService.emitStatusChange(this.micStatus)" in whisper_store |
| 205 | |
| 206 | for state in [ |
| 207 | "disabled", |
| 208 | "inactive", |
| 209 | "activating", |
| 210 | "listening", |
| 211 | "recording", |
| 212 | "waiting", |
| 213 | "processing", |
| 214 | ]: |
| 215 | assert f'"mic-{state}"' in whisper_store |
| 216 | assert f"#microphone-button.mic-{state}" in whisper_css |
| 217 | assert f"#microphone-button.mic-{state}" in mic_extension |
| 218 | |
| 219 | assert "border-radius: 12px;" in whisper_css |
| 220 | assert "background-color: transparent;" in whisper_css |
| 221 | assert "#microphone-button.mic-inactive {\n color: grey;" in whisper_css |
| 222 | assert "background-color: grey;" not in whisper_css |
| 223 | assert "#microphone-button.mic-listening {\n color: red;" in whisper_css |
| 224 | assert "#microphone-button.mic-recording {\n color: green;" in whisper_css |
| 225 | assert "#microphone-button.mic-waiting {\n color: teal;" in whisper_css |
| 226 | assert "#microphone-button.mic-processing {\n color: darkcyan;" in whisper_css |
| 227 | assert "#microphone-button.mic-activating svg" in whisper_css |
| 228 | assert "#microphone-button.mic-processing svg" in whisper_css |
| 229 | assert "#microphone-button:not(.mic-disabled):hover svg" in whisper_css |
| 230 | assert "#microphone-button:not(.mic-disabled):active svg" in whisper_css |
| 231 | assert "box-shadow: none;" in whisper_css |
| 232 | assert "order: 1;" in mic_extension |
| 233 | assert "whisper-stt-mic-pulse 0.8s infinite" in mic_extension |
| 234 | assert "whisper-stt-mic-pulse 0.8s infinite" in whisper_css |