mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
test(web): move media controls to Browser Mode (#45653)
This commit is contained in:
1 parent
89e351a684
commit
44c1f344f4
7 files changed
+736
-913
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createWavFixture, paintedPixelCount } from '@/test/browser-media'
|
||||
import AudioPlayer from '../AudioPlayer'
|
||||
|
||||
const fixtures: Array<ReturnType<typeof createWavFixture>> = []
|
||||
const fixture = (options?: Parameters<typeof createWavFixture>[0]) => {
|
||||
const result = createWavFixture(options)
|
||||
fixtures.push(result)
|
||||
return result
|
||||
}
|
||||
|
||||
function AudioSourceCaller({
|
||||
initial,
|
||||
label,
|
||||
next,
|
||||
}: {
|
||||
initial: string
|
||||
label: string
|
||||
next: string
|
||||
}) {
|
||||
const [src, setSrc] = useState(initial)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setSrc(next)}>
|
||||
{label}
|
||||
</button>
|
||||
<AudioPlayer src={src} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fixtures.splice(0).forEach(({ revoke }) => revoke())
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('plays, pauses and finishes real audio through the keyboard controls', async () => {
|
||||
const { url } = fixture()
|
||||
const screen = await render(
|
||||
<div style={{ width: 420 }}>
|
||||
<AudioPlayer src={url} />
|
||||
</div>,
|
||||
)
|
||||
const audio = screen.getByTestId('audio-player').element() as HTMLAudioElement
|
||||
await expect.poll(() => audio.duration).toBeGreaterThan(1.9)
|
||||
await expect.element(screen.getByText('0:02')).toBeVisible()
|
||||
|
||||
const play = screen.getByRole('button', { name: 'common.operation.play' })
|
||||
await userEvent.keyboard('{Tab}')
|
||||
await expect.element(play).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.poll(() => audio.paused).toBe(false)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.pause' })).toBeVisible()
|
||||
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.poll(() => audio.paused).toBe(true)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeVisible()
|
||||
|
||||
audio.currentTime = audio.duration - 0.05
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeVisible()
|
||||
expect(audio.ended).toBe(true)
|
||||
})
|
||||
|
||||
it.fails('keeps the available waveform and playback control pointer-reachable', async () => {
|
||||
// TODO(https://github.com/langgenius/dify/issues/45655): the hidden overlay wins hit testing.
|
||||
const { url } = fixture()
|
||||
const screen = await render(<AudioPlayer src={url} />)
|
||||
const audio = screen.getByTestId('audio-player').element() as HTMLAudioElement
|
||||
await expect.poll(() => audio.duration).toBeGreaterThan(0)
|
||||
const canvas = screen.getByTestId('waveform-canvas').element()
|
||||
const { left, top, width, height } = canvas.getBoundingClientRect()
|
||||
expect(document.elementFromPoint(left + width / 2, top + height / 2)).toBe(canvas)
|
||||
})
|
||||
|
||||
it('decodes the first alternative source into a painted waveform', async () => {
|
||||
// boundary: advance only the waveform delay; native decoding and animation frames stay real.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const first = fixture({ frequency: 220 })
|
||||
const second = fixture({ frequency: 880 })
|
||||
const nativeFetch = window.fetch.bind(window)
|
||||
// boundary: waveform resource request identity is observable at native fetch; canvas and decoder stay real.
|
||||
const waveformFetch = vi.spyOn(window, 'fetch').mockImplementation(nativeFetch)
|
||||
const screen = await render(<AudioPlayer srcs={[first.url, second.url]} />)
|
||||
const audio = screen.getByTestId('audio-player').element() as HTMLAudioElement
|
||||
const sources = Array.from(audio.querySelectorAll('source'), (source) => source.src)
|
||||
expect(sources).toEqual([first.url, second.url])
|
||||
await expect.poll(() => audio.currentSrc).toBe(first.url)
|
||||
await expect.poll(() => audio.duration).toBeGreaterThan(1.9)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await expect.poll(() => waveformFetch.mock.calls[0]?.[0]).toBe(first.url)
|
||||
const canvas = screen.getByTestId('waveform-canvas').element() as HTMLCanvasElement
|
||||
await expect.poll(() => paintedPixelCount(canvas)).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it('keeps a rejected playback attempt visible and retryable', async () => {
|
||||
const { url } = fixture()
|
||||
const screen = await render(<AudioPlayer src={url} />)
|
||||
const audio = screen.getByTestId('audio-player').element() as HTMLAudioElement
|
||||
await expect.poll(() => audio.duration).toBeGreaterThan(0)
|
||||
const nativePlay = audio.play.bind(audio)
|
||||
// boundary: emulate one browser autoplay denial, then delegate retries to native playback.
|
||||
vi.spyOn(audio, 'play')
|
||||
.mockRejectedValueOnce(new DOMException('Playback denied', 'NotAllowedError'))
|
||||
.mockImplementation(nativePlay)
|
||||
// boundary: suppress the expected production log from the intentional playback denial.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const play = screen.getByRole('button', { name: 'common.operation.play' })
|
||||
await userEvent.keyboard('{Tab}')
|
||||
await expect.element(play).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeVisible()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.poll(() => audio.paused).toBe(false)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.pause' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('recovers from an unavailable source when the caller supplies a valid replacement', async () => {
|
||||
const invalid = fixture()
|
||||
invalid.revoke()
|
||||
const valid = fixture()
|
||||
|
||||
const screen = await render(
|
||||
<AudioSourceCaller initial={invalid.url} label="Use valid audio" next={valid.url} />,
|
||||
)
|
||||
await expect.element(screen.getByText('common.operation.audioSourceUnavailable')).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeDisabled()
|
||||
await screen.getByRole('button', { name: 'Use valid audio' }).click()
|
||||
const audio = screen.getByTestId('audio-player').element() as HTMLAudioElement
|
||||
await expect.poll(() => audio.src).toBe(valid.url)
|
||||
await expect.poll(() => audio.duration).toBeGreaterThan(0)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('ignores a failed waveform response from the previous public source', async () => {
|
||||
// boundary: advance only the waveform delay while preserving the native response race.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const oldAudio = fixture({ frequency: 220 })
|
||||
const newAudio = fixture({ frequency: 660 })
|
||||
const nativeFetch = window.fetch.bind(window)
|
||||
let rejectOld!: () => void
|
||||
const oldResponse = new Promise<Response>((resolve) => {
|
||||
rejectOld = () => resolve(new Response(null, { status: 403 }))
|
||||
})
|
||||
// boundary: hold one blob fetch to reproduce an out-of-order browser response.
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
if (String(input) === oldAudio.url) return oldResponse
|
||||
return nativeFetch(input, init)
|
||||
})
|
||||
|
||||
const screen = await render(
|
||||
<AudioSourceCaller initial={oldAudio.url} label="Use next audio" next={newAudio.url} />,
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await expect.poll(() => vi.mocked(window.fetch).mock.calls.length).toBe(1)
|
||||
await screen.getByRole('button', { name: 'Use next audio' }).click()
|
||||
const audio = screen.getByTestId('audio-player').element() as HTMLAudioElement
|
||||
await expect.poll(() => audio.src).toBe(newAudio.url)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
const canvas = screen.getByTestId('waveform-canvas').element() as HTMLCanvasElement
|
||||
await expect.poll(() => paintedPixelCount(canvas)).toBeGreaterThan(100)
|
||||
rejectOld()
|
||||
await oldResponse
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeEnabled()
|
||||
expect(audio.src).toBe(newAudio.url)
|
||||
})
|
||||
@@ -1,224 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { toast } from '@/app/notifications'
|
||||
import AudioPlayer from '../AudioPlayer'
|
||||
|
||||
const decode = vi.fn()
|
||||
const close = vi.fn()
|
||||
const drawBar = vi.fn()
|
||||
const getAudio = () => screen.getByTestId('audio-player') as HTMLAudioElement
|
||||
const playButton = () => screen.getByRole('button', { name: 'common.operation.play' })
|
||||
const user = () => userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
async function generateWaveform() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
}
|
||||
function loadMetadata(duration = 120) {
|
||||
const audio = getAudio()
|
||||
Object.defineProperty(audio, 'duration', { configurable: true, value: duration })
|
||||
fireEvent.loadedMetadata(audio)
|
||||
const canvas = screen.getByTestId('waveform-canvas')
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 20,
|
||||
right: 200,
|
||||
bottom: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
return { audio, canvas }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.clearAllMocks()
|
||||
decode.mockResolvedValue({ getChannelData: () => new Float32Array(140).fill(0.5) })
|
||||
close.mockResolvedValue(undefined)
|
||||
vi.stubGlobal(
|
||||
'AudioContext',
|
||||
class {
|
||||
decodeAudioData = decode
|
||||
close = close
|
||||
},
|
||||
)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ArrayBuffer(128))))
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: drawBar,
|
||||
fill: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('AudioPlayer', () => {
|
||||
it('plays, pauses, and returns to play when playback ends', async () => {
|
||||
const interaction = user()
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
await interaction.click(playButton())
|
||||
expect(getAudio().play).toHaveBeenCalledOnce()
|
||||
await interaction.click(screen.getByRole('button', { name: 'common.operation.pause' }))
|
||||
expect(getAudio().pause).toHaveBeenCalledOnce()
|
||||
await interaction.click(playButton())
|
||||
fireEvent.ended(getAudio())
|
||||
expect(playButton()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps a rejected playback attempt retryable', async () => {
|
||||
const interaction = user()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new Error('Playback denied'))
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
await interaction.click(playButton())
|
||||
expect(playButton()).toBeEnabled()
|
||||
await interaction.click(playButton())
|
||||
expect(getAudio().play).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('button', { name: 'common.operation.pause' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an unavailable source and prevents playback after a media error', async () => {
|
||||
const interaction = user()
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
fireEvent.error(getAudio())
|
||||
await interaction.click(playButton())
|
||||
expect(playButton()).toBeDisabled()
|
||||
expect(screen.getByText('common.operation.audioSourceUnavailable')).toBeVisible()
|
||||
expect(getAudio().play).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the first alternative source and displays its duration', async () => {
|
||||
render(<AudioPlayer srcs={['https://example.com/a.mp3', 'https://example.com/b.ogg']} />)
|
||||
loadMetadata(90)
|
||||
expect(screen.getByText('1:30')).toBeInTheDocument()
|
||||
expect(Array.from(getAudio().querySelectorAll('source'), (source) => source.src)).toEqual([
|
||||
'https://example.com/a.mp3',
|
||||
'https://example.com/b.ogg',
|
||||
])
|
||||
await generateWaveform()
|
||||
expect(fetch).toHaveBeenCalledWith('https://example.com/a.mp3', { mode: 'cors' })
|
||||
expect(decode).toHaveBeenCalledOnce()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['silence', new Float32Array(140)],
|
||||
['short audio', new Float32Array([0.1, -0.2, 0.3])],
|
||||
])('draws finite waveform bars for %s', async (_name, samples) => {
|
||||
decode.mockResolvedValue({ getChannelData: () => samples })
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
await generateWaveform()
|
||||
expect(drawBar).toHaveBeenCalledTimes(70)
|
||||
for (const call of drawBar.mock.calls) expect(call.every(Number.isFinite)).toBe(true)
|
||||
expect(playButton()).toBeEnabled()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps playback available when waveform decoding fails and closes the audio context', async () => {
|
||||
const interaction = user()
|
||||
decode.mockRejectedValue(new Error('Unsupported decoder'))
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
await generateWaveform()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(drawBar).toHaveBeenCalled()
|
||||
await interaction.click(playButton())
|
||||
expect(getAudio().play).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables unavailable HTTP audio and releases its context', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 403 }))
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
await generateWaveform()
|
||||
expect(playButton()).toBeDisabled()
|
||||
expect(decode).not.toHaveBeenCalled()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['/audio.mp3', 'blob:https://example.com/audio'])(
|
||||
'supports playable source %s',
|
||||
async (src) => {
|
||||
render(<AudioPlayer src={src} />)
|
||||
await generateWaveform()
|
||||
expect(fetch).toHaveBeenCalledWith(src, { mode: 'cors' })
|
||||
expect(playButton()).toBeEnabled()
|
||||
expect(drawBar).toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('resets failed media on source change and ignores results from the old source', async () => {
|
||||
let resolveOld!: (response: Response) => void
|
||||
vi.mocked(fetch).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve
|
||||
}),
|
||||
)
|
||||
const { rerender } = render(<AudioPlayer src="https://example.com/old.mp3" />)
|
||||
await generateWaveform()
|
||||
fireEvent.error(getAudio())
|
||||
expect(playButton()).toBeDisabled()
|
||||
rerender(<AudioPlayer src="https://example.com/new.mp3" />)
|
||||
expect(playButton()).toBeEnabled()
|
||||
await generateWaveform()
|
||||
await act(async () => {
|
||||
resolveOld(new Response(null, { status: 403 }))
|
||||
})
|
||||
expect(playButton()).toBeEnabled()
|
||||
expect(close).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels scheduled waveform work when unmounted', async () => {
|
||||
const { unmount } = render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
unmount()
|
||||
await generateWaveform()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['mouse', 'touch'] as const)(
|
||||
'seeks to the %s position and avoids restarting active playback',
|
||||
async (kind) => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const { audio, canvas } = loadMetadata()
|
||||
await act(async () => {
|
||||
if (kind === 'mouse') fireEvent.click(canvas, { clientX: 100 })
|
||||
else fireEvent.touchStart(canvas, { touches: [{ clientX: 100 }] })
|
||||
})
|
||||
expect(audio.currentTime).toBe(60)
|
||||
expect(audio.play).toHaveBeenCalledOnce()
|
||||
fireEvent.click(canvas, { clientX: 150 })
|
||||
expect(audio.currentTime).toBe(90)
|
||||
expect(audio.play).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
|
||||
it('does not seek before metadata is available', () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
fireEvent.click(screen.getByTestId('waveform-canvas'), { clientX: 100 })
|
||||
expect(getAudio().play).not.toHaveBeenCalled()
|
||||
expect(getAudio().currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it('returns to play and reports a rejected seek playback', async () => {
|
||||
const notify = vi.spyOn(toast, 'error').mockReturnValue('error')
|
||||
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValue(new Error('Seek denied'))
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const { canvas } = loadMetadata()
|
||||
await act(async () => {
|
||||
fireEvent.click(canvas, { clientX: 100 })
|
||||
})
|
||||
expect(playButton()).toBeEnabled()
|
||||
expect(notify).toHaveBeenCalledWith('Error playing audio: Error: Seek denied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createVideoFixture } from '@/test/browser-media'
|
||||
import VideoPlayer from '../VideoPlayer'
|
||||
|
||||
const fixtures: Array<ReturnType<typeof createVideoFixture>> = []
|
||||
const fixture = () => {
|
||||
const result = createVideoFixture()
|
||||
fixtures.push(result)
|
||||
return result
|
||||
}
|
||||
|
||||
const renderPlayer = async (src = fixture().url) => {
|
||||
const screen = await render(
|
||||
<div style={{ width: '100vw' }}>
|
||||
<VideoPlayer src={src} />
|
||||
</div>,
|
||||
)
|
||||
const video = screen.getByTestId('video-element').element() as HTMLVideoElement
|
||||
await expect.poll(() => video.duration).toBeGreaterThan(0)
|
||||
return { screen, video }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fixtures.splice(0).forEach(({ revoke }) => revoke())
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('plays, pauses and returns to play after real media ends', async () => {
|
||||
await page.viewport(640, 640)
|
||||
const { screen, video } = await renderPlayer()
|
||||
video.playbackRate = 0.25
|
||||
const play = screen.getByRole('button', { name: 'common.operation.play' })
|
||||
await play.click()
|
||||
await expect.poll(() => video.paused).toBe(false)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.pause' })).toBeVisible()
|
||||
await screen.getByRole('button', { name: 'common.operation.pause' }).click()
|
||||
await expect.poll(() => video.paused).toBe(true)
|
||||
|
||||
video.currentTime = Math.max(0, video.duration - 0.03)
|
||||
await screen.getByRole('button', { name: 'common.operation.play' }).click()
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeVisible()
|
||||
expect(video.ended).toBe(true)
|
||||
})
|
||||
|
||||
it('seeks and changes volume at native pointer positions', async () => {
|
||||
await page.viewport(640, 640)
|
||||
const { screen, video } = await renderPlayer()
|
||||
const progress = screen.getByTestId('video-progress-bar')
|
||||
const progressBounds = progress.element().getBoundingClientRect()
|
||||
expect(progressBounds.width).toBeGreaterThan(400)
|
||||
await progress.hover({
|
||||
position: { x: progressBounds.width * 0.75, y: progressBounds.height / 2 },
|
||||
})
|
||||
await expect.element(screen.getByTestId('video-hover-time')).toBeVisible()
|
||||
await userEvent.click(progress, {
|
||||
position: { x: progressBounds.width * 0.75, y: progressBounds.height / 2 },
|
||||
})
|
||||
await expect.poll(() => video.currentTime / video.duration).toBeCloseTo(0.75, 1)
|
||||
|
||||
const volume = screen.getByTestId('video-volume-slider')
|
||||
const volumeBounds = volume.element().getBoundingClientRect()
|
||||
await userEvent.click(volume, {
|
||||
position: { x: volumeBounds.width * 0.4, y: volumeBounds.height / 2 },
|
||||
})
|
||||
expect(video.volume).toBeCloseTo(0.4, 1)
|
||||
expect(video.muted).toBe(false)
|
||||
await userEvent.click(volume, { position: { x: 0, y: volumeBounds.height / 2 } })
|
||||
expect(video.volume).toBe(0)
|
||||
expect(video.muted).toBe(true)
|
||||
const mute = screen.getByRole('button', { name: 'common.operation.toggleMute' })
|
||||
await expect.element(mute).toHaveAttribute('aria-pressed', 'true')
|
||||
video.volume = 0.6
|
||||
await mute.click()
|
||||
expect(video.muted).toBe(false)
|
||||
expect(video.volume).toBeCloseTo(0.6)
|
||||
})
|
||||
|
||||
it.fails('keeps the custom exit control available in native fullscreen', async () => {
|
||||
// TODO(https://github.com/langgenius/dify/issues/45656): sibling controls stay outside fullscreen.
|
||||
await page.viewport(640, 640)
|
||||
const { screen, video } = await renderPlayer()
|
||||
const fullscreen = screen.getByRole('button', { name: 'common.operation.toggleFullscreen' })
|
||||
try {
|
||||
await fullscreen.click()
|
||||
await expect.poll(() => document.fullscreenElement).toBe(video)
|
||||
const bounds = fullscreen.element().getBoundingClientRect()
|
||||
expect(bounds.left).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds.right).toBeLessThanOrEqual(window.innerWidth)
|
||||
expect(bounds.top).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight)
|
||||
await fullscreen.click()
|
||||
await expect.poll(() => document.fullscreenElement).toBeNull()
|
||||
} finally {
|
||||
if (document.fullscreenElement) await document.exitFullscreen()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the real container width to reveal desktop controls after a viewport change', async () => {
|
||||
await page.viewport(320, 640)
|
||||
const { screen } = await renderPlayer()
|
||||
await expect.element(screen.getByTestId('video-time-display')).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('video-volume-slider')).not.toBeInTheDocument()
|
||||
|
||||
await page.viewport(640, 640)
|
||||
await expect.element(screen.getByTestId('video-time-display')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('video-volume-slider')).toBeVisible()
|
||||
})
|
||||
|
||||
it('visually hides idle controls and reveals them on native hover', async () => {
|
||||
// boundary: advance only the idle delay; native hover and CSS opacity transitions stay real.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await page.viewport(640, 640)
|
||||
const { screen } = await renderPlayer()
|
||||
const player = screen.getByTestId('video-player-container')
|
||||
const controls = screen.getByTestId('video-controls')
|
||||
await player.hover()
|
||||
await vi.advanceTimersByTimeAsync(3_001)
|
||||
await expect.poll(() => Number.parseFloat(getComputedStyle(controls.element()).opacity)).toBe(0)
|
||||
|
||||
await userEvent.unhover(player)
|
||||
await player.hover()
|
||||
await expect.poll(() => Number.parseFloat(getComputedStyle(controls.element()).opacity)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a rejected playback attempt retryable', async () => {
|
||||
await page.viewport(640, 640)
|
||||
const { screen, video } = await renderPlayer()
|
||||
const nativePlay = video.play.bind(video)
|
||||
// boundary: emulate one browser autoplay denial, then delegate retries to native playback.
|
||||
vi.spyOn(video, 'play')
|
||||
.mockRejectedValueOnce(new DOMException('Playback denied', 'NotAllowedError'))
|
||||
.mockImplementation(nativePlay)
|
||||
// boundary: suppress the expected production log from the intentional playback denial.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await screen.getByRole('button', { name: 'common.operation.play' }).click()
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeVisible()
|
||||
await screen.getByRole('button', { name: 'common.operation.play' }).click()
|
||||
await expect.poll(() => video.paused).toBe(false)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.operation.pause' })).toBeVisible()
|
||||
})
|
||||
@@ -1,361 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import VideoPlayer from '../VideoPlayer'
|
||||
|
||||
describe('VideoPlayer', () => {
|
||||
const mockSrc = 'video.mp4'
|
||||
const mockSrcs = ['video1.mp4', 'video2.mp4']
|
||||
|
||||
const mockBoundingRect = (element: Element) => {
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
width: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 10,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
const getPlayButton = () => screen.getByRole('button', { name: 'common.operation.play' })
|
||||
const getPauseButton = () => screen.getByRole('button', { name: 'common.operation.pause' })
|
||||
const getMuteButton = () => screen.getByRole('button', { name: 'common.operation.toggleMute' })
|
||||
const getFullscreenButton = () =>
|
||||
screen.getByRole('button', { name: 'common.operation.toggleFullscreen' })
|
||||
|
||||
const originalFullscreen = Object.getOwnPropertyDescriptor(
|
||||
HTMLVideoElement.prototype,
|
||||
'requestFullscreen',
|
||||
)
|
||||
const originalExitFullscreen = Object.getOwnPropertyDescriptor(document, 'exitFullscreen')
|
||||
const originalFullscreenElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'requestFullscreen', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
Object.defineProperty(document, 'exitFullscreen', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(500)
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'duration', 'get').mockReturnValue(100)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
for (const [target, key, descriptor] of [
|
||||
[HTMLVideoElement.prototype, 'requestFullscreen', originalFullscreen],
|
||||
[document, 'exitFullscreen', originalExitFullscreen],
|
||||
[document, 'fullscreenElement', originalFullscreenElement],
|
||||
] as const) {
|
||||
if (descriptor) Object.defineProperty(target, key, descriptor)
|
||||
else Reflect.deleteProperty(target, key)
|
||||
}
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render with single src', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
expect(video.src).toContain(mockSrc)
|
||||
})
|
||||
|
||||
it('should render with multiple srcs', () => {
|
||||
render(<VideoPlayer srcs={mockSrcs} />)
|
||||
const sources = screen.getByTestId('video-element').querySelectorAll('source')
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources[0]!.src).toContain(mockSrcs[0])
|
||||
expect(sources[1]!.src).toContain(mockSrcs[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should toggle play/pause on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const playPauseBtn = getPlayButton()
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled()
|
||||
|
||||
await user.click(getPauseButton())
|
||||
expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle mute on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
const muteBtn = getMuteButton()
|
||||
|
||||
video.volume = 0.7
|
||||
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(true)
|
||||
|
||||
expect(video.volume).toBe(0.7)
|
||||
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(false)
|
||||
expect(video.volume).toBe(0.7)
|
||||
})
|
||||
|
||||
it('should toggle fullscreen on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const fullscreenBtn = getFullscreenButton()
|
||||
|
||||
await user.click(fullscreenBtn)
|
||||
expect(window.HTMLVideoElement.prototype.requestFullscreen).toHaveBeenCalled()
|
||||
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return {}
|
||||
},
|
||||
})
|
||||
await user.click(fullscreenBtn)
|
||||
expect(document.exitFullscreen).toHaveBeenCalled()
|
||||
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return null
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle video metadata and time updates', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
fireEvent(video, new Event('loadedmetadata'))
|
||||
expect(screen.getByTestId('video-time-display'))!.toHaveTextContent('00:00 / 01:40')
|
||||
|
||||
Object.defineProperty(video, 'currentTime', { value: 30, configurable: true })
|
||||
fireEvent(video, new Event('timeupdate'))
|
||||
expect(screen.getByTestId('video-time-display'))!.toHaveTextContent('00:30 / 01:40')
|
||||
})
|
||||
|
||||
it('should handle video end', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element')
|
||||
const playPauseBtn = getPlayButton()
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
fireEvent(video, new Event('ended'))
|
||||
|
||||
expect(getPlayButton())!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle progress bar interactions', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(progressBar)
|
||||
|
||||
fireEvent.mouseMove(progressBar, { clientX: 50 })
|
||||
expect(screen.getByTestId('video-hover-time'))!.toHaveTextContent('00:50')
|
||||
fireEvent.mouseLeave(progressBar)
|
||||
expect(screen.queryByTestId('video-hover-time')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(progressBar, { clientX: 75 })
|
||||
expect(video.currentTime).toBe(75)
|
||||
|
||||
fireEvent.mouseDown(progressBar, { clientX: 20 })
|
||||
expect(video.currentTime).toBe(20)
|
||||
fireEvent.mouseMove(document, { clientX: 40 })
|
||||
expect(video.currentTime).toBe(40)
|
||||
fireEvent.mouseUp(document)
|
||||
fireEvent.mouseMove(document, { clientX: 60 })
|
||||
expect(video.currentTime).toBe(40)
|
||||
})
|
||||
|
||||
it('should handle volume slider change', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const volumeSlider = screen.getByTestId('video-volume-slider')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(volumeSlider)
|
||||
|
||||
fireEvent.click(volumeSlider, { clientX: 50 })
|
||||
expect(video.volume).toBe(0.5)
|
||||
|
||||
fireEvent.mouseDown(volumeSlider, { clientX: 80 })
|
||||
expect(video.volume).toBe(0.8)
|
||||
|
||||
fireEvent.mouseMove(document, { clientX: 90 })
|
||||
expect(video.volume).toBe(0.9)
|
||||
|
||||
fireEvent.mouseUp(document) // Trigger cleanup
|
||||
fireEvent.mouseMove(document, { clientX: 100 })
|
||||
expect(video.volume).toBe(0.9) // No change after mouseUp
|
||||
})
|
||||
|
||||
it('should handle small size class based on offsetWidth', async () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const playerContainer = screen.getByTestId('video-player-container')
|
||||
|
||||
Object.defineProperty(playerContainer, 'offsetWidth', { value: 300, configurable: true })
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('video-time-display')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
Object.defineProperty(playerContainer, 'offsetWidth', { value: 500, configurable: true })
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('video-time-display'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle play() rejection error', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new Error('Play failed'))
|
||||
const user = userEvent.setup()
|
||||
|
||||
try {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const playPauseBtn = getPlayButton()
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPlayButton()).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.pause' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('should reset volume to 1 when unmuting with volume at 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
const muteBtn = getMuteButton()
|
||||
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(true)
|
||||
expect(video.volume).toBe(1)
|
||||
|
||||
video.volume = 0
|
||||
|
||||
await user.click(muteBtn)
|
||||
expect(video.muted).toBe(false)
|
||||
expect(video.volume).toBe(1)
|
||||
})
|
||||
|
||||
it('should not clear hoverTime on mouseLeave while dragging', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
|
||||
mockBoundingRect(progressBar)
|
||||
|
||||
fireEvent.mouseDown(progressBar, { clientX: 50 })
|
||||
|
||||
fireEvent.mouseLeave(progressBar)
|
||||
expect(screen.getByTestId('video-hover-time'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseUp(document)
|
||||
})
|
||||
|
||||
it('should not update time for out-of-bounds progress click', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(progressBar)
|
||||
|
||||
fireEvent.click(progressBar, { clientX: 200 })
|
||||
expect(video.currentTime).toBe(0)
|
||||
|
||||
fireEvent.click(progressBar, { clientX: -50 })
|
||||
expect(video.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it('should show controls on mouseEnter', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const container = screen.getByTestId('video-player-container')
|
||||
const controls = screen.getByTestId('video-controls')
|
||||
|
||||
expect(controls)!.toHaveAttribute('data-is-visible', 'true')
|
||||
|
||||
fireEvent.mouseMove(container)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3001)
|
||||
})
|
||||
expect(controls)!.toHaveAttribute('data-is-visible', 'false')
|
||||
|
||||
fireEvent.mouseEnter(container)
|
||||
expect(controls)!.toHaveAttribute('data-is-visible', 'true')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('unmutes playback when the volume slider is raised', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
const slider = screen.getByTestId('video-volume-slider')
|
||||
mockBoundingRect(slider)
|
||||
await user.click(getMuteButton())
|
||||
expect(video.muted).toBe(true)
|
||||
fireEvent.click(slider, { clientX: 40 })
|
||||
expect(video.muted).toBe(false)
|
||||
expect(video.volume).toBe(0.4)
|
||||
expect(getMuteButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('removes volume drag listeners if the player unmounts before mouseup', () => {
|
||||
const addListener = vi.spyOn(document, 'addEventListener')
|
||||
const removeListener = vi.spyOn(document, 'removeEventListener')
|
||||
const { unmount } = render(<VideoPlayer src={mockSrc} />)
|
||||
const slider = screen.getByTestId('video-volume-slider')
|
||||
mockBoundingRect(slider)
|
||||
fireEvent.mouseDown(slider, { clientX: 50 })
|
||||
const move = [...addListener.mock.calls]
|
||||
.reverse()
|
||||
.find(([event]) => event === 'mousemove')![1]
|
||||
const up = [...addListener.mock.calls].reverse().find(([event]) => event === 'mouseup')![1]
|
||||
|
||||
unmount()
|
||||
|
||||
expect(removeListener).toHaveBeenCalledWith('mousemove', move)
|
||||
expect(removeListener).toHaveBeenCalledWith('mouseup', up)
|
||||
})
|
||||
|
||||
it('should clamp volume slider to max 1', () => {
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const volumeSlider = screen.getByTestId('video-volume-slider')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
mockBoundingRect(volumeSlider)
|
||||
|
||||
fireEvent.click(volumeSlider, { clientX: 200 })
|
||||
expect(video.volume).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { consoleClient } from '@/service/console'
|
||||
import type { MicrophoneFixture } from '@/test/browser-media'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { createMicrophoneFixture, paintedPixelCount } from '@/test/browser-media'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
import VoiceInput from '../index'
|
||||
|
||||
// boundary: transcription stops at the generated console HTTP client; no external request is sent.
|
||||
const api = vi.hoisted(() => ({
|
||||
transcribe: vi.fn<typeof consoleClient.apps.byAppId.audioToText.post>(),
|
||||
}))
|
||||
|
||||
// boundary: retain the real transcription adapter while replacing only its generated HTTP transport.
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: withConsoleClientResponses(actual.consoleClient, {
|
||||
'apps.byAppId.audioToText.post': api.transcribe,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const target = { type: 'consoleApp' as const, appId: 'app-123' }
|
||||
const microphones: MicrophoneFixture[] = []
|
||||
const recorderCloseGates: Array<ReturnType<typeof createDeferred<void>>> = []
|
||||
let beforeTranscribe: () => Promise<unknown>
|
||||
let beforeTranscribeCalls = 0
|
||||
|
||||
function VoiceInputCaller() {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const restoreFocusAfterCloseRef = useRef(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('original draft')
|
||||
const [error, setError] = useState('')
|
||||
const close = () => {
|
||||
restoreFocusAfterCloseRef.current = true
|
||||
setOpen(false)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (open || !restoreFocusAfterCloseRef.current) return
|
||||
restoreFocusAfterCloseRef.current = false
|
||||
triggerRef.current?.focus()
|
||||
}, [open])
|
||||
return (
|
||||
<div style={{ minHeight: 80, position: 'relative', width: 420 }}>
|
||||
{!open && (
|
||||
<button ref={triggerRef} type="button" onClick={() => setOpen(true)}>
|
||||
Start voice input
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<VoiceInput
|
||||
target={target}
|
||||
onBeforeTranscribe={() => beforeTranscribe()}
|
||||
onConverted={setDraft}
|
||||
onCancel={close}
|
||||
onError={() => setError('conversion failed')}
|
||||
onStartError={(reason) =>
|
||||
setError(
|
||||
reason instanceof Error || reason instanceof DOMException
|
||||
? reason.message
|
||||
: 'microphone failed',
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<output aria-label="Draft text">{draft}</output>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const addMicrophone = () => {
|
||||
const microphone = createMicrophoneFixture()
|
||||
microphones.push(microphone)
|
||||
return microphone
|
||||
}
|
||||
|
||||
const resumeMicrophones = () => Promise.all(microphones.map(({ resume }) => resume()))
|
||||
|
||||
const renderCaller = async () => {
|
||||
const screen = await render(<VoiceInputCaller />)
|
||||
await screen.getByRole('button', { name: 'Start voice input' }).click()
|
||||
await resumeMicrophones()
|
||||
return screen
|
||||
}
|
||||
|
||||
const recordingCanvas = () => document.querySelector('canvas') as HTMLCanvasElement
|
||||
|
||||
const waitForRecording = async (screen: Awaited<ReturnType<typeof render>>) => {
|
||||
await expect.element(screen.getByText('common.voiceInput.speaking')).toBeVisible()
|
||||
await expect.poll(() => paintedPixelCount(recordingCanvas())).toBeGreaterThan(0)
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// boundary: provide the default generated-client HTTP response without external network access.
|
||||
api.transcribe.mockReset().mockResolvedValue({ text: 'transcribed text' })
|
||||
beforeTranscribeCalls = 0
|
||||
beforeTranscribe = async () => {
|
||||
beforeTranscribeCalls++
|
||||
}
|
||||
const microphone = addMicrophone()
|
||||
// boundary: the test page has no physical microphone; feed the real recorder a native stream.
|
||||
vi.spyOn(navigator.mediaDevices, 'getUserMedia').mockResolvedValue(microphone.stream)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
recorderCloseGates.splice(0).forEach(({ resolve }) => resolve())
|
||||
await Promise.allSettled(microphones.splice(0).map(({ dispose }) => dispose()))
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lets the user cancel while microphone permission is pending', async () => {
|
||||
const pendingMicrophone = createDeferred<MediaStream>()
|
||||
// boundary: keep the physical microphone permission request pending until the user cancels.
|
||||
vi.mocked(navigator.mediaDevices.getUserMedia)
|
||||
.mockReset()
|
||||
.mockReturnValue(pendingMicrophone.promise)
|
||||
const screen = await renderCaller()
|
||||
const starting = screen.getByText('common.voiceInput.starting')
|
||||
await expect.element(starting).toBeVisible()
|
||||
await expect.poll(() => vi.mocked(navigator.mediaDevices.getUserMedia).mock.calls.length).toBe(1)
|
||||
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
|
||||
await expect.element(starting).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toBeVisible()
|
||||
pendingMicrophone.resolve(microphones[0]!.stream)
|
||||
await expect.poll(() => microphones[0]!.track.readyState).toBe('ended')
|
||||
})
|
||||
|
||||
it('releases a late microphone after a public cancel and keeps the reopened session usable', async () => {
|
||||
const staleMicrophone = microphones[0]!
|
||||
const activeMicrophone = addMicrophone()
|
||||
const pendingMicrophone = createDeferred<MediaStream>()
|
||||
// boundary: deliver two physical microphone results out of order across public sessions.
|
||||
vi.mocked(navigator.mediaDevices.getUserMedia)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(pendingMicrophone.promise)
|
||||
.mockResolvedValueOnce(activeMicrophone.stream)
|
||||
|
||||
const screen = await renderCaller()
|
||||
const starting = screen.getByText('common.voiceInput.starting')
|
||||
await expect.element(starting).toBeVisible()
|
||||
await expect.poll(() => vi.mocked(navigator.mediaDevices.getUserMedia).mock.calls.length).toBe(1)
|
||||
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
await expect.element(starting).not.toBeInTheDocument()
|
||||
await screen.getByRole('button', { name: 'Start voice input' }).click()
|
||||
await resumeMicrophones()
|
||||
await waitForRecording(screen)
|
||||
pendingMicrophone.resolve(staleMicrophone.stream)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeVisible()
|
||||
await expect.poll(() => staleMicrophone.track.readyState).toBe('ended')
|
||||
expect(activeMicrophone.track.readyState).toBe('live')
|
||||
await screen.getByRole('button', { name: 'common.voiceInput.stop' }).click()
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('transcribed text')
|
||||
})
|
||||
|
||||
it('publishes a decodable recorded MP3 through the caller and restores keyboard focus', async () => {
|
||||
const screen = await renderCaller()
|
||||
const stop = screen.getByRole('button', { name: 'common.voiceInput.stop' })
|
||||
await expect.element(stop).toBeVisible()
|
||||
await waitForRecording(screen)
|
||||
await userEvent.keyboard('{Tab}')
|
||||
await expect.element(stop).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('transcribed text')
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toHaveFocus()
|
||||
|
||||
const [input] = api.transcribe.mock.calls[0]!
|
||||
const file = input.body.file
|
||||
expect(input.params).toEqual({ app_id: 'app-123' })
|
||||
expect(file).toBeInstanceOf(File)
|
||||
const recordedFile = file as File
|
||||
expect(recordedFile.name).toBe('temp.mp3')
|
||||
expect(file.type).toBe('audio/mp3')
|
||||
expect(file.size).toBeGreaterThan(0)
|
||||
const decodingContext = new AudioContext()
|
||||
try {
|
||||
const decoded = await decodingContext.decodeAudioData(await recordedFile.arrayBuffer())
|
||||
expect(decoded.duration).toBeGreaterThan(0)
|
||||
expect(decoded.numberOfChannels).toBe(1)
|
||||
} finally {
|
||||
await decodingContext.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the original draft when cancelled during recorder completion', async () => {
|
||||
const microphone = microphones[0]!
|
||||
const closeGate = createDeferred()
|
||||
const nativeClose = AudioContext.prototype.close
|
||||
const recorderCloseStarted = createDeferred()
|
||||
recorderCloseGates.push(closeGate)
|
||||
// boundary: delay completion of native recorder cleanup while invoking native close immediately.
|
||||
vi.spyOn(AudioContext.prototype, 'close').mockImplementation(function (this: AudioContext) {
|
||||
const nativeCompletion = nativeClose.call(this)
|
||||
if (this === microphone.context) return nativeCompletion
|
||||
recorderCloseStarted.resolve()
|
||||
return Promise.all([nativeCompletion, closeGate.promise]).then(() => undefined)
|
||||
})
|
||||
|
||||
const screen = await renderCaller()
|
||||
await waitForRecording(screen)
|
||||
await screen.getByRole('button', { name: 'common.voiceInput.stop' }).click()
|
||||
await expect.element(screen.getByText('common.voiceInput.converting')).toBeVisible()
|
||||
await recorderCloseStarted.promise
|
||||
expect(microphone.track.readyState).toBe('ended')
|
||||
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
closeGate.resolve()
|
||||
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('original draft')
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toBeVisible()
|
||||
expect(api.transcribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the original draft when cancelled during the caller draft-save callback', async () => {
|
||||
const draftSave = createDeferred()
|
||||
beforeTranscribe = () => {
|
||||
beforeTranscribeCalls++
|
||||
return draftSave.promise
|
||||
}
|
||||
const screen = await renderCaller()
|
||||
await waitForRecording(screen)
|
||||
await screen.getByRole('button', { name: 'common.voiceInput.stop' }).click()
|
||||
await expect.element(screen.getByText('common.voiceInput.converting')).toBeVisible()
|
||||
await expect.poll(() => beforeTranscribeCalls).toBe(1)
|
||||
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
draftSave.resolve()
|
||||
await draftSave.promise
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('original draft')
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toBeVisible()
|
||||
expect(api.transcribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the original draft and aborts HTTP when cancelled during transcription', async () => {
|
||||
const response = createDeferred<{ text: string }>()
|
||||
// boundary: hold the generated-client HTTP response so cancellation can abort it in flight.
|
||||
api.transcribe.mockReturnValueOnce(response.promise)
|
||||
const screen = await renderCaller()
|
||||
await waitForRecording(screen)
|
||||
await screen.getByRole('button', { name: 'common.voiceInput.stop' }).click()
|
||||
await expect.element(screen.getByText('common.voiceInput.converting')).toBeVisible()
|
||||
await expect.poll(() => api.transcribe.mock.calls.length).toBe(1)
|
||||
const oldRequestSignal = api.transcribe.mock.calls[0]![1]?.signal
|
||||
await screen.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toBeVisible()
|
||||
|
||||
const reopenedMicrophone = addMicrophone()
|
||||
// boundary: a reopened public session receives a fresh native microphone stream.
|
||||
vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(reopenedMicrophone.stream)
|
||||
await screen.getByRole('button', { name: 'Start voice input' }).click()
|
||||
await resumeMicrophones()
|
||||
await waitForRecording(screen)
|
||||
response.resolve({ text: 'late transcript' })
|
||||
await response.promise
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('original draft')
|
||||
await expect.element(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeVisible()
|
||||
expect(oldRequestSignal?.aborted).toBe(true)
|
||||
await screen.getByRole('button', { name: 'common.voiceInput.stop' }).click()
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('transcribed text')
|
||||
})
|
||||
|
||||
it('shows a microphone setup failure without replacing the draft', async () => {
|
||||
// boundary: emulate a physical microphone permission denial from the browser.
|
||||
vi.mocked(navigator.mediaDevices.getUserMedia)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new DOMException('microphone denied', 'NotAllowedError'))
|
||||
const screen = await renderCaller()
|
||||
await expect.element(screen.getByRole('alert')).toHaveTextContent('microphone denied')
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('original draft')
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('reports a transcription transport failure without replacing the draft', async () => {
|
||||
// boundary: reject the generated-client HTTP transport without sending an external request.
|
||||
api.transcribe.mockRejectedValueOnce(new Error('API failed'))
|
||||
const screen = await renderCaller()
|
||||
await waitForRecording(screen)
|
||||
await screen.getByRole('button', { name: 'common.voiceInput.stop' }).click()
|
||||
await expect.element(screen.getByRole('alert')).toHaveTextContent('conversion failed')
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('original draft')
|
||||
await expect.element(screen.getByRole('button', { name: 'Start voice input' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('stops at the ten-minute limit and publishes the visible result', async () => {
|
||||
// boundary: advance only the duration interval; native animation frames and audio stay real.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
const response = createDeferred<{ text: string }>()
|
||||
// boundary: hold the generated-client HTTP response while observing the converting state.
|
||||
api.transcribe.mockReturnValueOnce(response.promise)
|
||||
const screen = await renderCaller()
|
||||
await waitForRecording(screen)
|
||||
await expect.element(screen.getByRole('timer')).toHaveTextContent('00:00')
|
||||
await vi.advanceTimersByTimeAsync(595_000)
|
||||
await expect.element(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeVisible()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await expect.element(screen.getByText('common.voiceInput.converting')).toBeVisible()
|
||||
await expect.poll(() => api.transcribe.mock.calls.length).toBe(1)
|
||||
response.resolve({ text: 'transcribed text' })
|
||||
await expect.element(screen.getByLabelText('Draft text')).toHaveTextContent('transcribed text')
|
||||
})
|
||||
@@ -1,328 +0,0 @@
|
||||
import type { VoiceRecorder } from '../recorder'
|
||||
import { act, cleanup, 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())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// 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.getByRole('timer')).toHaveTextContent('00:00')
|
||||
})
|
||||
|
||||
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
|
||||
let resolveTranscription: (value: { text: string }) => void = () => {}
|
||||
vi.mocked(transcribeAudio).mockImplementationOnce((_target, _file, signal) => {
|
||||
requestSignal = signal
|
||||
return new Promise((resolve) => {
|
||||
resolveTranscription = resolve
|
||||
})
|
||||
})
|
||||
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)
|
||||
await act(async () => resolveTranscription({ text: 'stale transcription' }))
|
||||
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(() => {
|
||||
cleanup()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should update the timer while recording', async () => {
|
||||
renderVoiceInput()
|
||||
await act(async () => {})
|
||||
|
||||
act(() => vi.advanceTimersByTime(1000))
|
||||
|
||||
expect(screen.getByRole('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.getByRole('timer')).toHaveTextContent('10:00')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
type WavFixture = {
|
||||
bytes: Uint8Array
|
||||
revoke: () => void
|
||||
url: string
|
||||
}
|
||||
|
||||
export type MicrophoneFixture = {
|
||||
context: AudioContext
|
||||
dispose: () => Promise<void>
|
||||
resume: () => Promise<void>
|
||||
stream: MediaStream
|
||||
track: MediaStreamTrack
|
||||
}
|
||||
|
||||
const VIDEO_BASE64 =
|
||||
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMwbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAZAAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAlt0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAZAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAABAAAAAQAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAGQAAAAAAABAAAAAAHTbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAEABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABfm1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAT5zdGJsAAAAvnN0c2QAAAAAAAAAAQAAAK5hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAABAAEABIAAAASAAAAAAAAAABFExhdmM2My4xLjEwMiBsaWJ4MjY0AAAAAAAAAAAAAAAAGP//AAAANGF2Y0MBZAAK/+EAF2dkAAqs2V7ARAAAAwAEAAADACg8SJZYAQAGaOvjyyLA/fj4AAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAADi4AAAAAAAAABhzdHRzAAAAAAAAAAEAAAACAAAIAAAAABRzdHNzAAAAAAAAAAEAAAABAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAACAAAAAQAAABxzdHN6AAAAAAAAAAAAAAACAAACygAAAAwAAAAUc3RjbwAAAAAAAAABAAADYAAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZmNjMuMS4xMDIAAAAIZnJlZQAAAt5tZGF0AAACrQYF//+p3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE2NSByMzIyMiBiMzU2MDVhIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAyNSAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTUgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD00MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTIzLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAAAVZYiEABD//ubA+ZZaIeOAUIdrhk+BAAAACEGaIWxD//7g'
|
||||
|
||||
export const createWavFixture = ({
|
||||
duration = 2,
|
||||
frequency = 440,
|
||||
}: {
|
||||
duration?: number
|
||||
frequency?: number
|
||||
} = {}): WavFixture => {
|
||||
const sampleRate = 8_000
|
||||
const sampleCount = Math.round(sampleRate * duration)
|
||||
const bytes = new Uint8Array(44 + sampleCount * 2)
|
||||
const view = new DataView(bytes.buffer)
|
||||
const write = (offset: number, value: string) => {
|
||||
for (let index = 0; index < value.length; index++)
|
||||
view.setUint8(offset + index, value.charCodeAt(index))
|
||||
}
|
||||
|
||||
write(0, 'RIFF')
|
||||
view.setUint32(4, 36 + sampleCount * 2, true)
|
||||
write(8, 'WAVE')
|
||||
write(12, 'fmt ')
|
||||
view.setUint32(16, 16, true)
|
||||
view.setUint16(20, 1, true)
|
||||
view.setUint16(22, 1, true)
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, sampleRate * 2, true)
|
||||
view.setUint16(32, 2, true)
|
||||
view.setUint16(34, 16, true)
|
||||
write(36, 'data')
|
||||
view.setUint32(40, sampleCount * 2, true)
|
||||
for (let index = 0; index < sampleCount; index++) {
|
||||
const fade = Math.min(1, index / 80, (sampleCount - index) / 80)
|
||||
const sample = Math.sin((2 * Math.PI * frequency * index) / sampleRate) * 0.35 * fade
|
||||
view.setInt16(44 + index * 2, sample * 0x7fff, true)
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }))
|
||||
return { bytes, url, revoke: () => URL.revokeObjectURL(url) }
|
||||
}
|
||||
|
||||
export const createVideoFixture = (): WavFixture => {
|
||||
const binary = atob(VIDEO_BASE64)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' }))
|
||||
return { bytes, url, revoke: () => URL.revokeObjectURL(url) }
|
||||
}
|
||||
|
||||
export const createMicrophoneFixture = (): MicrophoneFixture => {
|
||||
const context = new AudioContext()
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
const destination = context.createMediaStreamDestination()
|
||||
const track = destination.stream.getAudioTracks()[0]!
|
||||
let disposed = false
|
||||
|
||||
oscillator.frequency.value = 440
|
||||
gain.gain.value = 0.15
|
||||
oscillator.connect(gain)
|
||||
gain.connect(destination)
|
||||
oscillator.start()
|
||||
|
||||
return {
|
||||
context,
|
||||
stream: destination.stream,
|
||||
track,
|
||||
resume: async () => {
|
||||
if (context.state === 'suspended') await context.resume()
|
||||
},
|
||||
dispose: async () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
track.stop()
|
||||
try {
|
||||
oscillator.stop()
|
||||
} catch {
|
||||
// The oscillator may already have stopped while its context was released.
|
||||
}
|
||||
oscillator.disconnect()
|
||||
gain.disconnect()
|
||||
destination.disconnect()
|
||||
if (context.state !== 'closed') await context.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const paintedPixelCount = (canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return 0
|
||||
const { data } = context.getImageData(0, 0, canvas.width, canvas.height)
|
||||
let count = 0
|
||||
for (let index = 3; index < data.length; index += 4) {
|
||||
if (data[index]) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
Reference in new issue
Block a user