test(web): move media controls to Browser Mode (#45653)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-11 11:18:18 +00:00
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')
})
})
})
+111
View File
@@ -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
}