dify/web/app/components/base/voice-input/__tests__/index.spec.tsx
yyh ab521c4cfd
refactor(chat): modernize speech-to-text pipeline (#38653)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yansong Zhang <916125788@qq.com>
2026-07-13 04:21:29 +00:00

319 lines
12 KiB
TypeScript

import type { VoiceRecorder } from '../recorder'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { StrictMode } from 'react'
import { transcribeAudio } from '../api'
import VoiceInput from '../index'
import { startVoiceRecorder } from '../recorder'
vi.mock('../api', () => ({ transcribeAudio: vi.fn() }))
vi.mock('../recorder', () => ({
startVoiceRecorder: vi.fn(),
}))
const recorderStop = vi.fn<() => Promise<Blob>>()
const recorderCancel = vi.fn<() => Promise<void>>()
const getByteFrequencyData = vi.fn()
const recorder: VoiceRecorder = {
analyser: {
frequencyBinCount: 8,
getByteFrequencyData,
} as unknown as AnalyserNode,
stop: recorderStop,
cancel: recorderCancel,
}
const target = {
type: 'consoleApp' as const,
appId: 'app-123',
}
const canvasContext = {
beginPath: vi.fn(),
clearRect: vi.fn(),
closePath: vi.fn(),
fill: vi.fn(),
rect: vi.fn(),
roundRect: vi.fn(),
scale: vi.fn(),
get fillStyle() {
return ''
},
set fillStyle(_value: string) {},
} as unknown as CanvasRenderingContext2D
const renderVoiceInput = (overrides: Partial<React.ComponentProps<typeof VoiceInput>> = {}) => {
const props: React.ComponentProps<typeof VoiceInput> = {
onCancel: vi.fn(),
onBeforeTranscribe: vi.fn().mockResolvedValue(undefined),
onConverted: vi.fn(),
onError: vi.fn(),
onStartError: vi.fn(),
target,
...overrides,
}
return { ...render(<VoiceInput {...props} />), props }
}
describe('VoiceInput', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(startVoiceRecorder).mockResolvedValue(recorder)
recorderStop.mockResolvedValue(new Blob(['mp3-data'], { type: 'audio/mp3' }))
recorderCancel.mockResolvedValue()
vi.mocked(transcribeAudio).mockResolvedValue({ text: 'transcribed text' })
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext)
vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 16,
height: 16,
left: 0,
right: 300,
top: 0,
width: 300,
x: 0,
y: 0,
toJSON: () => ({}),
})
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1),
)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
// The component owns the local recording state and browser-resource lifecycle.
describe('Recording state', () => {
it('should expose a cancellable starting state while microphone permission is pending', async () => {
let setupSignal: AbortSignal | undefined
vi.mocked(startVoiceRecorder).mockImplementationOnce((signal) => {
setupSignal = signal
return new Promise(() => {})
})
const { props } = renderVoiceInput()
expect(screen.getByRole('status')).toHaveTextContent('common.voiceInput.starting')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(props.onCancel).toHaveBeenCalledTimes(1)
expect(setupSignal?.aborted).toBe(true)
})
it('should show the recording state after microphone setup succeeds', async () => {
renderVoiceInput()
expect(await screen.findByText('common.voiceInput.speaking')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeInTheDocument()
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:00')
expect(getByteFrequencyData).toHaveBeenCalledTimes(1)
})
it('should report the original setup failure and close the voice input', async () => {
const startError = new DOMException('microphone denied', 'NotAllowedError')
vi.mocked(startVoiceRecorder).mockRejectedValueOnce(startError)
const { props } = renderVoiceInput()
await waitFor(() => expect(props.onStartError).toHaveBeenCalledWith(startError))
expect(props.onCancel).toHaveBeenCalledTimes(1)
})
it('should cancel recorder resources when unmounted', async () => {
const { unmount } = renderVoiceInput()
await screen.findByText('common.voiceInput.speaking')
unmount()
expect(recorderCancel).toHaveBeenCalledTimes(1)
})
it('should discard a stale recorder when effects are replayed', async () => {
let resolveFirstRecorder: (value: VoiceRecorder) => void = () => {}
let resolveSecondRecorder: (value: VoiceRecorder) => void = () => {}
const staleCancel = vi.fn().mockResolvedValue(undefined)
const activeCancel = vi.fn().mockResolvedValue(undefined)
vi.mocked(startVoiceRecorder)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstRecorder = resolve
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondRecorder = resolve
}),
)
render(
<StrictMode>
<VoiceInput onCancel={vi.fn()} onConverted={vi.fn()} target={target} />
</StrictMode>,
)
resolveSecondRecorder({ ...recorder, cancel: activeCancel })
expect(await screen.findByText('common.voiceInput.speaking')).toBeInTheDocument()
resolveFirstRecorder({ ...recorder, cancel: staleCancel })
await waitFor(() => expect(staleCancel).toHaveBeenCalledTimes(1))
expect(activeCancel).not.toHaveBeenCalled()
})
it('should keep the active recorder when error callbacks change', async () => {
const { props, rerender } = renderVoiceInput()
await screen.findByText('common.voiceInput.speaking')
rerender(<VoiceInput {...props} onCancel={vi.fn()} onStartError={vi.fn()} />)
expect(startVoiceRecorder).toHaveBeenCalledTimes(1)
expect(recorderCancel).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeInTheDocument()
})
})
// Stopping must upload a real MP3 file to the explicit owner-provided target.
describe('Conversion', () => {
it('should upload MP3 bytes to the explicit app target', async () => {
const { props } = renderVoiceInput()
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
expect(await screen.findByRole('status')).toHaveTextContent('common.voiceInput.converting')
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
const [requestTarget, file] = vi.mocked(transcribeAudio).mock.calls[0]!
expect(requestTarget).toBe(target)
expect(file.name).toBe('temp.mp3')
expect(file.type).toBe('audio/mp3')
expect(props.onBeforeTranscribe).toHaveBeenCalledTimes(1)
expect(vi.mocked(props.onBeforeTranscribe!).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(transcribeAudio).mock.invocationCallOrder[0]!,
)
await waitFor(() => expect(props.onConverted).toHaveBeenCalledWith('transcribed text'))
expect(props.onCancel).toHaveBeenCalledTimes(1)
})
it('should ignore repeated stop requests', async () => {
let resolveStop: (blob: Blob) => void = () => {}
recorderStop.mockReturnValueOnce(
new Promise((resolve) => {
resolveStop = resolve
}),
)
renderVoiceInput()
const stopButton = await screen.findByRole('button', { name: 'common.voiceInput.stop' })
fireEvent.click(stopButton)
fireEvent.click(stopButton)
expect(recorderStop).toHaveBeenCalledTimes(1)
resolveStop(new Blob(['mp3-data'], { type: 'audio/mp3' }))
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
})
it('should report conversion failure without replacing the current text', async () => {
vi.mocked(transcribeAudio).mockRejectedValueOnce(new Error('API error'))
const { props } = renderVoiceInput()
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
await waitFor(() => expect(props.onError).toHaveBeenCalledTimes(1))
expect(props.onConverted).not.toHaveBeenCalled()
expect(props.onCancel).toHaveBeenCalledTimes(1)
})
it('should not upload when saving the owning draft fails', async () => {
const onBeforeTranscribe = vi.fn().mockRejectedValue(new Error('save failed'))
const { props } = renderVoiceInput({ onBeforeTranscribe })
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
await waitFor(() => expect(props.onCancel).toHaveBeenCalledTimes(1))
expect(transcribeAudio).not.toHaveBeenCalled()
expect(props.onConverted).not.toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
})
it('should close without publishing late results when conversion is cancelled', async () => {
let requestSignal: AbortSignal | undefined
vi.mocked(transcribeAudio).mockImplementationOnce((_target, _file, signal) => {
requestSignal = signal
return new Promise(() => {})
})
const { props } = renderVoiceInput()
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
expect(recorderCancel).toHaveBeenCalledTimes(1)
expect(requestSignal?.aborted).toBe(true)
expect(props.onCancel).toHaveBeenCalledTimes(1)
expect(props.onConverted).not.toHaveBeenCalled()
})
it('should not upload when conversion is cancelled while the recorder is stopping', async () => {
let resolveStop: (blob: Blob) => void = () => {}
recorderStop.mockReturnValueOnce(
new Promise((resolve) => {
resolveStop = resolve
}),
)
const { props } = renderVoiceInput()
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
resolveStop(new Blob(['mp3-data'], { type: 'audio/mp3' }))
await act(async () => {})
expect(transcribeAudio).not.toHaveBeenCalled()
expect(props.onBeforeTranscribe).not.toHaveBeenCalled()
expect(props.onCancel).toHaveBeenCalledTimes(1)
expect(props.onError).not.toHaveBeenCalled()
})
it('should not upload when conversion is cancelled while the draft is saving', async () => {
let resolveDraftSave: () => void = () => {}
const onBeforeTranscribe = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveDraftSave = resolve
}),
)
const { props } = renderVoiceInput({ onBeforeTranscribe })
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
await waitFor(() => expect(onBeforeTranscribe).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
resolveDraftSave()
await act(async () => {})
expect(transcribeAudio).not.toHaveBeenCalled()
expect(props.onCancel).toHaveBeenCalledTimes(1)
expect(props.onError).not.toHaveBeenCalled()
})
})
// The ten-minute limit is a local timer transition, not a render-time side effect.
describe('Duration limit', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('should update the timer while recording', async () => {
renderVoiceInput()
await act(async () => {})
act(() => vi.advanceTimersByTime(1000))
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:01')
})
it('should stop automatically at ten minutes', async () => {
renderVoiceInput()
await act(async () => {})
act(() => vi.advanceTimersByTime(600_000))
await act(async () => {})
expect(recorderStop).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('10:00')
})
})
})