diff --git a/web/app/components/base/audio-btn/index.spec.tsx b/web/app/components/base/audio-btn/index.spec.tsx new file mode 100644 index 0000000000..5b30f5f737 --- /dev/null +++ b/web/app/components/base/audio-btn/index.spec.tsx @@ -0,0 +1,202 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import i18next from 'i18next' +import { useParams, usePathname } from 'next/navigation' +import AudioBtn from './index' + +const mockPlayAudio = vi.fn() +const mockPauseAudio = vi.fn() +const mockGetAudioPlayer = vi.fn() + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + usePathname: vi.fn(), +})) + +vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: vi.fn(() => ({ + getAudioPlayer: mockGetAudioPlayer, + })), + }, +})) + +describe('AudioBtn', () => { + const getButton = () => screen.getByRole('button') + const mockUseParams = (value: Partial>) => { + vi.mocked(useParams).mockReturnValue(value as ReturnType) + } + const mockUsePathname = (value: string) => { + vi.mocked(usePathname).mockReturnValue(value) + } + + const hoverAndCheckTooltip = async (expectedText: string) => { + await userEvent.hover(getButton()) + expect(await screen.findByText(expectedText)).toBeInTheDocument() + } + + const getLatestAudioCallback = () => { + const lastCall = mockGetAudioPlayer.mock.calls[mockGetAudioPlayer.mock.calls.length - 1] + const callback = lastCall?.[5] + + if (typeof callback !== 'function') + throw new Error('Audio callback not found in latest getAudioPlayer call') + + return callback as (event: string) => void + } + + beforeAll(async () => { + await i18next.init({}) + }) + + beforeEach(() => { + vi.clearAllMocks() + mockGetAudioPlayer.mockReturnValue({ + playAudio: mockPlayAudio, + pauseAudio: mockPauseAudio, + }) + mockUseParams({}) + mockUsePathname('/') + }) + + // Core rendering and base UI integration. + describe('Rendering', () => { + it('should render button with play tooltip by default', async () => { + render() + + expect(getButton()).toBeInTheDocument() + expect(getButton()).not.toBeDisabled() + await hoverAndCheckTooltip('play') + }) + + it('should apply className in initial state', () => { + const { container } = render() + const wrapper = container.firstElementChild + + expect(wrapper).toHaveClass('custom-wrapper') + }) + }) + + // URL path resolution for app/public audio endpoints. + describe('URL routing', () => { + it('should call public text-to-audio endpoint when token exists', async () => { + mockUseParams({ token: 'public-token' }) + + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[0]).toBe('/text-to-audio') + expect(call[1]).toBe(true) + }) + + it('should call app endpoint when appId exists', async () => { + mockUseParams({ appId: '123' }) + mockUsePathname('/apps/123/chat') + + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[0]).toBe('/apps/123/text-to-audio') + expect(call[1]).toBe(false) + }) + + it('should call installed app endpoint for explore installed routes', async () => { + mockUseParams({ appId: '456' }) + mockUsePathname('/explore/installed/app/456') + + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[0]).toBe('/installed-apps/456/text-to-audio') + expect(call[1]).toBe(false) + }) + }) + + // User-visible playback state transitions. + describe('Playback interactions', () => { + it('should start loading and call playAudio when button is clicked', async () => { + render() + await userEvent.click(getButton()) + + await waitFor(() => { + expect(mockPlayAudio).toHaveBeenCalledTimes(1) + expect(getButton()).toBeDisabled() + }) + expect(screen.getByRole('status')).toBeInTheDocument() + await hoverAndCheckTooltip('loading') + }) + + it('should pause audio when clicked while playing', async () => { + render() + await userEvent.click(getButton()) + + await act(() => { + getLatestAudioCallback()('play') + }) + + await hoverAndCheckTooltip('playing') + expect(getButton()).not.toBeDisabled() + + await userEvent.click(getButton()) + await waitFor(() => expect(mockPauseAudio).toHaveBeenCalledTimes(1)) + }) + }) + + // Audio event callback handling from the player manager. + describe('Audio callback events', () => { + it('should set loading tooltip when loaded event is received', async () => { + render() + await userEvent.click(getButton()) + + await act(() => { + getLatestAudioCallback()('loaded') + }) + + await hoverAndCheckTooltip('loading') + expect(getButton()).toBeDisabled() + }) + + it.each(['ended', 'paused', 'error'])('should return to play tooltip when %s event is received', async (event) => { + render() + await userEvent.click(getButton()) + + await act(() => { + getLatestAudioCallback()(event) + }) + + await hoverAndCheckTooltip('play') + expect(getButton()).not.toBeDisabled() + }) + }) + + // Prop forwarding and minimal-input behavior. + describe('Props and edge cases', () => { + it('should pass id, value, and voice to getAudioPlayer', async () => { + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[2]).toBe('msg-1') + expect(call[3]).toBe('hello') + expect(call[4]).toBe('en-US') + }) + + it('should keep empty route when neither token nor appId is present', async () => { + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[0]).toBe('') + expect(call[1]).toBe(false) + expect(call[3]).toBeUndefined() + }) + }) +}) diff --git a/web/app/components/base/divider/index.tsx b/web/app/components/base/divider/index.tsx index cde3189ed0..799005e424 100644 --- a/web/app/components/base/divider/index.tsx +++ b/web/app/components/base/divider/index.tsx @@ -7,8 +7,8 @@ import { cn } from '@/utils/classnames' const dividerVariants = cva('', { variants: { type: { - horizontal: 'w-full h-[0.5px] my-2 ', - vertical: 'w-[1px] h-full mx-2', + horizontal: 'my-2 h-[0.5px] w-full', + vertical: 'mx-2 h-full w-[1px]', }, bgStyle: { gradient: 'bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent', @@ -28,7 +28,7 @@ export type DividerProps = { const Divider: FC = ({ type, bgStyle, className = '', style }) => { return ( -
+
) } diff --git a/web/app/components/base/ga/index.spec.tsx b/web/app/components/base/ga/index.spec.tsx new file mode 100644 index 0000000000..954e0eba83 --- /dev/null +++ b/web/app/components/base/ga/index.spec.tsx @@ -0,0 +1,187 @@ +import type { ReactElement, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' + +type ConfigState = { + isCeEdition: boolean + isProd: boolean +} + +type GaProps = { + gaType: string +} + +type GaRenderFn = (props: GaProps) => Promise +type GaTypeValue = 'admin' | 'webapp' + +const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({ + mockHeaders: vi.fn(), + mockHeadersGet: vi.fn(), + configState: ({ + isCeEdition: false, + isProd: true, + }) as ConfigState, +})) + +vi.mock('@/config', () => ({ + get IS_CE_EDITION() { + return configState.isCeEdition + }, + get IS_PROD() { + return configState.isProd + }, +})) + +vi.mock('next/headers', () => ({ + headers: mockHeaders, +})) + +vi.mock('next/script', () => ({ + default: ({ + id, + strategy, + src, + nonce, + dangerouslySetInnerHTML, + }: { + id?: string + strategy?: string + src?: string + nonce?: string + dangerouslySetInnerHTML?: { __html?: string } + }) => ( +