// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, nextTick, ref } from 'vue' import { mount, flushPromises } from '@vue/test-utils' const STT_STORAGE_KEY = 'hermes-stt-settings-v1' const mockFetchSttSettings = vi.fn() const mockSaveSttSettings = vi.fn() const mockSaveActiveSttProvider = vi.fn() const mockClearSttSecret = vi.fn() const mockDeleteSttProvider = vi.fn() const mockDeleteSttBaseUrlPreset = vi.fn() const mockTranscribeSpeech = vi.fn() const mockFetchTtsSettings = vi.fn() const mockSaveTtsSettings = vi.fn() const mockSaveActiveTtsProvider = vi.fn() const mockClearTtsSecret = vi.fn() const mockDeleteTtsProvider = vi.fn() const mockDeleteTtsBaseUrlPreset = vi.fn() const mockFetchProviderModels = vi.fn() const mockProbeVoiceProvider = vi.fn() const mockSpeechStop = vi.fn() const mockSpeakViaBrowser = vi.fn() const mockOpenaiPlay = vi.fn() const mockMimoPlay = vi.fn() const mockMicRecorderState = ref({ status: 'idle', error: null, startedAt: null, mimeType: null }) const mockMicStart = vi.fn() const mockMicStop = vi.fn() const translations: Record = { 'settings.voice.activeTtsApi': 'Active TTS API', 'settings.voice.activeSttApi': 'Active STT API', 'settings.voice.addTtsApi': 'Add TTS API', 'settings.voice.addSttApi': 'Add STT API', 'settings.voice.builtin': 'Built-in', 'settings.voice.source': 'Source', 'settings.voice.model': 'Model', 'settings.voice.voice': 'Voice', 'settings.voice.apiKey': 'API key', 'settings.voice.connected': 'Connected', 'settings.voice.active': 'Active', 'settings.voice.lastTest': 'Last test', 'settings.voice.notTested': '—', 'settings.voice.recording': 'Recording', 'settings.voice.testFailedShort': 'Failed', 'settings.voice.testFailedSummary': 'Test failed. Check provider settings and try again.', 'settings.voice.showDetails': 'Show details', 'settings.voice.hideDetails': 'Hide details', 'settings.voice.moreActions': 'More', 'settings.voice.moreActionsFor': 'More actions for {provider}', 'common.edit': 'Edit', 'settings.voice.ttsProvidersTitle': 'TTS providers', 'settings.voice.ttsProvidersDescription': 'Manage text-to-speech integrations used to play assistant responses and voice previews.', 'settings.voice.sttProvidersTitle': 'STT providers', 'settings.voice.sttProvidersDescription': 'Manage speech-to-text integrations used by the microphone and voice input flow.', 'settings.voice.noneSelected': 'None selected', 'settings.voice.keyStored': 'Stored', 'settings.voice.keyMissing': 'Missing', 'settings.voice.customApi': 'API', 'settings.voice.localBuiltin': 'Local built-in', 'settings.voice.ttsCardHint': 'Speech synthesis provider', 'settings.voice.sttCardHint': 'Speech transcription provider', 'settings.voice.setActive': 'Set active', 'settings.voice.testAction': 'Test', 'settings.voice.connect': 'Connect', 'settings.voice.presetEdgeTtsLabel': 'Edge TTS', 'settings.voice.presetEdgeTtsDescription': 'Free high-quality cloud voices from Microsoft Edge.', 'settings.voice.presetOpenaiTtsLabel': 'OpenAI TTS', 'settings.voice.presetMimoTtsLabel': 'MiMo TTS', 'settings.voice.presetCustomTtsLabel': 'Custom TTS', 'settings.voice.presetBrowserSttLabel': 'Browser STT', 'settings.voice.presetBrowserSttDescription': 'Native browser speech recognition API.', 'settings.voice.presetOpenaiWhisperLabel': 'OpenAI Whisper', 'settings.voice.presetGroqSttLabel': 'Groq STT', 'settings.voice.presetCustomSttLabel': 'Custom STT', 'settings.voice.apiCompatibility': 'API compatibility', 'settings.voice.openaiCompatible': 'OpenAI-compatible', 'settings.voice.manualCustomEndpoint': 'Manual custom endpoint', 'settings.voice.connectionStep': 'Connection', 'settings.voice.discoveryManualHint': 'Manual custom endpoint selected. Model discovery is not available; enter the model name manually.', 'settings.voice.discoveryCredentialHint': 'Enter Base URL and API key, then connect to fetch available models.', 'settings.voice.discoveryNoAutoProbeHint': 'No requests are sent while typing. Use Connect when the credentials are ready.', 'settings.voice.apiKeyConnectPlaceholder': 'Paste API key to connect; stored keys are never shown', 'settings.voice.connectFetchModels': 'Connect & fetch models', 'settings.voice.modelRecommendedSuffix': 'recommended', 'settings.voice.modelConnectManualHint': 'Connect to auto-fill, or enter manually.', 'settings.voice.modelRequiredConnectManual': 'Model is required. Connect to auto-fill, or enter manually.', 'settings.voice.apiKeyRequired': 'API key is required.', 'settings.voice.apiKeyRequiredForDiscovery': 'API key is required to fetch models.', 'settings.voice.baseUrlRequired': 'Base URL is required.', 'settings.voice.baseUrlHttpsRequired': 'Use https:// unless the endpoint is localhost.', 'settings.voice.baseUrlInvalid': 'Enter a valid Base URL, including https://.', 'settings.voice.discoveryFailedManualFallback': 'Could not fetch models. You can still enter the model name manually.', 'settings.voice.completeRequiredProviderFields': 'Complete the required provider fields before saving.', 'settings.voice.fetchingModels': 'Fetching models…', 'settings.voice.recommendedModel': 'Recommended: {model}.', 'settings.voice.useRecommendedModel': 'Use this.', 'settings.voice.modelsDiscovered': '{count} models discovered. Recommended model is selected when the field is empty.', 'settings.voice.remove': 'Remove', 'settings.voice.testSuccess': 'Test completed', 'settings.voice.testTextRequired': 'Enter text before testing.', 'settings.voice.keyMissingForTest': 'Add an API key before testing this provider.', 'settings.voice.testing': 'Testing…', 'settings.voice.transcribing': 'Transcribing…', 'settings.voice.browserSttTestHint': 'Browser STT is tested from the chat microphone.', 'settings.voice.sttEmptyAudio': 'No audio was captured. Try again.', 'settings.voice.sttTestSuccess': 'Transcription completed', 'settings.voice.sttRecordingHint': 'Recording… click Stop to transcribe.', 'settings.voice.keepStoredKeyPlaceholder': 'Leave blank to keep the stored key', 'settings.voice.apiKeyPlaceholder': 'Enter API key', 'settings.voice.ttsTestSaved': 'Audition succeeded; TTS settings saved on the server', 'settings.voice.ttsTestSaving': 'Saving TTS settings before audition…', 'settings.voice.ttsMissingBaseUrl': 'Base URL is required before testing. Enter a provider endpoint, then test again.', 'settings.voice.ttsMissingApiKey': 'API key is required before testing. Paste the key, or save a stored server key first.', 'settings.voice.testFailed': 'Test failed: {error}', 'settings.voice.testTextDefault': 'Hello, this is a voice test.', 'settings.voice.ttsProvider': 'TTS Provider', 'settings.voice.ttsProviderHint': 'Choose the text-to-speech engine for message playback', 'settings.voice.providerWebSpeech': 'WebSpeech API (Browser)', 'settings.voice.providerOpenai': 'OpenAI TTS', 'settings.voice.providerCustom': 'Custom Endpoint (OpenAI-compatible)', 'settings.voice.providerEdge': 'Edge TTS (Free, no API Key)', 'settings.voice.providerMimo': 'MiMo TTS', 'settings.voice.webspeechVoice': 'Voice', 'settings.voice.webspeechVoiceHint': 'Select a voice from your browser or OS', 'settings.voice.webspeechVoicePlaceholder': 'Auto (default voice)', 'settings.voice.testTitle': 'Test Voice', 'settings.voice.testTextPlaceholder': 'Enter text to test...', 'settings.voice.testButton': 'Test', 'settings.voice.testButtonPlaying': 'Playing...', 'settings.voice.sttTitle': 'Speech-to-text', 'settings.voice.sttProvider': 'STT Provider', 'settings.voice.sttProviderHint': 'Choose how voice input is transcribed', 'settings.voice.sttProviderBrowser': 'Browser', 'settings.voice.sttProviderOpenai': 'OpenAI-compatible', 'settings.voice.sttProviderCustom': 'Custom', 'settings.voice.sttBrowserHint': 'Use built-in browser speech recognition when available.', 'settings.voice.sttApiKey': 'API Key', 'settings.voice.sttApiKeyHint': 'Stored server-side for STT providers that require authentication.', 'settings.voice.sttStoredSecret': 'Stored server key: {value}', 'settings.voice.sttPendingSecret': 'Stored server key: will be updated on save', 'settings.voice.sttClearKey': 'Clear stored key', 'settings.voice.sttModel': 'Model', 'settings.voice.sttModelHint': 'Speech-to-text model to use.', 'settings.voice.sttLanguage': 'Language', 'settings.voice.sttLanguageHint': 'Optional language hint such as en or zh.', 'settings.voice.sttPrompt': 'Prompt', 'settings.voice.sttPromptHint': 'Optional transcription prompt.', 'settings.voice.sttBaseUrl': 'Base URL', 'settings.voice.sttBaseUrlHint': 'Custom OpenAI-compatible speech-to-text endpoint.', 'settings.voice.sttSave': 'Save STT settings', 'settings.voice.sttSaveActive': 'Save active STT provider', 'settings.voice.sttActiveSaved': 'Active STT provider saved on server.', 'settings.voice.sttEndpointHint': 'Final transcription endpoint: {endpoint}', 'settings.voice.sttTestButton': 'Test STT API', 'settings.voice.sttTestStopButton': 'Stop and transcribe', 'settings.voice.sttTestBackendOnly': 'STT API test is available for OpenAI-compatible and Custom providers.', 'settings.voice.sttTestRecording': 'Recording test sample… click Stop and transcribe when done.', 'settings.voice.sttTestTranscribing': 'Transcribing test sample…', 'settings.voice.sttTestEmpty': 'No audio was captured for the STT test.', 'settings.voice.sttTestSucceeded': 'STT test succeeded.', 'settings.voice.sttTestFailed': 'STT test failed: {error} — {hint}', 'settings.voice.sttFailureHintMissingConfig': 'Check that the provider is saved with a base URL/model and an API key before testing.', 'settings.voice.sttFailureHintAuth': 'Check the API key. If a key was already stored, paste the current key again and save before testing.', 'settings.voice.sttFailureHintForbidden': 'The key reached the provider but does not have permission for this STT model or account. Check provider access/billing.', 'settings.voice.sttFailureHintEndpointModel': 'Check the Base URL and model. For Groq, use {endpoint} with whisper-large-v3-turbo or another Groq STT model.', 'settings.voice.sttFailureHintModel': 'Check that the selected STT model exists and is enabled for this account.', 'settings.voice.sttFailureHintQuota': 'The provider reports quota, billing, or rate-limit trouble. Check account quota/billing or retry later.', 'settings.voice.sttFailureHintNetworkCustom': 'The server could not reach the custom STT endpoint. Check that {endpoint} is reachable from the Web UI server and is OpenAI-compatible.', 'settings.voice.sttFailureHintNetwork': 'The Web UI server could not reach the STT provider. Check network/proxy/firewall and retry.', 'settings.voice.sttFailureHintAudio': 'The recorded audio was empty or rejected. Try a short clear sample, allow microphone access, or retry in Chrome.', 'settings.voice.sttFailureHintGenericCustom': 'Check API key, Base URL, model, and the final endpoint {endpoint}.', 'settings.voice.sttFailureHintGeneric': 'Check API key, model, and provider account access, then retry.', 'settings.voice.sttTestRecordFailed': 'Could not start microphone recording: {error}', 'settings.voice.sttTestTranscript': 'Transcript: {text}', 'settings.voice.sttSaved': 'STT settings saved on server.', 'settings.voice.sttSaveFailed': 'Failed to save STT settings.', 'settings.voice.sttCleared': 'Stored STT secret cleared from server.', 'settings.voice.sttClearFailed': 'Failed to clear stored STT secret.', 'settings.voice.storedBaseUrlsTitle': 'Stored Base URLs', 'settings.voice.storedBaseUrlsHint': 'Saved voice provider endpoints. Field-level inputs only clear the current value; delete saved endpoints here.', 'settings.voice.storedBaseUrlsEmpty': 'No custom voice Base URLs are saved on the server.', 'settings.voice.storedBaseUrlDelete': 'Delete URL', 'settings.voice.storedApiKeysTitle': 'Stored API keys', 'settings.voice.storedApiKeysHint': 'Masked list of server-stored voice API keys. Only provider/source is shown; raw keys are never displayed.', 'settings.voice.storedApiKeysEmpty': 'No voice API keys are stored on the server.', 'settings.voice.storedKeyKindTts': 'TTS', 'settings.voice.storedKeyKindStt': 'STT', 'settings.voice.storedApiKeyMasked': '[stored]', 'settings.voice.storedApiKeyDelete': 'Delete key', 'settings.voice.baseUrlPresetClear': 'Clear URL', 'settings.voice.baseUrlPresetCleared': 'Base URL cleared from the field.', 'settings.voice.baseUrlPresetDelete': 'Delete URL', 'settings.voice.baseUrlPresetDeleted': 'Base URL removed from saved list.', 'settings.voice.baseUrlPresetDeleteFailed': 'Failed to remove saved Base URL.', } vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string, params?: Record) => { const template = translations[key] ?? key return params ? template.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? '') : template }, }), })) vi.mock('naive-ui', () => ({ useMessage: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), }), NCard: defineComponent({ name: 'NCard', inheritAttrs: false, template: '
', }), NTag: defineComponent({ name: 'NTag', inheritAttrs: false, template: '', }), NTooltip: defineComponent({ name: 'NTooltip', inheritAttrs: false, template: '', }), NPopconfirm: defineComponent({ name: 'NPopconfirm', inheritAttrs: false, emits: ['positive-click'], template: '', }), NDropdown: defineComponent({ name: 'NDropdown', inheritAttrs: false, props: { options: { type: Array as () => Array<{ label: string; key: string; disabled?: boolean }>, default: () => [] } }, emits: ['select'], template: '', }), NModal: defineComponent({ name: 'NModal', inheritAttrs: false, props: { show: { type: Boolean, default: false } }, emits: ['update:show'], template: '
', }), NDrawer: defineComponent({ name: 'NDrawer', inheritAttrs: false, props: { show: { type: Boolean, default: false } }, emits: ['update:show'], template: '
', }), NDrawerContent: defineComponent({ name: 'NDrawerContent', inheritAttrs: false, template: '
', }), NForm: defineComponent({ name: 'NForm', inheritAttrs: false, template: '
', }), NFormItem: defineComponent({ name: 'NFormItem', inheritAttrs: false, props: { label: { type: String, default: '' } }, template: '', }), NSpace: defineComponent({ name: 'NSpace', inheritAttrs: false, template: '
', }), NGrid: defineComponent({ name: 'NGrid', inheritAttrs: false, template: '
', }), NGridItem: defineComponent({ name: 'NGridItem', inheritAttrs: false, template: '
', }), NSelect: defineComponent({ name: 'NSelect', inheritAttrs: false, props: { value: { type: [String, Number], default: '' }, options: { type: Array as () => Array<{ label: string; value: string }>, default: () => [] }, size: { type: String, default: '' }, }, emits: ['update:value'], template: `
{{ option.label }}
`, }), NInput: defineComponent({ name: 'NInput', inheritAttrs: false, props: { value: { type: String, default: '' }, placeholder: { type: String, default: '' }, type: { type: String, default: 'text' }, rows: { type: Number, default: 2 }, size: { type: String, default: '' }, }, emits: ['update:value'], template: `