Files
hermes-ui/tests/client/use-speech-tts.test.ts
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

510 lines
18 KiB
TypeScript

// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const createObjectURL = vi.fn()
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', {
createObjectURL,
revokeObjectURL,
})
const audioInstances: MockAudio[] = []
let objectUrlCounter = 0
class MockAudio {
src: string
onended: (() => void) | null = null
onerror: (() => void) | null = null
play = vi.fn().mockResolvedValue(undefined)
pause = vi.fn()
constructor(src = '') {
this.src = src
audioInstances.push(this)
}
}
vi.stubGlobal('Audio', MockAudio)
function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0))
}
function installSpeechSynthesisMock() {
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
speaking: false,
pending: false,
paused: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getVoices: vi.fn(() => []),
speak: vi.fn(),
cancel: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
},
})
}
describe('client TTS unified synthesize flow', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
localStorage.clear()
localStorage.setItem('hermes_server_url', 'https://hermes.example')
localStorage.setItem('hermes_api_key', 'secret-key')
objectUrlCounter = 0
createObjectURL.mockImplementation(() => `blob:mock-${++objectUrlCounter}`)
audioInstances.length = 0
installSpeechSynthesisMock()
})
it('synthesizeSpeech posts to the unified endpoint with auth, body, and signal', async () => {
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue(new Response('audio-bytes', {
status: 200,
headers: {
'X-TTS-Engine': 'openai-engine',
'X-TTS-Provider': 'openai',
'Content-Type': 'audio/mpeg',
},
}))
const { synthesizeSpeech } = await import('../../packages/client/src/api/hermes/tts')
const controller = new AbortController()
const result = await synthesizeSpeech({
provider: 'openai',
text: 'Hello world',
options: { voice: 'alloy', model: 'tts-1' },
signal: controller.signal,
})
expect(mockFetch).toHaveBeenCalledOnce()
expect(mockFetch).toHaveBeenCalledWith(
'https://hermes.example/api/hermes/tts/synthesize',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer secret-key',
'X-Hermes-Profile': 'research',
},
body: JSON.stringify({
provider: 'openai',
text: 'Hello world',
options: { voice: 'alloy', model: 'tts-1' },
}),
signal: controller.signal,
},
)
expect(result.audio).toBeInstanceOf(Blob)
expect(result.audio.size).toBeGreaterThan(0)
expect(result.engine).toBe('openai-engine')
expect(result.provider).toBe('openai')
})
it('openaiPlay routes through the unified synthesize endpoint with provider=openai', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: {
'X-TTS-Engine': 'openai',
'X-TTS-Provider': 'openai',
},
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-openai', 'Hello from OpenAI', {
baseUrl: 'https://api.openai.com/v1',
apiKey: 'provider-key',
model: 'tts-1',
voice: 'alloy',
})
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('https://hermes.example/api/hermes/tts/synthesize')
expect(url).not.toContain('/audio/speech')
expect(JSON.parse(options.body)).toEqual({
provider: 'openai',
text: 'Hello from OpenAI',
options: {
baseUrl: 'https://api.openai.com/v1',
apiKey: 'provider-key',
model: 'tts-1',
voice: 'alloy',
},
})
})
it('mimoPlay routes through the unified synthesize endpoint with provider=mimo', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['mimo-audio'], { type: 'audio/wav' }), {
status: 200,
headers: {
'X-TTS-Engine': 'mimo',
'X-TTS-Provider': 'mimo',
},
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.mimoPlay('msg-mimo', 'Hello from MiMo', {
baseUrl: 'https://mimo.example/v1',
apiKey: 'mimo-key',
authMode: 'api-key',
model: 'mimo-v2.5-tts',
voiceMode: 'voiceClone',
voice: 'verse',
voiceCloneDataUri: 'data:audio/wav;base64,ZmFrZQ==',
voiceCloneFormat: 'wav',
stylePrompt: 'warm and calm',
})
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('https://hermes.example/api/hermes/tts/synthesize')
expect(url).not.toContain('/chat/completions')
expect(JSON.parse(options.body)).toEqual({
provider: 'mimo',
text: 'Hello from MiMo',
options: {
baseUrl: 'https://mimo.example/v1',
apiKey: 'mimo-key',
authMode: 'api-key',
model: 'mimo-v2.5-tts',
voiceMode: 'voiceClone',
voice: 'verse',
voiceCloneDataUri: 'data:audio/wav;base64,ZmFrZQ==',
voiceCloneFormat: 'wav',
stylePrompt: 'warm and calm',
},
})
})
it('openaiPlay respects an explicit edge provider even when other fields look OpenAI-compatible', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['edge-audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'edge' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-edge-explicit', 'Edge explicit', {
provider: 'edge',
baseUrl: 'https://api.openai.com/v1',
model: 'tts-1',
voice: 'zh-CN-XiaoxiaoNeural',
})
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body).provider).toBe('edge')
})
it('openaiPlay falls back to edge for the legacy /api/tts/proxy baseUrl', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['edge-audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'edge' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-edge-fallback', 'Edge fallback', {
baseUrl: '/api/tts/proxy',
voice: 'zh-CN-XiaoxiaoNeural',
})
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body).provider).toBe('edge')
})
it('openaiPlay falls back to custom for non-proxy endpoints without a model', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['custom-audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-custom-fallback', 'Custom fallback', {
baseUrl: 'https://custom.example/v1',
apiKey: 'custom-key',
})
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body).provider).toBe('custom')
})
it('stop aborts a pending unified custom TTS request and clears custom state', async () => {
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
let capturedSignal: AbortSignal | undefined
mockFetch.mockImplementation((_url, options: RequestInit) => {
capturedSignal = options.signal as AbortSignal
return new Promise((_resolve, reject) => {
capturedSignal?.addEventListener('abort', () => reject(abortError), { once: true })
})
})
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
const pending = speech.openaiPlay('msg-stop', 'Stop me', {
baseUrl: 'https://custom.example/v1',
})
expect(speech.isCustomPlaying.value).toBe(true)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe('msg-stop')
speech.stop()
expect(capturedSignal?.aborted).toBe(true)
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
await pending.catch(() => undefined)
})
it('stop(true) clears queued playback and stops the active TTS audio', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Engine': 'edge' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
speech.enqueue('msg-1', 'First queued response')
await flushPromises()
const [firstAudio] = audioInstances
speech.enqueue('msg-2', 'Second queued response')
speech.stop(true)
firstAudio.onended?.()
await flushPromises()
expect(firstAudio.pause).toHaveBeenCalledOnce()
expect(firstAudio.src).toBe('')
expect(audioInstances).toHaveLength(1)
expect(speech.currentMessageId.value).toBe(null)
expect(speech.isPlaying.value).toBe(false)
})
it('handles AbortError silently without console.error and clears custom state', async () => {
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetch.mockRejectedValue(abortError)
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await expect(speech.openaiPlay('msg-abort', 'Abort me', {
baseUrl: 'https://custom.example/v1',
})).resolves.toBeUndefined()
expect(consoleError).not.toHaveBeenCalled()
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
it('openaiToggle handles request failures without an unhandled rejection', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetch.mockRejectedValue(new Error('upstream failed'))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
speech.openaiToggle('msg-toggle-fail', 'Fail through toggle', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
await flushPromises()
expect(consoleError).not.toHaveBeenCalled()
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
it('handles custom audio resume play() rejection', async () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-resume', 'Audio to resume', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
const [audio] = audioInstances
speech.openaiToggle('msg-resume', 'Audio to resume', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
audio.play.mockRejectedValueOnce(new Error('play blocked'))
speech.openaiToggle('msg-resume', 'Audio to resume', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
await flushPromises()
expect(consoleWarn).toHaveBeenCalledWith('[useSpeech] Custom TTS audio resume failed:', expect.any(Error))
expect(speech.isCustomPaused.value).toBe(true)
})
it('pause() pauses active custom audio and preserves the current custom message', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-custom-pause', 'Audio to pause', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
const [audio] = audioInstances
speech.pause()
expect(audio.pause).toHaveBeenCalledOnce()
expect(speech.isCustomPlaying.value).toBe(true)
expect(speech.isCustomPaused.value).toBe(true)
expect(speech.currentCustomMessageId.value).toBe('msg-custom-pause')
})
it('resume() resumes paused custom audio and clears the custom paused flag', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-custom-resume', 'Audio to resume globally', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
const [audio] = audioInstances
speech.pause()
audio.play.mockClear()
speech.resume()
await flushPromises()
expect(audio.play).toHaveBeenCalledOnce()
expect(speech.isCustomPlaying.value).toBe(true)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe('msg-custom-resume')
})
it('same-message toggle during pending custom synthesis aborts before audio playback starts', async () => {
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
let capturedSignal: AbortSignal | undefined
mockFetch.mockImplementation((_url, options: RequestInit) => {
capturedSignal = options.signal as AbortSignal
return new Promise((_resolve, reject) => {
capturedSignal?.addEventListener('abort', () => reject(abortError), { once: true })
})
})
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
speech.openaiToggle('msg-pending-toggle', 'Audio still synthesizing', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
expect(speech.isCustomPlaying.value).toBe(true)
speech.openaiToggle('msg-pending-toggle', 'Audio still synthesizing', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
await flushPromises()
expect(capturedSignal?.aborted).toBe(true)
expect(audioInstances).toHaveLength(0)
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
it('revokes custom audio object URLs when stop() stops custom playback', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: {
'X-TTS-Engine': 'openai',
'X-TTS-Provider': 'custom',
},
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-audio', 'Audio to stop', {
baseUrl: 'https://custom.example/v1',
})
expect(createObjectURL).toHaveBeenCalledOnce()
expect(audioInstances).toHaveLength(1)
const [audio] = audioInstances
const createdUrl = createObjectURL.mock.results[0]?.value
speech.stop()
expect(audio.pause).toHaveBeenCalledOnce()
expect(audio.src).toBe('')
expect(revokeObjectURL).toHaveBeenCalledWith(createdUrl)
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
})
describe('client TTS autoplay call sites', () => {
it('catches fire-and-forget custom TTS autoplay promises', () => {
const messageItem = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
const groupMessageItem = readFileSync('packages/client/src/components/hermes/group-chat/GroupMessageItem.vue', 'utf8')
expect(messageItem).toContain('function handleAutoplayTtsError')
expect(messageItem).toContain('void speech.openaiPlay')
expect(messageItem).toContain('void speech.mimoPlay')
expect(messageItem).toContain('.catch(handleAutoplayTtsError)')
expect(groupMessageItem).toContain('function handleAutoplayTtsError')
expect(groupMessageItem).toContain('void speech.openaiPlay')
expect(groupMessageItem).toContain('void speech.mimoPlay')
expect(groupMessageItem).toContain('.catch(handleAutoplayTtsError)')
})
it('does not require a local MiMo API key before using server-stored TTS credentials', () => {
const messageItem = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
const groupMessageItem = readFileSync('packages/client/src/components/hermes/group-chat/GroupMessageItem.vue', 'utf8')
expect(messageItem).not.toContain('MiMo TTS API Key 为空')
expect(groupMessageItem).not.toContain('if (!voiceSettings.mimoApiKey.value) return')
expect(messageItem).toContain('apiKey: apiKey || undefined')
expect(groupMessageItem).toContain('apiKey: apiKey || undefined')
})
})