// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, inject, nextTick, provide, ref, useAttrs } from 'vue' import { mount, flushPromises } from '@vue/test-utils' import ArcoVue from '@arco-design/web-vue' 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('@/composables/useMessage', () => ({ useMessage: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), }), })) /** * Arco overlays (modal / drawer / dropdown) render into a teleported popup, so * they are stubbed to render inline. Stubs are keyed by Arco's *internal* * component name — not the `a-*` template tag — because Arco is registered * globally via `app.use(ArcoVue)`. */ const AModalStub = defineComponent({ name: 'Modal', inheritAttrs: false, props: { visible: { type: Boolean, default: false } }, emits: ['cancel', 'ok', 'update:visible'], template: '
', }) const ADrawerStub = defineComponent({ name: 'Drawer', inheritAttrs: false, props: { visible: { type: Boolean, default: false } }, emits: ['cancel', 'update:visible'], template: '
', }) const DOPTION_SELECT = Symbol('arco-doption-select') const ADoptionStub = defineComponent({ name: 'Doption', inheritAttrs: false, props: { value: { type: [String, Number], default: '' }, disabled: { type: Boolean, default: false } }, setup(props, { slots }) { const attrs = useAttrs() const select = inject(DOPTION_SELECT, () => {}) return () => h('button', { ...attrs, type: 'button', class: 'arco-doption-stub', disabled: props.disabled, 'data-value': props.value, onClick: () => select(props.value), }, slots.default?.()) }, }) const ADropdownStub = defineComponent({ name: 'Dropdown', inheritAttrs: false, emits: ['select'], setup(_props, { slots, emit }) { provide(DOPTION_SELECT, (value: unknown) => emit('select', value)) return () => h('div', { class: 'arco-dropdown-stub' }, [slots.default?.(), slots.content?.()]) }, }) const ASelectStub = defineComponent({ name: 'Select', inheritAttrs: false, props: { modelValue: { type: [String, Number, Boolean, Object, Array], default: '' }, options: { type: Array as () => Array<{ label: string; value: unknown }>, default: () => [] }, placeholder: { type: String, default: '' }, }, emits: ['update:modelValue', 'change'], template: `
{{ option.label }}
`, methods: { onChange(event: Event) { const raw = (event.target as HTMLInputElement).value // Restore the option's declared value type so numeric ids stay numeric. const typed = this.options.find(option => String(option.value) === raw)?.value ?? raw this.$emit('update:modelValue', typed) this.$emit('change', typed) }, }, }) const arcoGlobal = { plugins: [ArcoVue], stubs: { Modal: AModalStub, Drawer: ADrawerStub, Dropdown: ADropdownStub, Doption: ADoptionStub, Select: ASelectStub, }, } /** * Arco's a-input / a-input-password wrap the real control in a * `.arco-input-wrapper` span, so field test ids land on the span. Tests keep * asserting against the same test ids and reach the control through the wrapper. */ function field(wrapper: any, testid: string) { return wrapper.get(`[data-testid="${testid}"]`) } /** The settable control behind a field test id (the element itself for stubs). */ function fieldInput(wrapper: any, testid: string) { const node = field(wrapper, testid) return node.element.tagName === 'INPUT' ? node : node.get('input') } /** Menu entries inside an a-dropdown are located by their rendered label. */ function dropdownOptionByText(wrapper: any, text: string) { const option = wrapper.findAll('.arco-doption-stub').find((node: any) => node.text().trim() === text) if (!option) throw new Error(`dropdown option not found: ${text}`) return option } vi.mock('@/api/hermes/stt-settings', () => ({ fetchSttSettings: mockFetchSttSettings, saveSttSettings: mockSaveSttSettings, saveActiveSttProvider: mockSaveActiveSttProvider, clearSttSecret: mockClearSttSecret, deleteSttProvider: mockDeleteSttProvider, deleteSttBaseUrlPreset: mockDeleteSttBaseUrlPreset, })) vi.mock('@/api/hermes/stt', () => ({ transcribeSpeech: mockTranscribeSpeech, })) vi.mock('@/composables/useMicRecorder', () => ({ useMicRecorder: () => ({ state: mockMicRecorderState, isRecording: ref(mockMicRecorderState.value.status === 'recording'), start: mockMicStart, stop: mockMicStop, cancel: vi.fn(), }), })) vi.mock('@/api/hermes/tts-settings', () => ({ fetchTtsSettings: mockFetchTtsSettings, saveTtsSettings: mockSaveTtsSettings, saveActiveTtsProvider: mockSaveActiveTtsProvider, clearTtsSecret: mockClearTtsSecret, deleteTtsProvider: mockDeleteTtsProvider, deleteTtsBaseUrlPreset: mockDeleteTtsBaseUrlPreset, })) vi.mock('@/api/hermes/system', () => ({ fetchProviderModels: mockFetchProviderModels, })) vi.mock('@/api/hermes/voice-provider-probe', () => ({ probeVoiceProvider: mockProbeVoiceProvider, })) vi.mock('@/composables/useSpeech', () => ({ useSpeech: () => ({ stop: mockSpeechStop, speakViaBrowser: mockSpeakViaBrowser, openaiPlay: mockOpenaiPlay, mimoPlay: mockMimoPlay, }), })) function createVoiceSettingsMock() { return { provider: ref('webspeech'), webspeechVoice: ref(''), loadServerTtsSettings: vi.fn().mockResolvedValue(undefined), applyServerTtsSettings: vi.fn(), setProvider(value: string) { this.provider.value = value }, setWebSpeechVoice(value: string) { this.webspeechVoice.value = value }, openaiApiKey: ref(''), openaiApiKeyPreview: ref(''), openaiHasApiKey: ref(false), openaiBaseUrl: ref(''), openaiModel: ref('tts-1'), openaiVoice: ref('alloy'), setOpenaiApiKey(value: string) { this.openaiApiKey.value = value }, setOpenaiBaseUrl(value: string) { this.openaiBaseUrl.value = value }, setOpenaiModel(value: string) { this.openaiModel.value = value }, setOpenaiVoice(value: string) { this.openaiVoice.value = value }, customUrl: ref(''), customApiKey: ref(''), customApiKeyPreview: ref(''), customHasApiKey: ref(false), setCustomUrl(value: string) { this.customUrl.value = value }, setCustomApiKey(value: string) { this.customApiKey.value = value }, edgeVoice: ref('zh-CN-XiaoxiaoNeural'), edgeRate: ref(1), edgePitchHz: ref(0), setEdgeVoice(value: string) { this.edgeVoice.value = value }, setEdgeRate(value: number) { this.edgeRate.value = value }, setEdgePitchHz(value: number) { this.edgePitchHz.value = value }, mimoApiKey: ref(''), mimoApiKeyPreview: ref(''), mimoHasApiKey: ref(false), mimoHasVoiceCloneData: ref(false), mimoAuthMode: ref('bearer'), mimoBaseUrl: ref('https://api.xiaomimimo.com/v1'), mimoModel: ref('mimo-v2.5-tts'), mimoVoice: ref('冰糖'), mimoVoiceDesignDesc: ref(''), mimoVoiceCloneDataUri: ref(''), mimoVoiceCloneFileName: ref(''), mimoVoiceCloneFormat: ref<'mp3' | 'wav'>('wav'), mimoStylePrompt: ref(''), setMimoApiKey(value: string) { this.mimoApiKey.value = value }, setMimoAuthMode(value: string) { this.mimoAuthMode.value = value }, setMimoBaseUrl(value: string) { this.mimoBaseUrl.value = value }, setMimoModel(value: string) { this.mimoModel.value = value }, setMimoVoice(value: string) { this.mimoVoice.value = value }, setMimoVoiceDesignDesc(value: string) { this.mimoVoiceDesignDesc.value = value }, setMimoVoiceCloneDataUri(value: string) { this.mimoVoiceCloneDataUri.value = value }, setMimoVoiceCloneFileName(value: string) { this.mimoVoiceCloneFileName.value = value }, setMimoVoiceCloneFormat(value: 'mp3' | 'wav') { this.mimoVoiceCloneFormat.value = value }, setMimoStylePrompt(value: string) { this.mimoStylePrompt.value = value }, } } let voiceSettingsMock = createVoiceSettingsMock() vi.mock('@/composables/useVoiceSettings', () => ({ useVoiceSettings: () => voiceSettingsMock, })) function installSpeechRecognition() { class MockSpeechRecognition {} vi.stubGlobal('SpeechRecognition', MockSpeechRecognition) ;(window as any).SpeechRecognition = MockSpeechRecognition } function removeSpeechRecognition() { vi.unstubAllGlobals() delete (window as any).SpeechRecognition delete (window as any).webkitSpeechRecognition } function installSpeechSynthesis() { Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: { getVoices: () => [], onvoiceschanged: null, }, }) } async function importVoiceSettingsComponent() { const mod = await import('../../packages/client/src/components/hermes/settings/VoiceSettings.vue') return mod.default } describe('useSttSettings', () => { beforeEach(() => { vi.resetModules() mockFetchSttSettings.mockReset() mockSaveSttSettings.mockReset() mockSaveActiveSttProvider.mockReset() mockClearSttSecret.mockReset() mockDeleteSttProvider.mockReset() mockDeleteSttBaseUrlPreset.mockReset() mockTranscribeSpeech.mockReset() mockMicStart.mockReset() mockMicStop.mockReset() mockMicRecorderState.value = { status: 'idle', error: null, startedAt: null, mimeType: null } mockFetchTtsSettings.mockReset() mockSaveTtsSettings.mockReset() mockSaveActiveTtsProvider.mockReset() mockClearTtsSecret.mockReset() mockDeleteTtsProvider.mockReset() mockDeleteTtsBaseUrlPreset.mockReset() mockFetchProviderModels.mockReset() mockProbeVoiceProvider.mockReset() mockSpeechStop.mockReset() mockSpeakViaBrowser.mockReset() mockOpenaiPlay.mockReset() mockMimoPlay.mockReset() localStorage.clear() voiceSettingsMock = createVoiceSettingsMock() removeSpeechRecognition() installSpeechSynthesis() }) it('defaults provider to browser when SpeechRecognition exists', async () => { installSpeechRecognition() const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') const settings = useSttSettings() expect(settings.provider.value).toBe('browser') }) it('defaults provider to openai when SpeechRecognition is unavailable', async () => { const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') const settings = useSttSettings() expect(settings.provider.value).toBe('openai') }) it('loads masked secret state from the server', async () => { mockFetchSttSettings.mockResolvedValue({ providers: [ { provider: 'openai', settings: { model: 'gpt-4o-transcribe', language: 'en', prompt: 'keep punctuation' }, secrets: { apiKey: '[stored]' }, updatedAt: 1, }, ], }) const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') const settings = useSttSettings() await settings.loadServerSttSettings(true) expect(settings.openaiModel.value).toBe('gpt-4o-transcribe') expect(settings.openaiLanguage.value).toBe('en') expect(settings.openaiPrompt.value).toBe('keep punctuation') expect(settings.openaiApiKey.value).toBe('') expect(settings.openaiApiKeyPreview.value).toBe('[stored]') expect(settings.openaiHasApiKey.value).toBe(true) }) it('authoritative empty hydration resets stale server-backed settings but keeps the local provider preference', async () => { localStorage.setItem(STT_STORAGE_KEY, JSON.stringify({ provider: 'custom', openaiModel: 'stale-openai-model', openaiLanguage: 'fr', openaiPrompt: 'stale-openai-prompt', customBaseUrl: 'https://stale.example/v1', customModel: 'stale-custom-model', customLanguage: 'ja', customPrompt: 'stale-custom-prompt', })) mockFetchSttSettings.mockResolvedValue({ providers: [] }) const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') const settings = useSttSettings() settings.openaiApiKeyPreview.value = '[stored-openai]' settings.openaiHasApiKey.value = true settings.customApiKeyPreview.value = '[stored-custom]' settings.customHasApiKey.value = true settings.setOpenaiApiKey('pending-openai-secret') settings.setCustomApiKey('pending-custom-secret') await settings.loadServerSttSettings(true) expect(settings.provider.value).toBe('custom') expect(settings.openaiModel.value).toBe('gpt-4o-transcribe') expect(settings.openaiLanguage.value).toBe('') expect(settings.openaiPrompt.value).toBe('') expect(settings.customBaseUrl.value).toBe('') expect(settings.customModel.value).toBe('gpt-4o-transcribe') expect(settings.customLanguage.value).toBe('') expect(settings.customPrompt.value).toBe('') expect(settings.openaiApiKey.value).toBe('') expect(settings.openaiApiKeyPreview.value).toBe('') expect(settings.openaiHasApiKey.value).toBe(false) expect(settings.customApiKey.value).toBe('') expect(settings.customApiKeyPreview.value).toBe('') expect(settings.customHasApiKey.value).toBe(false) }) it('authoritative partial hydration resets missing provider rows before applying returned server settings', async () => { localStorage.setItem(STT_STORAGE_KEY, JSON.stringify({ provider: 'custom', openaiModel: 'stale-openai-model', openaiLanguage: 'fr', openaiPrompt: 'stale-openai-prompt', customBaseUrl: 'https://stale.example/v1', customModel: 'stale-custom-model', customLanguage: 'ja', customPrompt: 'stale-custom-prompt', })) mockFetchSttSettings.mockResolvedValue({ providers: [ { provider: 'openai', settings: { model: 'gpt-4o-mini-transcribe', language: 'en', prompt: 'keep punctuation', }, secrets: { apiKey: '[stored]' }, updatedAt: 1, }, ], }) const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') const settings = useSttSettings() settings.customApiKeyPreview.value = '[stored-custom]' settings.customHasApiKey.value = true settings.setCustomApiKey('pending-custom-secret') await settings.loadServerSttSettings(true) expect(settings.provider.value).toBe('custom') expect(settings.openaiModel.value).toBe('gpt-4o-mini-transcribe') expect(settings.openaiLanguage.value).toBe('en') expect(settings.openaiPrompt.value).toBe('keep punctuation') expect(settings.openaiApiKey.value).toBe('') expect(settings.openaiApiKeyPreview.value).toBe('[stored]') expect(settings.openaiHasApiKey.value).toBe(true) expect(settings.customBaseUrl.value).toBe('') expect(settings.customModel.value).toBe('gpt-4o-transcribe') expect(settings.customLanguage.value).toBe('') expect(settings.customPrompt.value).toBe('') expect(settings.customApiKey.value).toBe('') expect(settings.customApiKeyPreview.value).toBe('') expect(settings.customHasApiKey.value).toBe(false) }) it('persists non-secret preferences without storing raw api keys', async () => { const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') const settings = useSttSettings() settings.setProvider('custom') settings.setCustomBaseUrl('https://custom.example/v1') settings.setCustomModel('whisper-large-v3') settings.setCustomLanguage('zh') settings.setCustomPrompt('preserve punctuation') settings.setCustomApiKey('raw-custom-secret') await nextTick() const raw = localStorage.getItem(STT_STORAGE_KEY) || '{}' expect(raw).toContain('https://custom.example/v1') expect(raw).toContain('whisper-large-v3') expect(raw).toContain('preserve punctuation') expect(raw).not.toContain('raw-custom-secret') }) it('sanitizes unexpected raw secret fields out of localStorage on load', async () => { localStorage.setItem(STT_STORAGE_KEY, JSON.stringify({ provider: 'custom', customBaseUrl: 'https://custom.example/v1', customModel: 'whisper-large-v3', apiKey: 'raw-root-secret', openaiApiKey: 'raw-openai-secret', customApiKey: 'raw-custom-secret', secrets: { apiKey: 'nested-secret' }, nested: { openaiApiKey: 'deep-secret' }, })) const { useSttSettings } = await import('../../packages/client/src/composables/useSttSettings') useSttSettings() await nextTick() const raw = localStorage.getItem(STT_STORAGE_KEY) || '{}' expect(raw).toContain('https://custom.example/v1') expect(raw).toContain('whisper-large-v3') expect(raw).not.toContain('raw-root-secret') expect(raw).not.toContain('raw-openai-secret') expect(raw).not.toContain('raw-custom-secret') expect(raw).not.toContain('nested-secret') expect(raw).not.toContain('deep-secret') expect(raw).not.toContain('"secrets"') }) }) describe('VoiceSettings STT UI', () => { beforeEach(() => { vi.resetModules() mockFetchSttSettings.mockReset() mockSaveSttSettings.mockReset() mockSaveActiveSttProvider.mockReset() mockClearSttSecret.mockReset() mockDeleteSttProvider.mockReset() mockDeleteSttBaseUrlPreset.mockReset() mockTranscribeSpeech.mockReset() mockMicStart.mockReset() mockMicStop.mockReset() mockMicRecorderState.value = { status: 'idle', error: null, startedAt: null, mimeType: null } mockFetchTtsSettings.mockReset() mockSaveTtsSettings.mockReset() mockSaveActiveTtsProvider.mockReset() mockClearTtsSecret.mockReset() mockDeleteTtsProvider.mockReset() mockDeleteTtsBaseUrlPreset.mockReset() mockFetchProviderModels.mockReset() mockSpeechStop.mockReset() mockSpeakViaBrowser.mockReset() mockOpenaiPlay.mockReset() mockMimoPlay.mockReset() localStorage.clear() voiceSettingsMock = createVoiceSettingsMock() removeSpeechRecognition() installSpeechSynthesis() mockFetchSttSettings.mockResolvedValue({ providers: [] }) mockFetchTtsSettings.mockResolvedValue({ providers: [] }) mockSaveActiveTtsProvider.mockResolvedValue('edge') mockSaveActiveSttProvider.mockResolvedValue('browser') }) async function mountComponent() { const VoiceSettings = await importVoiceSettingsComponent() return mount(VoiceSettings, { global: { ...arcoGlobal, stubs: { ...arcoGlobal.stubs, SettingRow: defineComponent({ name: 'SettingRow', props: { label: { type: String, default: '' }, hint: { type: String, default: '' }, }, template: `
{{ label }}
{{ hint }}
`, }), }, }, }) } it('renders card-based TTS/STT voice APIs with built-ins and masked stored secrets', async () => { mockFetchSttSettings.mockResolvedValue({ activeProvider: 'custom', providers: [ { provider: 'custom', settings: { baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, secrets: { apiKey: '[stored]' }, updatedAt: 3, }, ], }) mockFetchTtsSettings.mockResolvedValue({ providers: [ { provider: 'mimo', settings: { baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-tts', voice: '冰糖' }, secrets: { apiKey: '[stored]' }, updatedAt: 4, }, ], }) const wrapper = await mountComponent() await flushPromises() expect(wrapper.text()).toContain('Edge TTS') expect(wrapper.text()).toContain('Browser STT') expect(wrapper.text()).toContain('MiMo TTS') expect(wrapper.text()).toContain('Groq STT') expect(wrapper.text()).toContain('Stored') expect(wrapper.text()).not.toContain('raw-openai-secret') expect(wrapper.find('[data-testid="stt-provider-select"]').exists()).toBe(false) expect(wrapper.find('[data-testid="stt-custom-base-url"]').exists()).toBe(false) }) it('uses server active TTS provider instead of stale local voice settings', async () => { voiceSettingsMock.provider.value = 'edge' mockFetchTtsSettings.mockResolvedValue({ activeProvider: 'doubao', providers: [ { provider: 'doubao', settings: { baseUrl: 'https://openspeech.bytedance.com/api/v3/tts/unidirectional', model: 'seed-tts-2.0', voice: 'zh_female_xiaohe_uranus_bigtts', }, secrets: { apiKey: '[stored]' }, updatedAt: 4, }, ], }) mockSaveActiveTtsProvider.mockResolvedValue('edge') const wrapper = await mountComponent() await flushPromises() expect(wrapper.find('.active-summary').text()).toContain('Doubao') expect(wrapper.findAll('.voice-api-card').find(card => card.text().includes('Doubao'))?.text()).toContain('Active') await wrapper.findAll('.voice-api-card') .find(card => card.text().includes('Edge TTS'))! .findAll('button') .find(button => button.text().includes('Set active'))! .trigger('click') await flushPromises() expect(mockSaveActiveTtsProvider).toHaveBeenCalledWith('edge') expect(wrapper.find('.active-summary').text()).toContain('Edge TTS') }) it('adds a Groq STT preset through the unified add API modal and stores it as custom STT', async () => { mockSaveSttSettings.mockResolvedValue({ provider: 'custom', settings: { baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3' }, secrets: { apiKey: '[stored]' }, updatedAt: 5, }) mockFetchSttSettings .mockResolvedValueOnce({ activeProvider: 'browser', providers: [] }) .mockResolvedValue({ activeProvider: 'custom', providers: [{ provider: 'custom', settings: { baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3' }, secrets: { apiKey: '[stored]' }, updatedAt: 5, }], }) const wrapper = await mountComponent() await flushPromises() await wrapper.findAll('button').find(button => button.text().includes('Add STT API'))!.trigger('click') await flushPromises() await wrapper.get('input[placeholder="models.chooseProvider"]').setValue('stt-groq') await flushPromises() await wrapper.get('input[type="password"]').setValue('raw-groq-secret') await wrapper.findAll('button').find(button => button.text().includes('common.add'))!.trigger('click') await flushPromises() expect(mockSaveSttSettings).toHaveBeenCalledWith('custom', expect.objectContaining({ activeProvider: 'custom', settings: expect.objectContaining({ baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3', }), secrets: { apiKey: 'raw-groq-secret' }, })) expect(wrapper.text()).not.toContain('raw-groq-secret') }) it('adds a Doubao STT preset through the unified add API modal', async () => { mockSaveSttSettings.mockResolvedValue({ provider: 'doubao', settings: { baseUrl: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel', model: 'volc.seedasr.auc', audioTranscode: 'ffmpeg', }, secrets: { apiKey: '[stored]' }, updatedAt: 6, }) mockFetchSttSettings .mockResolvedValueOnce({ activeProvider: 'browser', providers: [] }) .mockResolvedValue({ activeProvider: 'doubao', providers: [{ provider: 'doubao', settings: { baseUrl: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel', model: 'volc.seedasr.auc', audioTranscode: 'ffmpeg', }, secrets: { apiKey: '[stored]' }, updatedAt: 6, }], }) const wrapper = await mountComponent() await flushPromises() await wrapper.findAll('button').find(button => button.text().includes('Add STT API'))!.trigger('click') await flushPromises() await wrapper.get('input[placeholder="models.chooseProvider"]').setValue('stt-doubao') await flushPromises() await wrapper.get('input[type="password"]').setValue('raw-doubao-api-key') await wrapper.get('[data-testid="voice-provider-audio-transcode"]').setValue('ffmpeg') await wrapper.findAll('button').find(button => button.text().includes('common.add'))!.trigger('click') await flushPromises() expect(mockSaveSttSettings).toHaveBeenCalledWith('doubao', expect.objectContaining({ activeProvider: 'doubao', settings: expect.objectContaining({ baseUrl: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel', model: 'volc.seedasr.auc', audioTranscode: 'ffmpeg', }), secrets: { apiKey: 'raw-doubao-api-key' }, })) expect(wrapper.text()).not.toContain('raw-doubao-api-key') }) it('uses connection-first discovery without typing spam or overwriting manual models', async () => { mockProbeVoiceProvider.mockResolvedValue({ ok: true, models: [ { id: 'gpt-4o-mini', label: 'gpt-4o-mini', capability: 'other' }, { id: 'tts-1-hd', label: 'tts-1-hd', capability: 'preferred' }, ], recommendedModel: 'tts-1-hd', manualModelAllowed: true, normalizedBaseUrl: 'https://custom.example/v1', }) mockSaveTtsSettings.mockResolvedValue({ provider: 'custom', settings: { baseUrl: 'https://custom.example/v1', model: 'my-manual-model' }, secrets: { apiKey: '[stored]' }, updatedAt: 11, }) const wrapper = await mountComponent() await flushPromises() await wrapper.findAll('button').find(button => button.text().includes('Add TTS API'))!.trigger('click') await flushPromises() await fieldInput(wrapper, 'voice-provider-select').setValue('tts-custom') await flushPromises() expect(wrapper.text()).toContain('API compatibility') expect(wrapper.text()).toContain('Connection') expect(wrapper.text()).toContain('Model') await fieldInput(wrapper, 'voice-provider-base-url').setValue('https://custom.example/v1/') await fieldInput(wrapper, 'voice-provider-api-key').setValue('raw-custom-secret') await fieldInput(wrapper, 'voice-provider-model').setValue('my-manual-model') await flushPromises() expect(mockProbeVoiceProvider).not.toHaveBeenCalled() await field(wrapper, 'voice-provider-probe').trigger('click') await flushPromises() await nextTick() expect(mockProbeVoiceProvider).toHaveBeenCalledTimes(1) expect(mockProbeVoiceProvider).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tts', provider: 'custom', compatibility: 'openai-compatible', baseUrl: 'https://custom.example/v1', apiKey: 'raw-custom-secret', })) expect(wrapper.text()).toContain('Recommended: tts-1-hd') expect((fieldInput(wrapper, 'voice-provider-model').element as HTMLInputElement).value).toBe('my-manual-model') await wrapper.findAll('button').find(button => button.text().includes('common.add'))!.trigger('click') await flushPromises() expect(mockSaveTtsSettings).toHaveBeenCalledWith('custom', expect.objectContaining({ settings: expect.objectContaining({ baseUrl: 'https://custom.example/v1', model: 'my-manual-model' }), secrets: { apiKey: 'raw-custom-secret' }, })) }) it('deletes stored API keys from cards without exposing raw key values', async () => { mockFetchTtsSettings .mockResolvedValueOnce({ providers: [{ provider: 'mimo', settings: { baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-tts' }, secrets: { apiKey: '[stored]' }, updatedAt: 4, }], }) .mockResolvedValue({ providers: [] }) mockDeleteTtsProvider.mockResolvedValue({ success: true, deleted: true, activeProvider: null }) const wrapper = await mountComponent() await flushPromises() expect(wrapper.text()).toContain('Stored') await dropdownOptionByText(wrapper, 'Remove').trigger('click') await flushPromises() expect(mockDeleteTtsProvider).toHaveBeenCalledWith('mimo') expect(wrapper.text()).not.toContain('raw-secret') }) it('routes TTS test through the active card provider and settings', async () => { mockFetchTtsSettings.mockResolvedValue({ providers: [{ provider: 'mimo', settings: { baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-tts', voice: '冰糖' }, secrets: { apiKey: '[stored]' }, updatedAt: 8, }], }) voiceSettingsMock.provider.value = 'mimo' const wrapper = await mountComponent() await flushPromises() await wrapper.get('[data-testid="voice-card-test-tts-mimo"]').trigger('click') await flushPromises() expect(mockMimoPlay).toHaveBeenCalledWith('tts-mimo', 'Hello, this is a voice test.', expect.objectContaining({ baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-tts', voice: '冰糖', })) expect(mockOpenaiPlay).not.toHaveBeenCalled() }) it('shows missing-key and inline testing/error states on provider cards', async () => { let resolvePlay!: () => void mockOpenaiPlay.mockReturnValue(new Promise(resolve => { resolvePlay = resolve })) mockFetchTtsSettings.mockResolvedValue({ providers: [{ provider: 'openai', settings: { baseUrl: 'https://api.openai.com/v1', model: 'tts-1', voice: 'alloy' }, secrets: {}, updatedAt: 9, }], }) const wrapper = await mountComponent() await flushPromises() expect(wrapper.text()).toContain('Missing') expect(wrapper.text()).toContain('Connect') await wrapper.get('[data-testid="voice-card-test-tts-openai"]').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Add an API key before testing this provider.') mockFetchTtsSettings.mockResolvedValue({ providers: [{ provider: 'openai', settings: { baseUrl: 'https://api.openai.com/v1', model: 'tts-1', voice: 'alloy' }, secrets: { apiKey: '[stored]' }, updatedAt: 10, }], }) await wrapper.get('[data-testid="voice-card-test-tts-edge"]').trigger('click') await nextTick() expect(wrapper.text()).toContain('Testing…') resolvePlay() await flushPromises() expect(wrapper.text()).toContain('Test completed') }) })