mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 09:55:09 +00:00
test: add unit tests for base-components part-3 (#32408)
This commit is contained in:
114
web/app/components/base/tab-header/index.spec.tsx
Normal file
114
web/app/components/base/tab-header/index.spec.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TabHeader from './index'
|
||||
|
||||
describe('TabHeader Component', () => {
|
||||
const mockItems = [
|
||||
{ id: 'tab1', name: 'General' },
|
||||
{ id: 'tab2', name: 'Settings' },
|
||||
{ id: 'tab3', name: 'Profile', isRight: true },
|
||||
{ id: 'tab4', name: 'Disabled Tab', disabled: true },
|
||||
]
|
||||
|
||||
it('should render all items with correct names', () => {
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Profile')).toBeInTheDocument()
|
||||
expect(screen.getByText('Disabled Tab')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should separate items into left and right containers correctly', () => {
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
|
||||
|
||||
const leftContainer = screen.getByTestId('tab-header-left')
|
||||
const rightContainer = screen.getByTestId('tab-header-right')
|
||||
|
||||
// Verify children count
|
||||
expect(leftContainer.children.length).toBe(3)
|
||||
expect(rightContainer.children.length).toBe(1)
|
||||
|
||||
// Verify specific item placement using within and toContainElement
|
||||
const profileTab = screen.getByTestId('tab-header-item-tab3')
|
||||
expect(rightContainer).toContainElement(profileTab)
|
||||
|
||||
const disabledTab = screen.getByTestId('tab-header-item-tab4')
|
||||
expect(leftContainer).toContainElement(disabledTab)
|
||||
})
|
||||
|
||||
it('should apply active styles to the selected tab', () => {
|
||||
const activeClass = 'custom-active-style'
|
||||
render(
|
||||
<TabHeader
|
||||
items={mockItems}
|
||||
value="tab2"
|
||||
activeItemClassName={activeClass}
|
||||
onChange={() => { }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const activeTab = screen.getByTestId('tab-header-item-tab2')
|
||||
expect(activeTab).toHaveClass('border-components-tab-active')
|
||||
expect(activeTab).toHaveClass(activeClass)
|
||||
|
||||
const inactiveTab = screen.getByTestId('tab-header-item-tab1')
|
||||
expect(inactiveTab).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should call onChange when a non-disabled tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
|
||||
|
||||
await user.click(screen.getByText('Settings'))
|
||||
expect(handleChange).toHaveBeenCalledWith('tab2')
|
||||
})
|
||||
|
||||
it('should not call onChange when a disabled tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
|
||||
|
||||
const disabledTab = screen.getByTestId('tab-header-item-tab4')
|
||||
expect(disabledTab).toHaveClass('cursor-not-allowed')
|
||||
|
||||
await user.click(disabledTab)
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render icon and extra content when provided', () => {
|
||||
const itemsWithExtras = [
|
||||
{
|
||||
id: 'extra',
|
||||
name: 'Extra',
|
||||
icon: <span data-testid="tab-icon">🚀</span>,
|
||||
extra: <span data-testid="tab-extra">New</span>,
|
||||
},
|
||||
]
|
||||
render(<TabHeader items={itemsWithExtras} value="extra" onChange={() => { }} />)
|
||||
|
||||
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-extra')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom class names for items and wrappers', () => {
|
||||
render(
|
||||
<TabHeader
|
||||
items={mockItems}
|
||||
value="tab1"
|
||||
itemClassName="my-text-class"
|
||||
itemWrapClassName="my-wrap-class"
|
||||
onChange={() => { }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tabWrap = screen.getByTestId('tab-header-item-tab1')
|
||||
// We target the inner div for the name class check
|
||||
const tabText = within(tabWrap).getByText('General')
|
||||
|
||||
expect(tabWrap).toHaveClass('my-wrap-class')
|
||||
expect(tabText).toHaveClass('my-text-class')
|
||||
})
|
||||
})
|
||||
@@ -32,8 +32,9 @@ const TabHeader: FC<ITabHeaderProps> = ({
|
||||
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
|
||||
<div
|
||||
key={id}
|
||||
data-testid={`tab-header-item-${id}`}
|
||||
className={cn(
|
||||
'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5',
|
||||
'relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5 system-md-semibold',
|
||||
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
|
||||
disabled && 'cursor-not-allowed opacity-30',
|
||||
itemWrapClassName,
|
||||
@@ -46,11 +47,11 @@ const TabHeader: FC<ITabHeaderProps> = ({
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="flex space-x-4">
|
||||
<div data-testid="tab-header" className="flex justify-between">
|
||||
<div data-testid="tab-header-left" className="flex space-x-4">
|
||||
{items.filter(item => !item.isRight).map(renderItem)}
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<div data-testid="tab-header-right" className="flex space-x-4">
|
||||
{items.filter(item => item.isRight).map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
99
web/app/components/base/tab-slider-new/index.spec.tsx
Normal file
99
web/app/components/base/tab-slider-new/index.spec.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TabSliderNew from './index'
|
||||
|
||||
describe('TabSliderNew Component', () => {
|
||||
const mockOptions = [
|
||||
{ value: 'all', text: 'All' },
|
||||
{ value: 'active', text: 'Active' },
|
||||
{ value: 'inactive', text: 'Inactive', icon: <span data-testid="tab-icon">ico</span> },
|
||||
]
|
||||
|
||||
it('should render all options with text and icons', () => {
|
||||
render(
|
||||
<TabSliderNew
|
||||
value="all"
|
||||
options={mockOptions}
|
||||
onChange={() => { }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('All')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
expect(screen.getByText('Inactive')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply active classes when the value matches the option', () => {
|
||||
render(
|
||||
<TabSliderNew
|
||||
value="active"
|
||||
options={mockOptions}
|
||||
onChange={() => { }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const activeTab = screen.getByTestId('tab-item-active')
|
||||
const inactiveTab = screen.getByTestId('tab-item-all')
|
||||
|
||||
// Check active styles
|
||||
expect(activeTab).toHaveClass('border-components-main-nav-nav-button-border')
|
||||
expect(activeTab).toHaveClass('text-components-main-nav-nav-button-text-active')
|
||||
|
||||
// Check inactive styles
|
||||
expect(inactiveTab).toHaveClass('text-text-tertiary')
|
||||
expect(inactiveTab).not.toHaveClass('border-components-main-nav-nav-button-border')
|
||||
})
|
||||
|
||||
it('should call onChange with the correct value when a tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<TabSliderNew
|
||||
value="all"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inactiveTab = screen.getByTestId('tab-item-inactive')
|
||||
await user.click(inactiveTab)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('inactive')
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply custom container className', () => {
|
||||
const customClass = 'custom-container-style'
|
||||
render(
|
||||
<TabSliderNew
|
||||
value="all"
|
||||
options={mockOptions}
|
||||
onChange={() => { }}
|
||||
className={customClass}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tab-slider-new')).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should call onChange even if clicking an already active tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<TabSliderNew
|
||||
value="all"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const activeTab = screen.getByTestId('tab-item-all')
|
||||
await user.click(activeTab)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('all')
|
||||
})
|
||||
})
|
||||
@@ -19,10 +19,14 @@ const TabSliderNew: FC<TabSliderProps> = ({
|
||||
options,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'relative flex')}>
|
||||
<div
|
||||
data-testid="tab-slider-new"
|
||||
className={cn(className, 'relative flex')}
|
||||
>
|
||||
{options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
data-testid={`tab-item-${option.value}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover',
|
||||
|
||||
100
web/app/components/base/tab-slider-plain/index.spec.tsx
Normal file
100
web/app/components/base/tab-slider-plain/index.spec.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TabSlider from './index'
|
||||
|
||||
describe('TabSlider Component', () => {
|
||||
const mockOptions = [
|
||||
{ value: 'tab1', text: 'Overview' },
|
||||
{ value: 'tab2', text: 'Settings' },
|
||||
{ value: 'tab3', text: <span data-testid="custom-jsx">Advanced</span> },
|
||||
]
|
||||
|
||||
it('should render all options correctly', () => {
|
||||
render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />)
|
||||
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-jsx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when an inactive tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
render(<TabSlider value="tab1" options={mockOptions} onChange={handleChange} />)
|
||||
|
||||
const settingsTab = screen.getByTestId('tab-slider-item-tab2')
|
||||
await user.click(settingsTab)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('tab2')
|
||||
})
|
||||
|
||||
it('should not call onChange when the active tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
render(<TabSlider value="tab1" options={mockOptions} onChange={handleChange} />)
|
||||
|
||||
const activeTab = screen.getByTestId('tab-slider-item-tab1')
|
||||
await user.click(activeTab)
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply active styles and render indicator for the active tab', () => {
|
||||
render(<TabSlider value="tab2" options={mockOptions} onChange={() => { }} />)
|
||||
|
||||
const activeTab = screen.getByTestId('tab-slider-item-tab2')
|
||||
const activeText = within(activeTab).getByTestId('tab-slider-item-text')
|
||||
const indicator = within(activeTab).getByTestId('tab-active-indicator')
|
||||
|
||||
expect(activeText).toHaveClass('text-text-primary')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
|
||||
const inactiveTab = screen.getByTestId('tab-slider-item-tab1')
|
||||
const inactiveText = within(inactiveTab).getByTestId('tab-slider-item-text')
|
||||
expect(inactiveText).toHaveClass('text-text-tertiary')
|
||||
expect(within(inactiveTab).queryByTestId('tab-active-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply smallItem styles when smallItem prop is true', () => {
|
||||
render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} smallItem />)
|
||||
|
||||
const item = screen.getByTestId('tab-slider-item-tab1')
|
||||
expect(item).toHaveClass('system-sm-semibold-uppercase')
|
||||
expect(item).not.toHaveClass('system-xl-semibold')
|
||||
})
|
||||
|
||||
it('should apply standard sizing when smallItem prop is false', () => {
|
||||
render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />)
|
||||
|
||||
const item = screen.getByTestId('tab-slider-item-tab1')
|
||||
expect(item).toHaveClass('system-xl-semibold')
|
||||
})
|
||||
|
||||
it('should handle border styles based on noBorderBottom prop', () => {
|
||||
const { rerender } = render(
|
||||
<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />,
|
||||
)
|
||||
expect(screen.getByTestId('tab-slider')).toHaveClass('border-b')
|
||||
|
||||
rerender(
|
||||
<TabSlider value="tab1" options={mockOptions} onChange={() => { }} noBorderBottom />,
|
||||
)
|
||||
expect(screen.getByTestId('tab-slider')).not.toHaveClass('border-b')
|
||||
})
|
||||
|
||||
it('should apply custom itemClassName to all items', () => {
|
||||
const customClass = 'my-custom-item'
|
||||
render(
|
||||
<TabSlider
|
||||
value="tab1"
|
||||
options={mockOptions}
|
||||
onChange={() => { }}
|
||||
itemClassName={customClass}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tab-slider-item-tab1')).toHaveClass(customClass)
|
||||
expect(screen.getByTestId('tab-slider-item-tab2')).toHaveClass(customClass)
|
||||
})
|
||||
})
|
||||
@@ -25,17 +25,27 @@ const Item: FC<ItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
data-testid={`tab-slider-item-${option.value}`}
|
||||
className={cn(
|
||||
'relative pb-2.5 ',
|
||||
'relative pb-2.5',
|
||||
!isActive && 'cursor-pointer',
|
||||
smallItem ? 'system-sm-semibold-uppercase' : 'system-xl-semibold',
|
||||
className,
|
||||
)}
|
||||
onClick={() => !isActive && onClick(option.value)}
|
||||
>
|
||||
<div className={cn(isActive ? 'text-text-primary' : 'text-text-tertiary')}>{option.text}</div>
|
||||
<div
|
||||
data-testid="tab-slider-item-text"
|
||||
className={cn(isActive ? 'text-text-primary' : 'text-text-tertiary')}
|
||||
>
|
||||
{option.text}
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600"></div>
|
||||
<div
|
||||
data-testid="tab-active-indicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600"
|
||||
>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -61,7 +71,10 @@ const TabSlider: FC<Props> = ({
|
||||
smallItem,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, !noBorderBottom && 'border-b border-divider-subtle', 'flex space-x-6')}>
|
||||
<div
|
||||
data-testid="tab-slider"
|
||||
className={cn(className, !noBorderBottom && 'border-b border-divider-subtle', 'flex space-x-6')}
|
||||
>
|
||||
{options.map(option => (
|
||||
<Item
|
||||
isActive={option.value === value}
|
||||
|
||||
107
web/app/components/base/tab-slider/index.spec.tsx
Normal file
107
web/app/components/base/tab-slider/index.spec.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import TabSlider from './index'
|
||||
|
||||
// Mock the service hook
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockOptions = [
|
||||
{ value: 'all', text: 'All' },
|
||||
{ value: 'plugins', text: 'Plugins' },
|
||||
{ value: 'settings', text: 'Settings' },
|
||||
]
|
||||
|
||||
describe('TabSlider Component', () => {
|
||||
const onChangeMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useInstalledPluginList).mockReturnValue({
|
||||
data: { total: 0 },
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useInstalledPluginList>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Helper to inject layout values into JSDOM
|
||||
const setElementLayout = (id: string, left: number, width: number) => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
Object.defineProperty(el, 'offsetLeft', { configurable: true, value: left })
|
||||
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width })
|
||||
}
|
||||
}
|
||||
|
||||
it('renders all options correctly', () => {
|
||||
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
|
||||
mockOptions.forEach((option) => {
|
||||
expect(screen.getByText(option.text as string)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onChange when a new tab is clicked', () => {
|
||||
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
|
||||
const pluginTab = screen.getByTestId('tab-item-plugins')
|
||||
fireEvent.click(pluginTab)
|
||||
expect(onChangeMock).toHaveBeenCalledWith('plugins')
|
||||
})
|
||||
|
||||
it('applies the correct active classes to the selected tab', () => {
|
||||
render(<TabSlider value="plugins" options={mockOptions} onChange={onChangeMock} />)
|
||||
const activeTab = screen.getByTestId('tab-item-plugins')
|
||||
expect(activeTab).toHaveClass('text-text-primary')
|
||||
|
||||
const inactiveTab = screen.getByTestId('tab-item-all')
|
||||
expect(inactiveTab).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('renders the Badge when plugins exist and value is "plugins"', () => {
|
||||
vi.mocked(useInstalledPluginList).mockReturnValue({
|
||||
data: { total: 5 },
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useInstalledPluginList>)
|
||||
|
||||
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports functional itemClassName based on active state', () => {
|
||||
render(
|
||||
<TabSlider
|
||||
value="all"
|
||||
options={mockOptions}
|
||||
onChange={onChangeMock}
|
||||
itemClassName={active => (active ? 'is-active-custom' : 'is-inactive-custom')}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('tab-item-all')).toHaveClass('is-active-custom')
|
||||
expect(screen.getByTestId('tab-item-settings')).toHaveClass('is-inactive-custom')
|
||||
})
|
||||
|
||||
it('updates slider styles based on element dimensions', () => {
|
||||
// 1. Initial Render
|
||||
const { rerender } = render(
|
||||
<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />,
|
||||
)
|
||||
|
||||
// 2. Mock layout properties for the elements now that they are in the DOM
|
||||
setElementLayout('tab-0', 0, 100)
|
||||
setElementLayout('tab-1', 120, 80)
|
||||
|
||||
// 3. Rerender with the same or new value to trigger the useEffect
|
||||
// This forces updateSliderStyle to run while the mocked values exist
|
||||
rerender(<TabSlider value="plugins" options={mockOptions} onChange={onChangeMock} />)
|
||||
|
||||
const slider = screen.getByTestId('tab-slider-bg')
|
||||
|
||||
// Assert the transform matches the "tab-1" (plugins) layout we mocked
|
||||
expect(slider.style.transform).toBe('translateX(120px)')
|
||||
expect(slider.style.width).toBe('80px')
|
||||
})
|
||||
})
|
||||
@@ -46,8 +46,12 @@ const TabSlider: FC<TabSliderProps> = ({
|
||||
}, [value, options, pluginList?.total])
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5')}>
|
||||
<div
|
||||
data-testid="tab-slider-container"
|
||||
className={cn(className, 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5')}
|
||||
>
|
||||
<div
|
||||
data-testid="tab-slider-bg"
|
||||
className="shadows-shadow-xs absolute bottom-0.5 left-0 right-0 top-0.5 rounded-[10px] bg-components-panel-bg transition-transform duration-300 ease-in-out"
|
||||
style={sliderStyle}
|
||||
/>
|
||||
@@ -55,6 +59,7 @@ const TabSlider: FC<TabSliderProps> = ({
|
||||
<div
|
||||
id={`tab-${index}`}
|
||||
key={option.value}
|
||||
data-testid={`tab-item-${option.value}`}
|
||||
className={cn(
|
||||
'relative z-10 flex cursor-pointer items-center justify-center gap-1 rounded-[10px] px-2.5 py-1.5 transition-colors duration-300 ease-in-out',
|
||||
'system-md-semibold',
|
||||
|
||||
77
web/app/components/base/textarea/index.spec.tsx
Normal file
77
web/app/components/base/textarea/index.spec.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TextArea from './index'
|
||||
|
||||
describe('TextArea', () => {
|
||||
it('should render correctly with default props', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle value and onChange correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toHaveValue('initial')
|
||||
|
||||
await user.type(textarea, ' updated')
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
|
||||
rerender(<TextArea value="initial updated" onChange={handleChange} />)
|
||||
expect(textarea).toHaveValue('initial updated')
|
||||
})
|
||||
|
||||
it('should handle autoFocus correctly', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should handle disabled state', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} disabled />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toBeDisabled()
|
||||
expect(textarea).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should handle placeholder', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
|
||||
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should handle size variants', () => {
|
||||
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
|
||||
|
||||
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
|
||||
})
|
||||
|
||||
it('should handle destructive state', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} destructive />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should handle onFocus and onBlur', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleFocus = vi.fn()
|
||||
const handleBlur = vi.fn()
|
||||
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
|
||||
await user.click(textarea)
|
||||
expect(handleFocus).toHaveBeenCalled()
|
||||
|
||||
await user.tab()
|
||||
expect(handleBlur).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,9 @@ const textareaVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'py-1 rounded-md system-xs-regular',
|
||||
regular: 'px-3 rounded-md system-sm-regular',
|
||||
large: 'px-4 rounded-lg system-md-regular',
|
||||
small: 'rounded-md py-1 system-xs-regular',
|
||||
regular: 'rounded-md px-3 system-sm-regular',
|
||||
large: 'rounded-lg px-4 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -48,6 +48,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
data-testid="text-area"
|
||||
{...props}
|
||||
>
|
||||
</textarea>
|
||||
|
||||
31
web/app/components/base/timezone-label/index.spec.tsx
Normal file
31
web/app/components/base/timezone-label/index.spec.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import TimezoneLabel from './index'
|
||||
|
||||
describe('TimezoneLabel', () => {
|
||||
it('should render correctly with various timezones', () => {
|
||||
const { rerender } = render(<TimezoneLabel timezone="UTC" />)
|
||||
const label = screen.getByTestId('timezone-label')
|
||||
expect(label).toHaveTextContent('UTC+0')
|
||||
expect(label).toHaveAttribute('title', 'Timezone: UTC (UTC+0)')
|
||||
|
||||
rerender(<TimezoneLabel timezone="Asia/Shanghai" />)
|
||||
expect(label).toHaveTextContent('UTC+8')
|
||||
expect(label).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)')
|
||||
|
||||
rerender(<TimezoneLabel timezone="America/New_York" />)
|
||||
// New York is UTC-5 or UTC-4 depending on DST.
|
||||
// dayjs handles this, we just check it renders some offset.
|
||||
expect(label.textContent).toMatch(/UTC[-+]\d+/)
|
||||
})
|
||||
|
||||
it('should apply correct styling for inline prop', () => {
|
||||
render(<TimezoneLabel timezone="UTC" inline />)
|
||||
expect(screen.getByTestId('timezone-label')).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<TimezoneLabel timezone="UTC" className="custom-test-class" />)
|
||||
expect(screen.getByTestId('timezone-label')).toHaveClass('custom-test-class')
|
||||
})
|
||||
})
|
||||
@@ -43,11 +43,12 @@ const TimezoneLabel: React.FC<TimezoneLabelProps> = ({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'system-sm-regular text-text-tertiary',
|
||||
'text-text-tertiary system-sm-regular',
|
||||
inline && 'text-text-quaternary',
|
||||
className,
|
||||
)}
|
||||
title={`Timezone: ${timezone} (${offsetStr})`}
|
||||
data-testid="timezone-label"
|
||||
>
|
||||
{offsetStr}
|
||||
</span>
|
||||
|
||||
49
web/app/components/base/tooltip/content.spec.tsx
Normal file
49
web/app/components/base/tooltip/content.spec.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ToolTipContent } from './content'
|
||||
|
||||
describe('ToolTipContent', () => {
|
||||
it('should render children correctly', () => {
|
||||
render(
|
||||
<ToolTipContent>
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
expect(screen.getByTestId('tooltip-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text')
|
||||
expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title when provided', () => {
|
||||
render(
|
||||
<ToolTipContent title="Tooltip Title">
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title')
|
||||
})
|
||||
|
||||
it('should render action when provided', () => {
|
||||
render(
|
||||
<ToolTipContent action={<span>Action Text</span>}>
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text')
|
||||
})
|
||||
|
||||
it('should handle action click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleActionClick = vi.fn()
|
||||
render(
|
||||
<ToolTipContent action={<span onClick={handleActionClick}>Action Text</span>}>
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Action Text'))
|
||||
expect(handleActionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,12 +11,12 @@ export const ToolTipContent: FC<ToolTipContentProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-[180px]">
|
||||
<div className="w-[180px]" data-testid="tooltip-content">
|
||||
{!!title && (
|
||||
<div className="mb-1.5 font-semibold text-text-secondary">{title}</div>
|
||||
<div className="mb-1.5 font-semibold text-text-secondary" data-testid="tooltip-content-title">{title}</div>
|
||||
)}
|
||||
<div className="mb-1.5 text-text-tertiary">{children}</div>
|
||||
{!!action && <div className="cursor-pointer text-text-accent">{action}</div>}
|
||||
<div className="mb-1.5 text-text-tertiary" data-testid="tooltip-content-body">{children}</div>
|
||||
{!!action && <div className="cursor-pointer text-text-accent" data-testid="tooltip-content-action">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
262
web/app/components/base/video-gallery/VideoPlayer.spec.tsx
Normal file
262
web/app/components/base/video-gallery/VideoPlayer.spec.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
describe('VideoPlayer', () => {
|
||||
const mockSrc = 'video.mp4'
|
||||
const mockSrcs = ['video1.mp4', 'video2.mp4']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
|
||||
// Mock HTMLVideoElement methods
|
||||
window.HTMLVideoElement.prototype.play = vi.fn().mockResolvedValue(undefined)
|
||||
window.HTMLVideoElement.prototype.pause = vi.fn()
|
||||
window.HTMLVideoElement.prototype.load = vi.fn()
|
||||
window.HTMLVideoElement.prototype.requestFullscreen = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// Mock document methods
|
||||
document.exitFullscreen = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// Mock offsetWidth to avoid smallSize mode by default
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||
configurable: true,
|
||||
value: 500,
|
||||
})
|
||||
|
||||
// Define properties on HTMLVideoElement prototype
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'duration', {
|
||||
configurable: true,
|
||||
get() { return 100 },
|
||||
})
|
||||
|
||||
// Use a descriptor check to avoid re-defining if it exists
|
||||
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'currentTime')) {
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', {
|
||||
configurable: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
get() { return (this as any)._currentTime || 0 },
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
set(v) { (this as any)._currentTime = v },
|
||||
})
|
||||
}
|
||||
|
||||
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'volume')) {
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'volume', {
|
||||
configurable: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
get() { return (this as any)._volume || 1 },
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
set(v) { (this as any)._volume = v },
|
||||
})
|
||||
}
|
||||
|
||||
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'muted')) {
|
||||
Object.defineProperty(window.HTMLVideoElement.prototype, 'muted', {
|
||||
configurable: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
get() { return (this as any)._muted || false },
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
set(v) { (this as any)._muted = v },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 = screen.getByTestId('video-play-pause-button')
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled()
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle mute on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const muteBtn = screen.getByTestId('video-mute-button')
|
||||
|
||||
await user.click(muteBtn)
|
||||
expect(muteBtn).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle fullscreen on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const fullscreenBtn = screen.getByTestId('video-fullscreen-button')
|
||||
|
||||
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 = screen.getByTestId('video-play-pause-button')
|
||||
|
||||
await user.click(playPauseBtn)
|
||||
fireEvent(video, new Event('ended'))
|
||||
|
||||
expect(playPauseBtn).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show/hide controls on mouse move and timeout', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const container = screen.getByTestId('video-player-container')
|
||||
|
||||
fireEvent.mouseMove(container)
|
||||
fireEvent.mouseMove(container) // Trigger clearTimeout
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3001)
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle progress bar interactions', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<VideoPlayer src={mockSrc} />)
|
||||
const progressBar = screen.getByTestId('video-progress-bar')
|
||||
const video = screen.getByTestId('video-element') as HTMLVideoElement
|
||||
|
||||
vi.spyOn(progressBar, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
width: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 10,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => { },
|
||||
} as DOMRect)
|
||||
|
||||
// Hover
|
||||
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()
|
||||
|
||||
// Click
|
||||
await user.click(progressBar)
|
||||
// Note: user.click calculates clientX based on element position, but we mocked getBoundingClientRect
|
||||
// RTL fireEvent is more direct for coordinate-based tests
|
||||
fireEvent.click(progressBar, { clientX: 75 })
|
||||
expect(video.currentTime).toBe(75)
|
||||
|
||||
// Drag
|
||||
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
|
||||
|
||||
vi.spyOn(volumeSlider, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
width: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 10,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => { },
|
||||
} as DOMRect)
|
||||
|
||||
// Click
|
||||
fireEvent.click(volumeSlider, { clientX: 50 })
|
||||
expect(video.volume).toBe(0.5)
|
||||
|
||||
// MouseDown and Drag
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -215,8 +215,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.videoPlayer} onMouseMove={showControls} onMouseEnter={showControls}>
|
||||
<video ref={videoRef} src={src} className={styles.video}>
|
||||
<div ref={containerRef} className={styles.videoPlayer} onMouseMove={showControls} onMouseEnter={showControls} data-testid="video-player-container">
|
||||
<video ref={videoRef} src={src} className={styles.video} data-testid="video-element">
|
||||
{/* If srcs array is provided, render multiple source elements */}
|
||||
{srcs && srcs.map((srcUrl, index) => (
|
||||
<source key={index} src={srcUrl} />
|
||||
@@ -232,12 +232,14 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-testid="video-progress-bar"
|
||||
>
|
||||
<div className={styles.progress} style={{ width: `${(currentTime / duration) * 100}%` }} />
|
||||
{hoverTime !== null && (
|
||||
<div
|
||||
className={styles.hoverTimeIndicator}
|
||||
style={{ left: `${(hoverTime / duration) * 100}%` }}
|
||||
data-testid="video-hover-time"
|
||||
>
|
||||
{formatTime(hoverTime)}
|
||||
</div>
|
||||
@@ -246,11 +248,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
</div>
|
||||
<div className={styles.controlsContent}>
|
||||
<div className={styles.leftControls}>
|
||||
<button type="button" className={styles.playPauseButton} onClick={togglePlayPause}>
|
||||
<button type="button" className={styles.playPauseButton} onClick={togglePlayPause} data-testid="video-play-pause-button">
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
{!isSmallSize && (
|
||||
<span className={styles.time}>
|
||||
<span className={styles.time} data-testid="video-time-display">
|
||||
{formatTime(currentTime)}
|
||||
{' '}
|
||||
/
|
||||
@@ -260,7 +262,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.rightControls}>
|
||||
<button type="button" className={styles.muteButton} onClick={toggleMute}>
|
||||
<button type="button" className={styles.muteButton} onClick={toggleMute} data-testid="video-mute-button">
|
||||
{isMuted ? <UnmuteIcon /> : <MuteIcon />}
|
||||
</button>
|
||||
{!isSmallSize && (
|
||||
@@ -279,12 +281,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}}
|
||||
data-testid="video-volume-slider"
|
||||
>
|
||||
<div className={styles.volumeLevel} style={{ width: `${volume * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className={styles.fullscreenButton} onClick={toggleFullscreen}>
|
||||
<button type="button" className={styles.fullscreenButton} onClick={toggleFullscreen} data-testid="video-fullscreen-button">
|
||||
<FullscreenIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
23
web/app/components/base/video-gallery/index.spec.tsx
Normal file
23
web/app/components/base/video-gallery/index.spec.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import VideoGallery from './index'
|
||||
|
||||
describe('VideoGallery', () => {
|
||||
const mockSrcs = ['video1.mp4', 'video2.mp4']
|
||||
|
||||
it('should render nothing when srcs is empty', () => {
|
||||
const { container } = render(<VideoGallery srcs={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render nothing when all srcs are empty strings', () => {
|
||||
const { container } = render(<VideoGallery srcs={['', '']} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render VideoPlayer when valid srcs are provided', () => {
|
||||
render(<VideoGallery srcs={mockSrcs} />)
|
||||
expect(screen.getByTestId('video-gallery-container')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('video-element')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ const VideoGallery: React.FC<Props> = ({ srcs }) => {
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="my-3">
|
||||
<div className="my-3" data-testid="video-gallery-container">
|
||||
<VideoPlayer srcs={validSrcs} />
|
||||
</div>
|
||||
)
|
||||
|
||||
310
web/app/components/base/voice-input/index.spec.tsx
Normal file
310
web/app/components/base/voice-input/index.spec.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { audioToText } from '@/service/share'
|
||||
import VoiceInput from './index'
|
||||
|
||||
const { mockState, MockRecorder } = vi.hoisted(() => {
|
||||
const state = {
|
||||
params: {} as Record<string, string>,
|
||||
pathname: '/test',
|
||||
rafCallback: undefined as (() => void) | undefined,
|
||||
recorderInstances: [] as unknown[],
|
||||
startOverride: null as (() => Promise<void>) | null,
|
||||
analyseData: new Uint8Array(1024).fill(150) as Uint8Array,
|
||||
}
|
||||
|
||||
class MockRecorderClass {
|
||||
start = vi.fn((..._args: unknown[]) => {
|
||||
if (state.startOverride)
|
||||
return state.startOverride()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
stop = vi.fn()
|
||||
getRecordAnalyseData = vi.fn(() => state.analyseData)
|
||||
getWAV = vi.fn(() => new ArrayBuffer(0))
|
||||
getChannelData = vi.fn(() => ({
|
||||
left: { buffer: new ArrayBuffer(2048), byteLength: 2048 },
|
||||
right: { buffer: new ArrayBuffer(2048), byteLength: 2048 },
|
||||
}))
|
||||
|
||||
constructor() {
|
||||
state.recorderInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { mockState: state, MockRecorder: MockRecorderClass }
|
||||
})
|
||||
|
||||
vi.mock('js-audio-recorder', () => ({
|
||||
default: MockRecorder,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
|
||||
audioToText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: vi.fn(() => mockState.params),
|
||||
usePathname: vi.fn(() => mockState.pathname),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', () => ({
|
||||
convertToMp3: vi.fn(() => new Blob(['test'], { type: 'audio/mp3' })),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useRafInterval: vi.fn((fn: () => void) => {
|
||||
mockState.rafCallback = fn
|
||||
return vi.fn()
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('VoiceInput', () => {
|
||||
const onConverted = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockState.params = {}
|
||||
mockState.pathname = '/test'
|
||||
mockState.rafCallback = undefined
|
||||
mockState.recorderInstances = []
|
||||
mockState.startOverride = null
|
||||
|
||||
// Ensure canvas has non-zero dimensions for initCanvas()
|
||||
HTMLCanvasElement.prototype.getBoundingClientRect = vi.fn(() => ({
|
||||
width: 300,
|
||||
height: 32,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 300,
|
||||
bottom: 32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { })
|
||||
})
|
||||
|
||||
it('should start recording on mount and show speaking state', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(recorder.start).toHaveBeenCalled()
|
||||
expect(await screen.findByText('common.voiceInput.speaking')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-stop')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:00')
|
||||
})
|
||||
|
||||
it('should increment timer via useRafInterval callback', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByText('common.voiceInput.speaking')
|
||||
|
||||
act(() => {
|
||||
mockState.rafCallback?.()
|
||||
})
|
||||
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:01')
|
||||
|
||||
act(() => {
|
||||
mockState.rafCallback?.()
|
||||
})
|
||||
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:02')
|
||||
})
|
||||
|
||||
it('should call onCancel when recording start fails', async () => {
|
||||
mockState.startOverride = () => Promise.reject(new Error('Permission denied'))
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop recording and convert audio on stop click', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'hello world' })
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(await screen.findByTestId('voice-input-converting-text')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.voiceInput.converting')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-loader')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recorder.stop).toHaveBeenCalled()
|
||||
expect(onConverted).toHaveBeenCalledWith('hello world')
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onConverted with empty string on conversion failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockRejectedValueOnce(new Error('API error'))
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConverted).toHaveBeenCalledWith('')
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show cancel button during conversion and cancel on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockImplementation(() => new Promise(() => { }))
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
const cancelBtn = await screen.findByTestId('voice-input-cancel')
|
||||
await user.click(cancelBtn)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should automatically stop recording after 600 seconds', async () => {
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'auto stopped' })
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
for (let i = 0; i < 600; i++)
|
||||
act(() => { mockState.rafCallback?.() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConverted).toHaveBeenCalledWith('auto stopped')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show red timer text after 500 seconds', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
for (let i = 0; i < 501; i++)
|
||||
act(() => { mockState.rafCallback?.() })
|
||||
|
||||
const timer = screen.getByTestId('voice-input-timer')
|
||||
expect(timer.className).toContain('text-[#F04438]')
|
||||
})
|
||||
|
||||
it('should draw on canvas with low data values triggering v < 128 clamp', async () => {
|
||||
mockState.analyseData = new Uint8Array(1024).fill(50)
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 2)
|
||||
cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const firstRecorder = mockState.recorderInstances[0] as any
|
||||
expect(firstRecorder.getRecordAnalyseData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should draw on canvas with high data values triggering v > 178 clamp', async () => {
|
||||
mockState.analyseData = new Uint8Array(1024).fill(250)
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 2)
|
||||
cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const firstRecorder = mockState.recorderInstances[0] as any
|
||||
expect(firstRecorder.getRecordAnalyseData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass wordTimestamps in form data', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} wordTimestamps="enabled" />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalled()
|
||||
const formData = vi.mocked(audioToText).mock.calls[0][2] as FormData
|
||||
expect(formData.get('word_timestamps')).toBe('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL patterns', () => {
|
||||
it('should use webApp source with /audio-to-text for token-based URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { token: 'my-token' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith('/audio-to-text', 'webApp', expect.any(FormData))
|
||||
})
|
||||
})
|
||||
|
||||
it('should use installed-apps URL when pathname includes explore/installed', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { appId: 'app-123' }
|
||||
mockState.pathname = '/explore/installed'
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith(
|
||||
'/installed-apps/app-123/audio-to-text',
|
||||
'installedApp',
|
||||
expect.any(FormData),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use /apps URL for non-explore paths with appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { appId: 'app-456' }
|
||||
mockState.pathname = '/dashboard/apps'
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith(
|
||||
'/apps/app-456/audio-to-text',
|
||||
'installedApp',
|
||||
expect.any(FormData),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,8 @@
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useRafInterval } from 'ahooks'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import { AppSourceType, audioToText } from '@/service/share'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './index.module.css'
|
||||
@@ -117,7 +112,7 @@ const VoiceInput = ({
|
||||
onCancel()
|
||||
}
|
||||
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
|
||||
const handleStartRecord = async () => {
|
||||
const handleStartRecord = useCallback(async () => {
|
||||
try {
|
||||
await recorder.current.start()
|
||||
setStartRecord(true)
|
||||
@@ -129,9 +124,8 @@ const VoiceInput = ({
|
||||
catch {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const initCanvas = () => {
|
||||
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
|
||||
const initCanvas = useCallback(() => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
|
||||
|
||||
@@ -149,7 +143,7 @@ const VoiceInput = ({
|
||||
ctxRef.current = ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
if (originDuration >= 600 && startRecord)
|
||||
handleStopRecorder()
|
||||
|
||||
@@ -160,7 +154,7 @@ const VoiceInput = ({
|
||||
return () => {
|
||||
recorderRef?.stop()
|
||||
}
|
||||
}, [])
|
||||
}, [handleStartRecord, initCanvas])
|
||||
|
||||
const minutes = Number.parseInt(`${Number.parseInt(`${originDuration}`) / 60}`)
|
||||
const seconds = Number.parseInt(`${originDuration}`) % 60
|
||||
@@ -170,7 +164,7 @@ const VoiceInput = ({
|
||||
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pl-[14.5px] pr-[6.5px]">
|
||||
<canvas id="voice-input-record" className="absolute bottom-0 left-0 h-4 w-full" />
|
||||
{
|
||||
startConvert && <RiLoader2Line className="mr-2 h-4 w-4 animate-spin text-primary-700" />
|
||||
startConvert && <div className="i-ri-loader-2-line mr-2 h-4 w-4 animate-spin text-primary-700" data-testid="voice-input-loader" />
|
||||
}
|
||||
<div className="grow">
|
||||
{
|
||||
@@ -182,7 +176,7 @@ const VoiceInput = ({
|
||||
}
|
||||
{
|
||||
startConvert && (
|
||||
<div className={cn(s.convert, 'text-sm')}>
|
||||
<div className={cn(s.convert, 'text-sm')} data-testid="voice-input-converting-text">
|
||||
{t('voiceInput.converting', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
@@ -191,24 +185,26 @@ const VoiceInput = ({
|
||||
{
|
||||
startRecord && (
|
||||
<div
|
||||
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
|
||||
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
|
||||
onClick={handleStopRecorder}
|
||||
data-testid="voice-input-stop"
|
||||
>
|
||||
<StopCircle className="h-5 w-5 text-primary-600" />
|
||||
<div className="i-ri-stop-circle-line h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
startConvert && (
|
||||
<div
|
||||
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
|
||||
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
|
||||
onClick={onCancel}
|
||||
data-testid="voice-input-cancel"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
<div className="i-ri-close-line h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`}>{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
|
||||
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`} data-testid="voice-input-timer">{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
126
web/app/components/base/zendesk/index.spec.tsx
Normal file
126
web/app/components/base/zendesk/index.spec.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Zendesk from './index'
|
||||
|
||||
// Shared state for mocks
|
||||
let mockIsCeEdition = false
|
||||
let mockZendeskWidgetKey: string | undefined = 'test-key'
|
||||
let mockIsProd = false
|
||||
let mockNonce: string | null = 'test-nonce'
|
||||
|
||||
// Mock react's memo to just return the function
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
return {
|
||||
...actual,
|
||||
memo: vi.fn(fn => fn),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CE_EDITION() { return mockIsCeEdition },
|
||||
get ZENDESK_WIDGET_KEY() { return mockZendeskWidgetKey },
|
||||
get IS_PROD() { return mockIsProd },
|
||||
}))
|
||||
|
||||
// Mock next/headers
|
||||
vi.mock('next/headers', () => ({
|
||||
headers: vi.fn(() => ({
|
||||
get: vi.fn((name: string) => {
|
||||
if (name === 'x-nonce')
|
||||
return mockNonce
|
||||
return null
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock next/script
|
||||
type ScriptProps = {
|
||||
'children'?: ReactNode
|
||||
'id'?: string
|
||||
'src'?: string
|
||||
'nonce'?: string
|
||||
'data-testid'?: string
|
||||
}
|
||||
vi.mock('next/script', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(({ children, id, src, nonce, 'data-testid': testId }: ScriptProps) => (
|
||||
<div data-testid={testId} id={id} data-src={src} data-nonce={nonce}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
}))
|
||||
|
||||
describe('Zendesk', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCeEdition = false
|
||||
mockZendeskWidgetKey = 'test-key'
|
||||
mockIsProd = false
|
||||
mockNonce = 'test-nonce'
|
||||
})
|
||||
|
||||
// Helper to call the async component
|
||||
const renderZendesk = async () => {
|
||||
const Component = Zendesk as unknown as () => Promise<ReactNode>
|
||||
return await Component()
|
||||
}
|
||||
|
||||
it('should render nothing when IS_CE_EDITION is true', async () => {
|
||||
mockIsCeEdition = true
|
||||
const result = await renderZendesk()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing when ZENDESK_WIDGET_KEY is missing', async () => {
|
||||
mockZendeskWidgetKey = undefined
|
||||
const result = await renderZendesk()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should render scripts correctly in non-production environment', async () => {
|
||||
mockIsProd = false
|
||||
const result = await renderZendesk()
|
||||
render(result as React.ReactElement) // result is ReactNode, which render accepts but types might be picky
|
||||
|
||||
const snippet = screen.getByTestId('ze-snippet')
|
||||
expect(snippet).toBeInTheDocument()
|
||||
expect(snippet).toHaveAttribute('id', 'ze-snippet')
|
||||
expect(snippet).toHaveAttribute('data-src', 'https://static.zdassets.com/ekr/snippet.js?key=test-key')
|
||||
expect(snippet).toHaveAttribute('data-nonce', '')
|
||||
|
||||
const init = screen.getByTestId('ze-init')
|
||||
expect(init).toBeInTheDocument()
|
||||
expect(init).toHaveAttribute('id', 'ze-init')
|
||||
expect(init).toHaveTextContent('window.zE(\'messenger\', \'hide\')')
|
||||
expect(init).toHaveAttribute('data-nonce', '')
|
||||
})
|
||||
|
||||
it('should render scripts with nonce in production environment', async () => {
|
||||
mockIsProd = true
|
||||
mockNonce = 'prod-nonce'
|
||||
const result = await renderZendesk()
|
||||
render(result as React.ReactElement)
|
||||
|
||||
const snippet = screen.getByTestId('ze-snippet')
|
||||
expect(snippet).toHaveAttribute('data-nonce', 'prod-nonce')
|
||||
|
||||
const init = screen.getByTestId('ze-init')
|
||||
expect(init).toHaveAttribute('data-nonce', 'prod-nonce')
|
||||
})
|
||||
|
||||
it('should render scripts with empty nonce in production when header is missing', async () => {
|
||||
mockIsProd = true
|
||||
mockNonce = null
|
||||
const result = await renderZendesk()
|
||||
render(result as React.ReactElement)
|
||||
|
||||
const snippet = screen.getByTestId('ze-snippet')
|
||||
expect(snippet).toHaveAttribute('data-nonce', '')
|
||||
|
||||
const init = screen.getByTestId('ze-init')
|
||||
expect(init).toHaveAttribute('data-nonce', '')
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,9 @@ const Zendesk = async () => {
|
||||
nonce={nonce ?? undefined}
|
||||
id="ze-snippet"
|
||||
src={`https://static.zdassets.com/ekr/snippet.js?key=${ZENDESK_WIDGET_KEY}`}
|
||||
data-testid="ze-snippet"
|
||||
/>
|
||||
<Script nonce={nonce ?? undefined} id="ze-init">
|
||||
<Script nonce={nonce ?? undefined} id="ze-init" data-testid="ze-init">
|
||||
{`
|
||||
(function () {
|
||||
window.addEventListener('load', function () {
|
||||
|
||||
@@ -2722,16 +2722,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/tab-header/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/tab-slider-plain/index.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/tab-slider/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 2
|
||||
@@ -2781,14 +2771,6 @@
|
||||
"app/components/base/textarea/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/timezone-label/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/toast/index.tsx": {
|
||||
@@ -2817,11 +2799,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/voice-input/index.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/voice-input/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
|
||||
Reference in New Issue
Block a user