= {
+ 'common.operation.search': 'Search',
+ 'common.placeholder.input': 'Please input',
+ }
+ return translations[key] || ''
+ },
+ }),
+}))
+
+describe('Input component', () => {
+ describe('Variants', () => {
+ it('should return correct classes for regular size', () => {
+ const result = inputVariants({ size: 'regular' })
+ expect(result).toContain('px-3')
+ expect(result).toContain('radius-md')
+ expect(result).toContain('system-sm-regular')
+ })
+
+ it('should return correct classes for large size', () => {
+ const result = inputVariants({ size: 'large' })
+ expect(result).toContain('px-4')
+ expect(result).toContain('radius-lg')
+ expect(result).toContain('system-md-regular')
+ })
+
+ it('should use regular size as default', () => {
+ const result = inputVariants({})
+ expect(result).toContain('px-3')
+ expect(result).toContain('radius-md')
+ expect(result).toContain('system-sm-regular')
+ })
+ })
+
+ it('renders correctly with default props', () => {
+ render()
+ const input = screen.getByPlaceholderText('Please input')
+ expect(input).toBeInTheDocument()
+ expect(input).not.toBeDisabled()
+ expect(input).not.toHaveClass('cursor-not-allowed')
+ })
+
+ it('shows left icon when showLeftIcon is true', () => {
+ render()
+ const searchIcon = document.querySelector('svg')
+ expect(searchIcon).toBeInTheDocument()
+ const input = screen.getByPlaceholderText('Search')
+ expect(input).toHaveClass('pl-[26px]')
+ })
+
+ it('shows clear icon when showClearIcon is true and has value', () => {
+ render()
+ const clearIcon = document.querySelector('.group svg')
+ expect(clearIcon).toBeInTheDocument()
+ const input = screen.getByDisplayValue('test')
+ expect(input).toHaveClass('pr-[26px]')
+ })
+
+ it('does not show clear icon when disabled, even with value', () => {
+ render()
+ const clearIcon = document.querySelector('.group svg')
+ expect(clearIcon).not.toBeInTheDocument()
+ })
+
+ it('calls onClear when clear icon is clicked', () => {
+ const onClear = jest.fn()
+ render()
+ const clearIconContainer = document.querySelector('.group')
+ fireEvent.click(clearIconContainer!)
+ expect(onClear).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows warning icon when destructive is true', () => {
+ render()
+ const warningIcon = document.querySelector('svg')
+ expect(warningIcon).toBeInTheDocument()
+ const input = screen.getByPlaceholderText('Please input')
+ expect(input).toHaveClass('border-components-input-border-destructive')
+ })
+
+ it('applies disabled styles when disabled', () => {
+ render()
+ const input = screen.getByPlaceholderText('Please input')
+ expect(input).toBeDisabled()
+ expect(input).toHaveClass('cursor-not-allowed')
+ expect(input).toHaveClass('bg-components-input-bg-disabled')
+ })
+
+ it('displays custom unit when provided', () => {
+ render()
+ const unitElement = screen.getByText('km')
+ expect(unitElement).toBeInTheDocument()
+ })
+
+ it('applies custom className and style', () => {
+ const customClass = 'test-class'
+ const customStyle = { color: 'red' }
+ render()
+ const input = screen.getByPlaceholderText('Please input')
+ expect(input).toHaveClass(customClass)
+ expect(input).toHaveStyle('color: red')
+ })
+
+ it('applies large size variant correctly', () => {
+ render()
+ const input = screen.getByPlaceholderText('Please input')
+ expect(input.className).toContain(inputVariants({ size: 'large' }))
+ })
+
+ it('uses custom placeholder when provided', () => {
+ const placeholder = 'Custom placeholder'
+ render()
+ const input = screen.getByPlaceholderText(placeholder)
+ expect(input).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx
index e49cbf8ca..4d7ab4ed7 100644
--- a/web/app/components/base/input/index.tsx
+++ b/web/app/components/base/input/index.tsx
@@ -43,7 +43,7 @@ const Input = ({
styleCss,
value,
placeholder,
- onChange,
+ onChange = () => { },
unit,
...props
}: InputProps) => {
diff --git a/web/app/components/base/loading/index.spec.tsx b/web/app/components/base/loading/index.spec.tsx
new file mode 100644
index 000000000..03e2cfbc2
--- /dev/null
+++ b/web/app/components/base/loading/index.spec.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import Loading from './index'
+
+describe('Loading Component', () => {
+ it('renders correctly with default props', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('flex w-full items-center justify-center')
+ expect(container.firstChild).not.toHaveClass('h-full')
+ })
+
+ it('renders correctly with area type', () => {
+ const { container } = render()
+ expect(container.firstChild).not.toHaveClass('h-full')
+ })
+
+ it('renders correctly with app type', () => {
+ const { container } = render()
+ expect(container.firstChild).toHaveClass('h-full')
+ })
+
+ it('contains SVG with spin-animation class', () => {
+ const { container } = render()
+
+ const svgElement = container.querySelector('svg')
+ expect(svgElement).toHaveClass('spin-animation')
+ })
+})
diff --git a/web/app/components/base/portal-to-follow-elem/index.spec.tsx b/web/app/components/base/portal-to-follow-elem/index.spec.tsx
new file mode 100644
index 000000000..74790d784
--- /dev/null
+++ b/web/app/components/base/portal-to-follow-elem/index.spec.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '.'
+
+afterEach(cleanup)
+
+describe('PortalToFollowElem', () => {
+ describe('Context and Provider', () => {
+ test('should throw error when using context outside provider', () => {
+ // Suppress console.error for this test
+ const originalError = console.error
+ console.error = jest.fn()
+
+ expect(() => {
+ render(
+ Trigger ,
+ )
+ }).toThrow('PortalToFollowElem components must be wrapped in ')
+
+ console.error = originalError
+ })
+
+ test('should not throw when used within provider', () => {
+ expect(() => {
+ render(
+
+ Trigger
+ ,
+ )
+ }).not.toThrow()
+ })
+ })
+
+ describe('PortalToFollowElemTrigger', () => {
+ test('should render children correctly', () => {
+ const { getByText } = render(
+
+ Trigger Text
+ ,
+ )
+ expect(getByText('Trigger Text')).toBeInTheDocument()
+ })
+
+ test('should handle asChild prop correctly', () => {
+ const { getByRole } = render(
+
+
+
+
+ ,
+ )
+
+ expect(getByRole('button')).toHaveTextContent('Button Trigger')
+ })
+ })
+
+ describe('PortalToFollowElemContent', () => {
+ test('should not render content when closed', () => {
+ const { queryByText } = render(
+
+ Trigger
+ Popup Content
+ ,
+ )
+
+ expect(queryByText('Popup Content')).not.toBeInTheDocument()
+ })
+
+ test('should render content when open', () => {
+ const { getByText } = render(
+
+ Trigger
+ Popup Content
+ ,
+ )
+
+ expect(getByText('Popup Content')).toBeInTheDocument()
+ })
+ })
+
+ describe('Controlled behavior', () => {
+ test('should call onOpenChange when interaction happens', () => {
+ const handleOpenChange = jest.fn()
+
+ const { getByText } = render(
+
+ Hover Me
+ Content
+ ,
+ )
+
+ fireEvent.mouseEnter(getByText('Hover Me'))
+ expect(handleOpenChange).toHaveBeenCalled()
+
+ fireEvent.mouseLeave(getByText('Hover Me'))
+ expect(handleOpenChange).toHaveBeenCalled()
+ })
+ })
+
+ describe('Configuration options', () => {
+ test('should accept placement prop', () => {
+ // Since we can't easily test actual positioning, we'll check if the prop is passed correctly
+ const useFloatingMock = jest.spyOn(require('@floating-ui/react'), 'useFloating')
+
+ render(
+
+ Trigger
+ ,
+ )
+
+ expect(useFloatingMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ placement: 'top-start',
+ }),
+ )
+
+ useFloatingMock.mockRestore()
+ })
+ })
+})
diff --git a/web/app/components/base/spinner/index.spec.tsx b/web/app/components/base/spinner/index.spec.tsx
new file mode 100644
index 000000000..0c4f0f670
--- /dev/null
+++ b/web/app/components/base/spinner/index.spec.tsx
@@ -0,0 +1,49 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import Spinner from './index'
+
+describe('Spinner component', () => {
+ it('should render correctly when loading is true', () => {
+ const { container } = render()
+ const spinner = container.firstChild as HTMLElement
+
+ expect(spinner).toHaveClass('animate-spin')
+
+ // Check for accessibility text
+ const screenReaderText = spinner.querySelector('span')
+ expect(screenReaderText).toBeInTheDocument()
+ expect(screenReaderText).toHaveTextContent('Loading...')
+ })
+
+ it('should be hidden when loading is false', () => {
+ const { container } = render()
+ const spinner = container.firstChild as HTMLElement
+
+ expect(spinner).toHaveClass('hidden')
+ })
+
+ it('should render with custom className', () => {
+ const customClass = 'text-blue-500'
+ const { container } = render()
+ const spinner = container.firstChild as HTMLElement
+
+ expect(spinner).toHaveClass(customClass)
+ })
+
+ it('should render children correctly', () => {
+ const childText = 'Child content'
+ const { getByText } = render(
+ {childText},
+ )
+
+ expect(getByText(childText)).toBeInTheDocument()
+ })
+
+ it('should use default loading value (false) when not provided', () => {
+ const { container } = render()
+ const spinner = container.firstChild as HTMLElement
+
+ expect(spinner).toHaveClass('hidden')
+ })
+})
diff --git a/web/app/components/base/toast/index.spec.tsx b/web/app/components/base/toast/index.spec.tsx
new file mode 100644
index 000000000..366fb4cb0
--- /dev/null
+++ b/web/app/components/base/toast/index.spec.tsx
@@ -0,0 +1,191 @@
+import React from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import Toast, { ToastProvider, useToastContext } from '.'
+import '@testing-library/jest-dom'
+
+// Mock timers for testing timeouts
+jest.useFakeTimers()
+
+const TestComponent = () => {
+ const { notify, close } = useToastContext()
+
+ return (
+
+
+
+
+ )
+}
+
+describe('Toast', () => {
+ describe('Toast Component', () => {
+ test('renders toast with correct type and message', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Success message')).toBeInTheDocument()
+ })
+
+ test('renders with different types', () => {
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ expect(document.querySelector('.text-text-success')).toBeInTheDocument()
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(document.querySelector('.text-text-destructive')).toBeInTheDocument()
+ })
+
+ test('renders with custom component', () => {
+ render(
+
+ Custom}
+ />
+ ,
+ )
+
+ expect(screen.getByTestId('custom-component')).toBeInTheDocument()
+ })
+
+ test('renders children content', () => {
+ render(
+
+
+ Additional information
+
+ ,
+ )
+
+ expect(screen.getByText('Additional information')).toBeInTheDocument()
+ })
+
+ test('does not render close button when close is undefined', () => {
+ // Create a modified context where close is undefined
+ const CustomToastContext = React.createContext({ notify: () => { }, close: undefined })
+
+ // Create a wrapper component using the custom context
+ const Wrapper = ({ children }: any) => (
+ { }, close: undefined }}>
+ {children}
+
+ )
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('No close button')).toBeInTheDocument()
+ // Ensure the close button is not rendered
+ expect(document.querySelector('.h-4.w-4.shrink-0.text-text-tertiary')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('ToastProvider and Context', () => {
+ test('shows and hides toast using context', async () => {
+ render(
+
+
+ ,
+ )
+
+ // No toast initially
+ expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
+
+ // Show toast
+ act(() => {
+ screen.getByText('Show Toast').click()
+ })
+ expect(screen.getByText('Notification message')).toBeInTheDocument()
+
+ // Close toast
+ act(() => {
+ screen.getByText('Close Toast').click()
+ })
+ expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
+ })
+
+ test('automatically hides toast after duration', async () => {
+ render(
+
+
+ ,
+ )
+
+ // Show toast
+ act(() => {
+ screen.getByText('Show Toast').click()
+ })
+ expect(screen.getByText('Notification message')).toBeInTheDocument()
+
+ // Fast-forward timer
+ act(() => {
+ jest.advanceTimersByTime(3000) // Default for info type is 3000ms
+ })
+
+ // Toast should be gone
+ await waitFor(() => {
+ expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Toast.notify static method', () => {
+ test('creates and removes toast from DOM', async () => {
+ act(() => {
+ // Call the static method
+ Toast.notify({ message: 'Static notification', type: 'warning' })
+ })
+
+ // Toast should be in document
+ expect(screen.getByText('Static notification')).toBeInTheDocument()
+
+ // Fast-forward timer
+ act(() => {
+ jest.advanceTimersByTime(6000) // Default for warning type is 6000ms
+ })
+
+ // Toast should be removed
+ await waitFor(() => {
+ expect(screen.queryByText('Static notification')).not.toBeInTheDocument()
+ })
+ })
+
+ test('calls onClose callback after duration', async () => {
+ const onCloseMock = jest.fn()
+ act(() => {
+ Toast.notify({
+ message: 'Closing notification',
+ type: 'success',
+ onClose: onCloseMock,
+ })
+ })
+
+ // Fast-forward timer
+ act(() => {
+ jest.advanceTimersByTime(3000) // Default for success type is 3000ms
+ })
+
+ // onClose should be called
+ await waitFor(() => {
+ expect(onCloseMock).toHaveBeenCalled()
+ })
+ })
+ })
+})
diff --git a/web/app/components/base/tooltip/index.spec.tsx b/web/app/components/base/tooltip/index.spec.tsx
new file mode 100644
index 000000000..1b9b7a0ee
--- /dev/null
+++ b/web/app/components/base/tooltip/index.spec.tsx
@@ -0,0 +1,116 @@
+import React from 'react'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import Tooltip from './index'
+
+afterEach(cleanup)
+
+describe('Tooltip', () => {
+ describe('Rendering', () => {
+ test('should render default tooltip with question icon', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ expect(trigger).not.toBeNull()
+ expect(trigger?.querySelector('svg')).not.toBeNull() // question icon
+ })
+
+ test('should render with custom children', () => {
+ const { getByText } = render(
+
+
+ ,
+ )
+ expect(getByText('Hover me').textContent).toBe('Hover me')
+ })
+ })
+
+ describe('Disabled state', () => {
+ test('should not show tooltip when disabled', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.mouseEnter(trigger!)
+ })
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Trigger methods', () => {
+ test('should open on hover when triggerMethod is hover', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.mouseEnter(trigger!)
+ })
+ expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
+ })
+
+ test('should close on mouse leave when triggerMethod is hover', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.mouseEnter(trigger!)
+ fireEvent.mouseLeave(trigger!)
+ })
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
+ })
+
+ test('should toggle on click when triggerMethod is click', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.click(trigger!)
+ })
+ expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
+ })
+
+ test('should not close immediately on mouse leave when needsDelay is true', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.mouseEnter(trigger!)
+ fireEvent.mouseLeave(trigger!)
+ })
+ expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
+ })
+ })
+
+ describe('Styling and positioning', () => {
+ test('should apply custom trigger className', () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ expect(trigger?.className).toContain('custom-trigger')
+ })
+
+ test('should apply custom popup className', async () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.mouseEnter(trigger!)
+ })
+ expect((await screen.findByText('Tooltip content'))?.className).toContain('custom-popup')
+ })
+
+ test('should apply noDecoration when specified', async () => {
+ const triggerClassName = 'custom-trigger'
+ const { container } = render()
+ const trigger = container.querySelector(`.${triggerClassName}`)
+ act(() => {
+ fireEvent.mouseEnter(trigger!)
+ })
+ expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg')
+ })
+ })
+})
diff --git a/web/jest.config.ts b/web/jest.config.ts
index 83b3db2f8..aa2f22bf8 100644
--- a/web/jest.config.ts
+++ b/web/jest.config.ts
@@ -26,7 +26,7 @@ const config: Config = {
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
- collectCoverage: false,
+ collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,