diff --git a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.browser.spec.tsx b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.browser.spec.tsx new file mode 100644 index 00000000000..0ff392909b4 --- /dev/null +++ b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.browser.spec.tsx @@ -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> = [] +const fixture = (options?: Parameters[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 ( + <> + + + + ) +} + +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( +
+ +
, + ) + 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() + 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() + 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() + 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( + , + ) + 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((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( + , + ) + 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((resolve) => requestAnimationFrame(() => resolve())) + await expect.element(screen.getByRole('button', { name: 'common.operation.play' })).toBeEnabled() + expect(audio.src).toBe(newAudio.url) +}) diff --git a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx b/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx deleted file mode 100644 index 6770de5a026..00000000000 --- a/web/app/components/base/audio-gallery/__tests__/AudioPlayer.spec.tsx +++ /dev/null @@ -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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - await generateWaveform() - fireEvent.error(getAudio()) - expect(playButton()).toBeDisabled() - rerender() - 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() - 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() - 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() - 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() - const { canvas } = loadMetadata() - await act(async () => { - fireEvent.click(canvas, { clientX: 100 }) - }) - expect(playButton()).toBeEnabled() - expect(notify).toHaveBeenCalledWith('Error playing audio: Error: Seek denied') - }) -}) diff --git a/web/app/components/base/video-gallery/__tests__/VideoPlayer.browser.spec.tsx b/web/app/components/base/video-gallery/__tests__/VideoPlayer.browser.spec.tsx new file mode 100644 index 00000000000..a3e6474e6dd --- /dev/null +++ b/web/app/components/base/video-gallery/__tests__/VideoPlayer.browser.spec.tsx @@ -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> = [] +const fixture = () => { + const result = createVideoFixture() + fixtures.push(result) + return result +} + +const renderPlayer = async (src = fixture().url) => { + const screen = await render( +
+ +
, + ) + 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() +}) diff --git a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx deleted file mode 100644 index 4f61f4b846f..00000000000 --- a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx +++ /dev/null @@ -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() - const video = screen.getByTestId('video-element') as HTMLVideoElement - expect(video.src).toContain(mockSrc) - }) - - it('should render with multiple srcs', () => { - render() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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) - }) - }) -}) diff --git a/web/app/components/base/voice-input/__tests__/index.browser.spec.tsx b/web/app/components/base/voice-input/__tests__/index.browser.spec.tsx new file mode 100644 index 00000000000..19eb6a0e4fb --- /dev/null +++ b/web/app/components/base/voice-input/__tests__/index.browser.spec.tsx @@ -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(), +})) + +// boundary: retain the real transcription adapter while replacing only its generated HTTP transport. +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + 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>> = [] +let beforeTranscribe: () => Promise +let beforeTranscribeCalls = 0 + +function VoiceInputCaller() { + const triggerRef = useRef(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 ( +
+ {!open && ( + + )} + {open && ( + beforeTranscribe()} + onConverted={setDraft} + onCancel={close} + onError={() => setError('conversion failed')} + onStartError={(reason) => + setError( + reason instanceof Error || reason instanceof DOMException + ? reason.message + : 'microphone failed', + ) + } + /> + )} + {draft} + {error &&
{error}
} +
+ ) +} + +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() + 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>) => { + await expect.element(screen.getByText('common.voiceInput.speaking')).toBeVisible() + await expect.poll(() => paintedPixelCount(recordingCanvas())).toBeGreaterThan(0) + await new Promise((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() + // 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() + // 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((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((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') +}) diff --git a/web/app/components/base/voice-input/__tests__/index.spec.tsx b/web/app/components/base/voice-input/__tests__/index.spec.tsx deleted file mode 100644 index a92c8f6a0bc..00000000000 --- a/web/app/components/base/voice-input/__tests__/index.spec.tsx +++ /dev/null @@ -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>() -const recorderCancel = vi.fn<() => Promise>() -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> = {}) => { - const props: React.ComponentProps = { - onCancel: vi.fn(), - onBeforeTranscribe: vi.fn().mockResolvedValue(undefined), - onConverted: vi.fn(), - onError: vi.fn(), - onStartError: vi.fn(), - target, - ...overrides, - } - return { ...render(), 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( - - - , - ) - 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() - - 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((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') - }) - }) -}) diff --git a/web/test/browser-media.ts b/web/test/browser-media.ts new file mode 100644 index 00000000000..82e7b2f5ca4 --- /dev/null +++ b/web/test/browser-media.ts @@ -0,0 +1,111 @@ +type WavFixture = { + bytes: Uint8Array + revoke: () => void + url: string +} + +export type MicrophoneFixture = { + context: AudioContext + dispose: () => Promise + resume: () => Promise + 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 +}