mirror of
https://github.com/langgenius/dify.git
synced 2026-07-25 05:28:35 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yansong Zhang <916125788@qq.com>
319 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|