test: add comprehensive tests (#31649)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Coding On Star 2026-01-29 11:16:26 +08:00 committed by GitHub
parent b48a10d7ec
commit 8f414af34e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 18982 additions and 2105 deletions

View File

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AutomaticBtn from './automatic-btn'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('AutomaticBtn', () => {
const mockOnClick = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the button with correct text', () => {
render(<AutomaticBtn onClick={mockOnClick} />)
expect(screen.getByText('operation.automatic')).toBeInTheDocument()
})
it('should render the sparkling icon', () => {
const { container } = render(<AutomaticBtn onClick={mockOnClick} />)
// The icon should be an SVG element inside the button
const svg = container.querySelector('svg')
expect(svg).toBeTruthy()
})
it('should render as a button element', () => {
render(<AutomaticBtn onClick={mockOnClick} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when button is clicked', () => {
render(<AutomaticBtn onClick={mockOnClick} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should call onClick multiple times on multiple clicks', () => {
render(<AutomaticBtn onClick={mockOnClick} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
expect(mockOnClick).toHaveBeenCalledTimes(3)
})
})
describe('Styling', () => {
it('should have secondary-accent variant', () => {
render(<AutomaticBtn onClick={mockOnClick} />)
const button = screen.getByRole('button')
expect(button.className).toContain('secondary-accent')
})
it('should have small size', () => {
render(<AutomaticBtn onClick={mockOnClick} />)
const button = screen.getByRole('button')
expect(button.className).toContain('small')
})
})
})

View File

@ -0,0 +1,134 @@
import type { App } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import EmptyElement from './empty-element'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, components }: { i18nKey: string, components: Record<string, React.ReactNode> }) => (
<span data-testid="trans-component" data-i18n-key={i18nKey}>
{i18nKey}
{components.shareLink}
{components.testLink}
</span>
),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirectionPath: (isTest: boolean, _app: App) => isTest ? '/test-path' : '/prod-path',
}))
vi.mock('@/utils/var', () => ({
basePath: '/base',
}))
describe('EmptyElement', () => {
const createMockAppDetail = (mode: AppModeEnum) => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test description',
mode,
icon_type: 'emoji',
icon: 'test-icon',
icon_background: '#ffffff',
enable_site: true,
enable_api: true,
created_at: Date.now(),
site: {
access_token: 'test-token',
app_base_url: 'https://app.example.com',
},
}) as unknown as App
describe('Rendering', () => {
it('should render empty element with title', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)
render(<EmptyElement appDetail={appDetail} />)
expect(screen.getByText('table.empty.element.title')).toBeInTheDocument()
})
it('should render Trans component with i18n key', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)
render(<EmptyElement appDetail={appDetail} />)
const transComponent = screen.getByTestId('trans-component')
expect(transComponent).toHaveAttribute('data-i18n-key', 'table.empty.element.content')
})
it('should render ThreeDotsIcon SVG', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)
const { container } = render(<EmptyElement appDetail={appDetail} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('App Mode Handling', () => {
it('should use CHAT mode for chat apps', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)
render(<EmptyElement appDetail={appDetail} />)
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', 'https://app.example.com/base/chat/test-token')
})
it('should use COMPLETION mode for completion apps', () => {
const appDetail = createMockAppDetail(AppModeEnum.COMPLETION)
render(<EmptyElement appDetail={appDetail} />)
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', 'https://app.example.com/base/completion/test-token')
})
it('should use WORKFLOW mode for workflow apps', () => {
const appDetail = createMockAppDetail(AppModeEnum.WORKFLOW)
render(<EmptyElement appDetail={appDetail} />)
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', 'https://app.example.com/base/workflow/test-token')
})
it('should use CHAT mode for advanced-chat apps', () => {
const appDetail = createMockAppDetail(AppModeEnum.ADVANCED_CHAT)
render(<EmptyElement appDetail={appDetail} />)
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', 'https://app.example.com/base/chat/test-token')
})
it('should use CHAT mode for agent-chat apps', () => {
const appDetail = createMockAppDetail(AppModeEnum.AGENT_CHAT)
render(<EmptyElement appDetail={appDetail} />)
const link = screen.getAllByRole('link')[0]
expect(link).toHaveAttribute('href', 'https://app.example.com/base/chat/test-token')
})
})
describe('Links', () => {
it('should render share link with correct attributes', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)
render(<EmptyElement appDetail={appDetail} />)
const links = screen.getAllByRole('link')
const shareLink = links[0]
expect(shareLink).toHaveAttribute('target', '_blank')
expect(shareLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render test link with redirection path', () => {
const appDetail = createMockAppDetail(AppModeEnum.CHAT)
render(<EmptyElement appDetail={appDetail} />)
const links = screen.getAllByRole('link')
const testLink = links[1]
expect(testLink).toHaveAttribute('href', '/test-path')
})
})
})

View File

@ -0,0 +1,210 @@
import type { QueryParam } from './index'
import { fireEvent, render, screen } from '@testing-library/react'
import Filter, { TIME_PERIOD_MAPPING } from './filter'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) => {
if (options?.count !== undefined)
return `${key} (${options.count})`
return key
},
}),
}))
vi.mock('@/service/use-log', () => ({
useAnnotationsCount: () => ({
data: { count: 10 },
isLoading: false,
}),
}))
describe('Filter', () => {
const defaultQueryParams: QueryParam = {
period: '9',
annotation_status: 'all',
keyword: '',
}
const mockSetQueryParams = vi.fn()
const defaultProps = {
appId: 'test-app-id',
queryParams: defaultQueryParams,
setQueryParams: mockSetQueryParams,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render filter components', () => {
render(<Filter {...defaultProps} />)
expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument()
})
it('should return null when loading', () => {
// This test verifies the component renders correctly with the mocked data
const { container } = render(<Filter {...defaultProps} />)
expect(container.firstChild).not.toBeNull()
})
it('should render sort component in chat mode', () => {
render(<Filter {...defaultProps} isChatMode />)
expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument()
})
it('should not render sort component when not in chat mode', () => {
render(<Filter {...defaultProps} isChatMode={false} />)
expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument()
})
})
describe('TIME_PERIOD_MAPPING', () => {
it('should have correct period keys', () => {
expect(Object.keys(TIME_PERIOD_MAPPING)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
})
it('should have today period with value 0', () => {
expect(TIME_PERIOD_MAPPING['1'].value).toBe(0)
expect(TIME_PERIOD_MAPPING['1'].name).toBe('today')
})
it('should have last7days period with value 7', () => {
expect(TIME_PERIOD_MAPPING['2'].value).toBe(7)
expect(TIME_PERIOD_MAPPING['2'].name).toBe('last7days')
})
it('should have last4weeks period with value 28', () => {
expect(TIME_PERIOD_MAPPING['3'].value).toBe(28)
expect(TIME_PERIOD_MAPPING['3'].name).toBe('last4weeks')
})
it('should have allTime period with value -1', () => {
expect(TIME_PERIOD_MAPPING['9'].value).toBe(-1)
expect(TIME_PERIOD_MAPPING['9'].name).toBe('allTime')
})
})
describe('User Interactions', () => {
it('should update keyword when typing in search input', () => {
render(<Filter {...defaultProps} />)
const searchInput = screen.getByPlaceholderText('operation.search')
fireEvent.change(searchInput, { target: { value: 'test search' } })
expect(mockSetQueryParams).toHaveBeenCalledWith({
...defaultQueryParams,
keyword: 'test search',
})
})
it('should clear keyword when clear button is clicked', () => {
const propsWithKeyword = {
...defaultProps,
queryParams: { ...defaultQueryParams, keyword: 'existing search' },
}
render(<Filter {...propsWithKeyword} />)
const clearButton = screen.getByTestId('input-clear')
fireEvent.click(clearButton)
expect(mockSetQueryParams).toHaveBeenCalledWith({
...defaultQueryParams,
keyword: '',
})
})
})
describe('Query Params', () => {
it('should display "today" when period is set to 1', () => {
const propsWithPeriod = {
...defaultProps,
queryParams: { ...defaultQueryParams, period: '1' },
}
render(<Filter {...propsWithPeriod} />)
// Period '1' maps to 'today' in TIME_PERIOD_MAPPING
expect(screen.getByText('filter.period.today')).toBeInTheDocument()
})
it('should display "last7days" when period is set to 2', () => {
const propsWithPeriod = {
...defaultProps,
queryParams: { ...defaultQueryParams, period: '2' },
}
render(<Filter {...propsWithPeriod} />)
expect(screen.getByText('filter.period.last7days')).toBeInTheDocument()
})
it('should display "allTime" when period is set to 9', () => {
render(<Filter {...defaultProps} />)
// Default period is '9' which maps to 'allTime'
expect(screen.getByText('filter.period.allTime')).toBeInTheDocument()
})
it('should display annotated status with count when annotation_status is annotated', () => {
const propsWithAnnotation = {
...defaultProps,
queryParams: { ...defaultQueryParams, annotation_status: 'annotated' },
}
render(<Filter {...propsWithAnnotation} />)
// The mock returns count: 10, so the text should include the count
expect(screen.getByText('filter.annotation.annotated (10)')).toBeInTheDocument()
})
it('should display not_annotated status when annotation_status is not_annotated', () => {
const propsWithNotAnnotated = {
...defaultProps,
queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' },
}
render(<Filter {...propsWithNotAnnotated} />)
expect(screen.getByText('filter.annotation.not_annotated')).toBeInTheDocument()
})
it('should display all annotation status when annotation_status is all', () => {
render(<Filter {...defaultProps} />)
// Default annotation_status is 'all'
expect(screen.getByText('filter.annotation.all')).toBeInTheDocument()
})
})
describe('Chat Mode', () => {
it('should display sort component with sort_by parameter', () => {
const propsWithSort = {
...defaultProps,
isChatMode: true,
queryParams: { ...defaultQueryParams, sort_by: 'created_at' },
}
render(<Filter {...propsWithSort} />)
expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument()
})
it('should handle descending sort order', () => {
const propsWithDescSort = {
...defaultProps,
isChatMode: true,
queryParams: { ...defaultQueryParams, sort_by: '-created_at' },
}
render(<Filter {...propsWithDescSort} />)
expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,221 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ModelInfo from './model-info'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
currentModel: {
model: 'gpt-4',
model_display_name: 'GPT-4',
},
currentProvider: {
provider: 'openai',
label: 'OpenAI',
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { provider: unknown, modelName: string }) => (
<div data-testid="model-icon" data-model-name={modelName}>ModelIcon</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem, showMode }: { modelItem: { model: string }, showMode: boolean }) => (
<div data-testid="model-name" data-show-mode={showMode ? 'true' : 'false'}>
{modelItem?.model}
</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open ? 'true' : 'false'}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
describe('ModelInfo', () => {
const defaultModel = {
name: 'gpt-4',
provider: 'openai',
completion_params: {
temperature: 0.7,
top_p: 0.9,
presence_penalty: 0.1,
max_tokens: 2048,
stop: ['END'],
},
}
describe('Rendering', () => {
it('should render model icon', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should render model name', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByTestId('model-name')).toBeInTheDocument()
expect(screen.getByTestId('model-name')).toHaveTextContent('gpt-4')
})
it('should render info icon button', () => {
const { container } = render(<ModelInfo model={defaultModel} />)
// The info button should contain an SVG icon
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should show model name with showMode prop', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByTestId('model-name')).toHaveAttribute('data-show-mode', 'true')
})
})
describe('Info Panel Toggle', () => {
it('should be closed by default', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
})
it('should open when info button is clicked', () => {
render(<ModelInfo model={defaultModel} />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
})
it('should close when info button is clicked again', () => {
render(<ModelInfo model={defaultModel} />)
const trigger = screen.getByTestId('portal-trigger')
// Open
fireEvent.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
// Close
fireEvent.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
})
})
describe('Model Parameters Display', () => {
it('should render model params header', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByText('detail.modelParams')).toBeInTheDocument()
})
it('should render temperature parameter', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByText('0.7')).toBeInTheDocument()
})
it('should render top_p parameter', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByText('Top P')).toBeInTheDocument()
expect(screen.getByText('0.9')).toBeInTheDocument()
})
it('should render presence_penalty parameter', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByText('Presence Penalty')).toBeInTheDocument()
expect(screen.getByText('0.1')).toBeInTheDocument()
})
it('should render max_tokens parameter', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByText('Max Token')).toBeInTheDocument()
expect(screen.getByText('2048')).toBeInTheDocument()
})
it('should render stop parameter as comma-separated values', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByText('Stop')).toBeInTheDocument()
expect(screen.getByText('END')).toBeInTheDocument()
})
})
describe('Missing Parameters', () => {
it('should show dash for missing parameters', () => {
const modelWithNoParams = {
name: 'gpt-4',
provider: 'openai',
completion_params: {},
}
render(<ModelInfo model={modelWithNoParams} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
it('should show dash for non-array stop values', () => {
const modelWithInvalidStop = {
name: 'gpt-4',
provider: 'openai',
completion_params: {
stop: 'not-an-array',
},
}
render(<ModelInfo model={modelWithInvalidStop} />)
const stopValues = screen.getAllByText('-')
expect(stopValues.length).toBeGreaterThan(0)
})
it('should join array stop values with comma', () => {
const modelWithMultipleStops = {
name: 'gpt-4',
provider: 'openai',
completion_params: {
stop: ['END', 'STOP', 'DONE'],
},
}
render(<ModelInfo model={modelWithMultipleStops} />)
expect(screen.getByText('END,STOP,DONE')).toBeInTheDocument()
})
})
describe('Model without completion_params', () => {
it('should handle undefined completion_params', () => {
const modelWithNoCompletionParams = {
name: 'gpt-4',
provider: 'openai',
}
render(<ModelInfo model={modelWithNoCompletionParams} />)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,217 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import VarPanel from './var-panel'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
default: ({ url, title, onCancel }: { url: string, title: string, onCancel: () => void }) => (
<div data-testid="image-preview" data-url={url} data-title={title}>
<button onClick={onCancel} data-testid="close-preview">Close</button>
</div>
),
}))
describe('VarPanel', () => {
const defaultProps = {
varList: [
{ label: 'name', value: 'John Doe' },
{ label: 'age', value: '25' },
],
message_files: [],
}
describe('Rendering', () => {
it('should render variables section header', () => {
render(<VarPanel {...defaultProps} />)
expect(screen.getByText('detail.variables')).toBeInTheDocument()
})
it('should render variable labels with braces', () => {
render(<VarPanel {...defaultProps} />)
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('age')).toBeInTheDocument()
})
it('should render variable values', () => {
render(<VarPanel {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should render opening and closing braces', () => {
render(<VarPanel {...defaultProps} />)
const openingBraces = screen.getAllByText('{{')
const closingBraces = screen.getAllByText('}}')
expect(openingBraces.length).toBe(2)
expect(closingBraces.length).toBe(2)
})
it('should render Variable02 icon', () => {
const { container } = render(<VarPanel {...defaultProps} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Collapse/Expand', () => {
it('should show expanded state by default', () => {
render(<VarPanel {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should collapse when header is clicked', () => {
render(<VarPanel {...defaultProps} />)
const header = screen.getByText('detail.variables').closest('div')
fireEvent.click(header!)
expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
expect(screen.queryByText('25')).not.toBeInTheDocument()
})
it('should expand when clicked again', () => {
render(<VarPanel {...defaultProps} />)
const header = screen.getByText('detail.variables').closest('div')
// Collapse
fireEvent.click(header!)
expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
// Expand
fireEvent.click(header!)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should show arrow icon when collapsed', () => {
const { container } = render(<VarPanel {...defaultProps} />)
const header = screen.getByText('detail.variables').closest('div')
fireEvent.click(header!)
// When collapsed, there should be SVG icons in the component
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should show arrow icon when expanded', () => {
const { container } = render(<VarPanel {...defaultProps} />)
// When expanded, there should be SVG icons in the component
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
})
describe('Message Files', () => {
it('should not render images section when message_files is empty', () => {
render(<VarPanel {...defaultProps} />)
expect(screen.queryByText('detail.uploadImages')).not.toBeInTheDocument()
})
it('should render images section when message_files has items', () => {
const propsWithFiles = {
...defaultProps,
message_files: ['https://example.com/image1.jpg', 'https://example.com/image2.jpg'],
}
render(<VarPanel {...propsWithFiles} />)
expect(screen.getByText('detail.uploadImages')).toBeInTheDocument()
})
it('should render image thumbnails with correct background', () => {
const propsWithFiles = {
...defaultProps,
message_files: ['https://example.com/image1.jpg'],
}
const { container } = render(<VarPanel {...propsWithFiles} />)
const thumbnail = container.querySelector('[style*="background-image"]')
expect(thumbnail).toBeInTheDocument()
expect(thumbnail).toHaveStyle({ backgroundImage: 'url(https://example.com/image1.jpg)' })
})
it('should open image preview when thumbnail is clicked', () => {
const propsWithFiles = {
...defaultProps,
message_files: ['https://example.com/image1.jpg'],
}
const { container } = render(<VarPanel {...propsWithFiles} />)
const thumbnail = container.querySelector('[style*="background-image"]')
fireEvent.click(thumbnail!)
expect(screen.getByTestId('image-preview')).toBeInTheDocument()
expect(screen.getByTestId('image-preview')).toHaveAttribute('data-url', 'https://example.com/image1.jpg')
})
it('should close image preview when close button is clicked', () => {
const propsWithFiles = {
...defaultProps,
message_files: ['https://example.com/image1.jpg'],
}
const { container } = render(<VarPanel {...propsWithFiles} />)
// Open preview
const thumbnail = container.querySelector('[style*="background-image"]')
fireEvent.click(thumbnail!)
expect(screen.getByTestId('image-preview')).toBeInTheDocument()
// Close preview
act(() => {
fireEvent.click(screen.getByTestId('close-preview'))
})
expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
})
})
describe('Empty State', () => {
it('should render with empty varList', () => {
const emptyProps = {
varList: [],
message_files: [],
}
render(<VarPanel {...emptyProps} />)
expect(screen.getByText('detail.variables')).toBeInTheDocument()
})
})
describe('Multiple Images', () => {
it('should render multiple image thumbnails', () => {
const propsWithMultipleFiles = {
...defaultProps,
message_files: [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
'https://example.com/image3.jpg',
],
}
const { container } = render(<VarPanel {...propsWithMultipleFiles} />)
const thumbnails = container.querySelectorAll('[style*="background-image"]')
expect(thumbnails.length).toBe(3)
})
})
})

View File

@ -0,0 +1,390 @@
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import TriggerCard from './trigger-card'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) => {
if (options?.count !== undefined)
return `${key} (${options.count})`
return key
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
const mockSetTriggerStatus = vi.fn()
const mockSetTriggerStatuses = vi.fn()
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatus: mockSetTriggerStatus,
setTriggerStatuses: mockSetTriggerStatuses,
}),
}))
const mockUpdateTriggerStatus = vi.fn()
const mockInvalidateAppTriggers = vi.fn()
let mockTriggers: Array<{
id: string
node_id: string
title: string
trigger_type: string
status: string
provider_name?: string
}> = []
let mockIsLoading = false
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => ({
data: { data: mockTriggers },
isLoading: mockIsLoading,
}),
useUpdateTriggerStatus: () => ({
mutateAsync: mockUpdateTriggerStatus,
}),
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => ({
data: [
{ id: 'plugin-1', name: 'Test Plugin', icon: 'test-icon' },
],
}),
}))
vi.mock('@/utils', () => ({
canFindTool: () => false,
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ type }: { type: string }) => (
<div data-testid="block-icon" data-type={type}>BlockIcon</div>
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ defaultValue, onChange, disabled }: { defaultValue: boolean, onChange: (v: boolean) => void, disabled: boolean }) => (
<button
data-testid="switch"
data-checked={defaultValue ? 'true' : 'false'}
data-disabled={disabled ? 'true' : 'false'}
onClick={() => onChange(!defaultValue)}
>
Switch
</button>
),
}))
describe('TriggerCard', () => {
const mockAppInfo = {
id: 'test-app-id',
name: 'Test App',
description: 'Test description',
mode: AppModeEnum.WORKFLOW,
icon_type: 'emoji',
icon: 'test-icon',
icon_background: '#ffffff',
created_at: Date.now(),
updated_at: Date.now(),
enable_site: true,
enable_api: true,
} as AppDetailResponse
const mockOnToggleResult = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockTriggers = []
mockIsLoading = false
mockUpdateTriggerStatus.mockResolvedValue({})
})
describe('Loading State', () => {
it('should render loading skeleton when isLoading is true', () => {
mockIsLoading = true
const { container } = render(
<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />,
)
expect(container.querySelector('.animate-pulse')).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('should show no triggers added message when triggers is empty', () => {
mockTriggers = []
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(screen.getByText('overview.triggerInfo.noTriggerAdded')).toBeInTheDocument()
})
it('should show trigger status description when no triggers', () => {
mockTriggers = []
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(screen.getByText('overview.triggerInfo.triggerStatusDescription')).toBeInTheDocument()
})
it('should show learn more link when no triggers', () => {
mockTriggers = []
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const learnMoreLink = screen.getByText('overview.triggerInfo.learnAboutTriggers')
expect(learnMoreLink).toBeInTheDocument()
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/trigger/overview')
})
})
describe('With Triggers', () => {
beforeEach(() => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Webhook Trigger',
trigger_type: 'trigger-webhook',
status: 'enabled',
},
{
id: 'trigger-2',
node_id: 'node-2',
title: 'Schedule Trigger',
trigger_type: 'trigger-schedule',
status: 'disabled',
},
]
})
it('should show triggers count message', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(screen.getByText('overview.triggerInfo.triggersAdded (2)')).toBeInTheDocument()
})
it('should render trigger titles', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(screen.getByText('Webhook Trigger')).toBeInTheDocument()
expect(screen.getByText('Schedule Trigger')).toBeInTheDocument()
})
it('should show running status for enabled triggers', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(screen.getByText('overview.status.running')).toBeInTheDocument()
})
it('should show disable status for disabled triggers', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(screen.getByText('overview.status.disable')).toBeInTheDocument()
})
it('should render block icons for each trigger', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const blockIcons = screen.getAllByTestId('block-icon')
expect(blockIcons.length).toBe(2)
})
it('should render switches for each trigger', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switches = screen.getAllByTestId('switch')
expect(switches.length).toBe(2)
})
})
describe('Toggle Trigger', () => {
beforeEach(() => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Test Trigger',
trigger_type: 'trigger-webhook',
status: 'disabled',
},
]
})
it('should call updateTriggerStatus when toggle is clicked', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
expect(mockUpdateTriggerStatus).toHaveBeenCalledWith({
appId: 'test-app-id',
triggerId: 'trigger-1',
enableTrigger: true,
})
})
})
it('should update trigger status in store optimistically', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
expect(mockSetTriggerStatus).toHaveBeenCalledWith('node-1', 'enabled')
})
})
it('should invalidate app triggers after successful update', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('test-app-id')
})
})
it('should call onToggleResult with null on success', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
expect(mockOnToggleResult).toHaveBeenCalledWith(null)
})
})
it('should rollback status and call onToggleResult with error on failure', async () => {
const error = new Error('Update failed')
mockUpdateTriggerStatus.mockRejectedValueOnce(error)
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
expect(mockSetTriggerStatus).toHaveBeenCalledWith('node-1', 'disabled')
expect(mockOnToggleResult).toHaveBeenCalledWith(error)
})
})
})
describe('Trigger Types', () => {
it('should render webhook trigger type correctly', () => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Webhook',
trigger_type: 'trigger-webhook',
status: 'enabled',
},
]
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const blockIcon = screen.getByTestId('block-icon')
expect(blockIcon).toHaveAttribute('data-type', 'trigger-webhook')
})
it('should render schedule trigger type correctly', () => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Schedule',
trigger_type: 'trigger-schedule',
status: 'enabled',
},
]
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const blockIcon = screen.getByTestId('block-icon')
expect(blockIcon).toHaveAttribute('data-type', 'trigger-schedule')
})
it('should render plugin trigger type correctly', () => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Plugin',
trigger_type: 'trigger-plugin',
status: 'enabled',
provider_name: 'plugin-1',
},
]
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const blockIcon = screen.getByTestId('block-icon')
expect(blockIcon).toHaveAttribute('data-type', 'trigger-plugin')
})
})
describe('Editor Permissions', () => {
it('should render switches for triggers', () => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Test Trigger',
trigger_type: 'trigger-webhook',
status: 'enabled',
},
]
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
expect(switchBtn).toBeInTheDocument()
})
})
describe('Status Sync', () => {
it('should sync trigger statuses to store when data loads', () => {
mockTriggers = [
{
id: 'trigger-1',
node_id: 'node-1',
title: 'Test',
trigger_type: 'trigger-webhook',
status: 'enabled',
},
{
id: 'trigger-2',
node_id: 'node-2',
title: 'Test 2',
trigger_type: 'trigger-schedule',
status: 'disabled',
},
]
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
expect(mockSetTriggerStatuses).toHaveBeenCalledWith({
'node-1': 'enabled',
'node-2': 'disabled',
})
})
})
})

View File

@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import Usage from './usage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockPlan = {
usage: {
annotatedResponse: 50,
},
total: {
annotatedResponse: 100,
},
}
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: mockPlan,
}),
}))
describe('Usage', () => {
// Rendering: renders UsageInfo with correct props from context
describe('Rendering', () => {
it('should render usage info with data from provider context', () => {
// Arrange & Act
render(<Usage />)
// Assert
expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument()
})
it('should pass className to UsageInfo component', () => {
// Arrange
const testClassName = 'mt-4'
// Act
const { container } = render(<Usage className={testClassName} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass(testClassName)
})
it('should display usage and total values from context', () => {
// Arrange & Act
render(<Usage />)
// Assert
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
})
})
})

View File

@ -73,6 +73,56 @@ describe('Billing', () => {
})
})
it('returns the refetched url from the async callback', async () => {
const newUrl = 'https://new-billing-url'
refetchMock.mockResolvedValue({ data: newUrl })
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [asyncCallback] = openAsyncWindowMock.mock.calls[0]
// Execute the async callback passed to openAsyncWindow
const result = await asyncCallback()
expect(result).toBe(newUrl)
expect(refetchMock).toHaveBeenCalled()
})
it('returns null when refetch returns no url', async () => {
refetchMock.mockResolvedValue({ data: null })
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [asyncCallback] = openAsyncWindowMock.mock.calls[0]
// Execute the async callback when url is null
const result = await asyncCallback()
expect(result).toBeNull()
})
it('handles errors in onError callback', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [, options] = openAsyncWindowMock.mock.calls[0]
// Execute the onError callback
const testError = new Error('Test error')
options.onError(testError)
expect(consoleError).toHaveBeenCalledWith('Failed to fetch billing url', testError)
consoleError.mockRestore()
})
it('disables the button while billing url is fetching', () => {
fetching = true
render(<Billing />)

View File

@ -125,4 +125,70 @@ describe('PlanComp', () => {
expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null)
})
it('does not trigger verify when isPending is true', async () => {
isPending = true
render(<PlanComp loc="billing-page" />)
const verifyBtn = screen.getByText('education.toVerified')
fireEvent.click(verifyBtn)
await waitFor(() => expect(mutateAsyncMock).not.toHaveBeenCalled())
})
it('renders sandbox plan', () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: Plan.sandbox },
enableEducationPlan: false,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
render(<PlanComp loc="billing-page" />)
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
})
it('renders team plan', () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: Plan.team },
enableEducationPlan: false,
allowRefreshEducationVerify: false,
isEducationAccount: false,
})
render(<PlanComp loc="billing-page" />)
expect(screen.getByText('billing.plans.team.name')).toBeInTheDocument()
})
it('shows verify button when education account is about to expire', () => {
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: true,
allowRefreshEducationVerify: true,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
expect(screen.getByText('education.toVerified')).toBeInTheDocument()
})
it('handles modal onConfirm and onCancel callbacks', async () => {
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
render(<PlanComp loc="billing-page" />)
// Trigger verify to show modal
const verifyBtn = screen.getByText('education.toVerified')
fireEvent.click(verifyBtn)
await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'))
// Get the props passed to the modal and call onConfirm/onCancel
const lastCall = verifyStateModalMock.mock.calls[verifyStateModalMock.mock.calls.length - 1][0]
expect(lastCall.onConfirm).toBeDefined()
expect(lastCall.onCancel).toBeDefined()
// Call onConfirm to close modal
lastCall.onConfirm()
lastCall.onCancel()
})
})

View File

@ -52,6 +52,24 @@ describe('Pricing Assets', () => {
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
})
it('should render inactive state for Cloud', () => {
// Arrange
const { container } = render(<Cloud isActive={false} />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
})
it('should render active state for SelfHosted', () => {
// Arrange
const { container } = render(<SelfHosted isActive />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
})
it('should render inactive state for SelfHosted', () => {
// Arrange
const { container } = render(<SelfHosted isActive={false} />)

View File

@ -0,0 +1,301 @@
import type { CurrentPlanInfoBackend } from '../type'
import { DocumentProcessingPriority, Plan } from '../type'
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from './index'
describe('billing utils', () => {
// parseVectorSpaceToMB tests
describe('parseVectorSpaceToMB', () => {
it('should parse MB values correctly', () => {
expect(parseVectorSpaceToMB('50MB')).toBe(50)
expect(parseVectorSpaceToMB('100MB')).toBe(100)
})
it('should parse GB values and convert to MB', () => {
expect(parseVectorSpaceToMB('5GB')).toBe(5 * 1024)
expect(parseVectorSpaceToMB('20GB')).toBe(20 * 1024)
})
it('should be case insensitive', () => {
expect(parseVectorSpaceToMB('50mb')).toBe(50)
expect(parseVectorSpaceToMB('5gb')).toBe(5 * 1024)
})
it('should return 0 for invalid format', () => {
expect(parseVectorSpaceToMB('50')).toBe(0)
expect(parseVectorSpaceToMB('invalid')).toBe(0)
expect(parseVectorSpaceToMB('')).toBe(0)
expect(parseVectorSpaceToMB('50TB')).toBe(0)
})
})
// getPlanVectorSpaceLimitMB tests
describe('getPlanVectorSpaceLimitMB', () => {
it('should return correct vector space for sandbox plan', () => {
expect(getPlanVectorSpaceLimitMB(Plan.sandbox)).toBe(50)
})
it('should return correct vector space for professional plan', () => {
expect(getPlanVectorSpaceLimitMB(Plan.professional)).toBe(5 * 1024)
})
it('should return correct vector space for team plan', () => {
expect(getPlanVectorSpaceLimitMB(Plan.team)).toBe(20 * 1024)
})
it('should return 0 for invalid plan', () => {
// @ts-expect-error - Testing invalid plan input
expect(getPlanVectorSpaceLimitMB('invalid')).toBe(0)
})
})
// parseCurrentPlan tests
describe('parseCurrentPlan', () => {
const createMockPlanData = (overrides: Partial<CurrentPlanInfoBackend> = {}): CurrentPlanInfoBackend => ({
billing: {
enabled: true,
subscription: {
plan: Plan.sandbox,
},
},
members: {
size: 1,
limit: 1,
},
apps: {
size: 2,
limit: 5,
},
vector_space: {
size: 10,
limit: 50,
},
annotation_quota_limit: {
size: 5,
limit: 10,
},
documents_upload_quota: {
size: 20,
limit: 0,
},
docs_processing: DocumentProcessingPriority.standard,
can_replace_logo: false,
model_load_balancing_enabled: false,
dataset_operator_enabled: false,
education: {
enabled: false,
activated: false,
},
webapp_copyright_enabled: false,
workspace_members: {
size: 1,
limit: 1,
},
is_allow_transfer_workspace: false,
knowledge_pipeline: {
publish_enabled: false,
},
...overrides,
})
it('should parse plan type correctly', () => {
const data = createMockPlanData()
const result = parseCurrentPlan(data)
expect(result.type).toBe(Plan.sandbox)
})
it('should parse usage values correctly', () => {
const data = createMockPlanData()
const result = parseCurrentPlan(data)
expect(result.usage.vectorSpace).toBe(10)
expect(result.usage.buildApps).toBe(2)
expect(result.usage.teamMembers).toBe(1)
expect(result.usage.annotatedResponse).toBe(5)
expect(result.usage.documentsUploadQuota).toBe(20)
})
it('should parse total limits correctly', () => {
const data = createMockPlanData()
const result = parseCurrentPlan(data)
expect(result.total.vectorSpace).toBe(50)
expect(result.total.buildApps).toBe(5)
expect(result.total.teamMembers).toBe(1)
expect(result.total.annotatedResponse).toBe(10)
})
it('should convert 0 limits to NUM_INFINITE (-1)', () => {
const data = createMockPlanData({
documents_upload_quota: {
size: 20,
limit: 0,
},
})
const result = parseCurrentPlan(data)
expect(result.total.documentsUploadQuota).toBe(-1)
})
it('should handle api_rate_limit quota', () => {
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: null,
},
})
const result = parseCurrentPlan(data)
expect(result.usage.apiRateLimit).toBe(100)
expect(result.total.apiRateLimit).toBe(5000)
})
it('should handle trigger_event quota', () => {
const data = createMockPlanData({
trigger_event: {
usage: 50,
limit: 3000,
reset_date: null,
},
})
const result = parseCurrentPlan(data)
expect(result.usage.triggerEvents).toBe(50)
expect(result.total.triggerEvents).toBe(3000)
})
it('should use fallback for api_rate_limit when not provided', () => {
const data = createMockPlanData()
const result = parseCurrentPlan(data)
// Fallback to plan preset value for sandbox: 5000
expect(result.total.apiRateLimit).toBe(5000)
})
it('should convert 0 or -1 rate limits to NUM_INFINITE', () => {
const data = createMockPlanData({
api_rate_limit: {
usage: 0,
limit: 0,
reset_date: null,
},
})
const result = parseCurrentPlan(data)
expect(result.total.apiRateLimit).toBe(-1)
const data2 = createMockPlanData({
api_rate_limit: {
usage: 0,
limit: -1,
reset_date: null,
},
})
const result2 = parseCurrentPlan(data2)
expect(result2.total.apiRateLimit).toBe(-1)
})
it('should handle reset dates with milliseconds timestamp', () => {
const futureDate = Date.now() + 86400000 // Tomorrow in ms
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: futureDate,
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBe(1)
})
it('should handle reset dates with seconds timestamp', () => {
const futureDate = Math.floor(Date.now() / 1000) + 86400 // Tomorrow in seconds
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: futureDate,
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBe(1)
})
it('should handle reset dates in YYYYMMDD format', () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const year = tomorrow.getFullYear()
const month = String(tomorrow.getMonth() + 1).padStart(2, '0')
const day = String(tomorrow.getDate()).padStart(2, '0')
const dateNumber = Number.parseInt(`${year}${month}${day}`, 10)
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: dateNumber,
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBe(1)
})
it('should return null for invalid reset dates', () => {
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: 0,
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBeNull()
})
it('should return null for negative reset dates', () => {
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: -1,
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBeNull()
})
it('should return null when reset date is in the past', () => {
const pastDate = Date.now() - 86400000 // Yesterday
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: pastDate,
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBeNull()
})
it('should handle missing apps field', () => {
const data = createMockPlanData()
// @ts-expect-error - Testing edge case
delete data.apps
const result = parseCurrentPlan(data)
expect(result.usage.buildApps).toBe(0)
})
it('should return null for unrecognized date format', () => {
const data = createMockPlanData({
api_rate_limit: {
usage: 100,
limit: 5000,
reset_date: 12345, // Unrecognized format
},
})
const result = parseCurrentPlan(data)
expect(result.reset.apiRateLimit).toBeNull()
})
})
})

View File

@ -0,0 +1,24 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import ApiIndex from './index'
afterEach(() => {
cleanup()
})
describe('ApiIndex', () => {
it('should render without crashing', () => {
render(<ApiIndex />)
expect(screen.getByText('index')).toBeInTheDocument()
})
it('should render a div with text "index"', () => {
const { container } = render(<ApiIndex />)
expect(container.firstChild).toBeInstanceOf(HTMLDivElement)
expect(container.textContent).toBe('index')
})
it('should be a valid function component', () => {
expect(typeof ApiIndex).toBe('function')
})
})

View File

@ -0,0 +1,111 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
afterEach(() => {
cleanup()
})
describe('ChunkLabel', () => {
it('should render label text', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
})
it('should render character count', () => {
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
expect(screen.getByText('150 characters')).toBeInTheDocument()
})
it('should render separator dot', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render with zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should render with large character count', () => {
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
describe('ChunkContainer', () => {
it('should render label and character count', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
expect(screen.getByText('Container 1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children content', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<ChunkContainer label="Container" characterCount={100}>
<div data-testid="child-div">
<span>Nested content</span>
</div>
</ChunkContainer>,
)
expect(screen.getByTestId('child-div')).toBeInTheDocument()
expect(screen.getByText('Nested content')).toBeInTheDocument()
})
it('should render empty children', () => {
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
expect(screen.getByText('Empty')).toBeInTheDocument()
})
})
describe('QAPreview', () => {
const mockQA = {
question: 'What is the meaning of life?',
answer: 'The meaning of life is 42.',
}
it('should render question text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
})
it('should render answer text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
})
it('should render Q label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('Q')).toBeInTheDocument()
})
it('should render A label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty strings', () => {
render(<QAPreview qa={{ question: '', answer: '' }} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with long text', () => {
const longQuestion = 'Q'.repeat(500)
const longAnswer = 'A'.repeat(500)
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
expect(screen.getByText(longQuestion)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should render with special characters', () => {
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
expect(screen.getByText('& special chars!')).toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
import type { ErrorDocsResponse } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { retryErrorDocs } from '@/service/datasets'
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
import RetryButton from './index-failed'
@ -19,6 +19,11 @@ vi.mock('@/service/datasets', () => ({
const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs)
const mockRetryErrorDocs = vi.mocked(retryErrorDocs)
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Helper to create mock query result
const createMockQueryResult = (
data: ErrorDocsResponse | undefined,
@ -139,6 +144,11 @@ describe('RetryButton (IndexFailed)', () => {
document_ids: ['doc1', 'doc2'],
})
})
// Wait for all state updates to complete
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
it('should refetch error docs after successful retry', async () => {
@ -202,8 +212,13 @@ describe('RetryButton (IndexFailed)', () => {
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
// Wait for retry to complete and state to update
await waitFor(() => {
expect(mockRetryErrorDocs).toHaveBeenCalled()
})
// Button should still be visible after failed retry
await waitFor(() => {
// Button should still be visible after failed retry
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
})
@ -275,6 +290,11 @@ describe('RetryButton (IndexFailed)', () => {
document_ids: [],
})
})
// Wait for all state updates to complete
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
})
})

View File

@ -1,381 +1,644 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Operations from './operations'
// Mock services
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentUnArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentEnable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDisable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDelete: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDownload: () => ({ mutateAsync: vi.fn().mockResolvedValue({ url: 'https://example.com/download' }), isPending: false }),
useSyncDocument: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useSyncWebsite: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentPause: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentResume: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentSummary: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
}))
// Mock utils
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
// Mock router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: React.ReactNode }) => children,
},
}))
vi.mock('use-context-selector', () => ({
useContext: () => ({
notify: mockNotify,
}),
}))
// Mock document service hooks
const mockArchive = vi.fn()
const mockUnArchive = vi.fn()
const mockEnable = vi.fn()
const mockDisable = vi.fn()
const mockDelete = vi.fn()
const mockDownload = vi.fn()
const mockSync = vi.fn()
const mockSyncWebsite = vi.fn()
const mockPause = vi.fn()
const mockResume = vi.fn()
let isDownloadPending = false
const mockGenerateSummary = vi.fn()
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
useDocumentUnArchive: () => ({ mutateAsync: mockUnArchive }),
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
useDocumentDownload: () => ({ mutateAsync: mockDownload, isPending: isDownloadPending }),
useSyncDocument: () => ({ mutateAsync: mockSync }),
useSyncWebsite: () => ({ mutateAsync: mockSyncWebsite }),
useDocumentPause: () => ({ mutateAsync: mockPause }),
useDocumentResume: () => ({ mutateAsync: mockResume }),
useDocumentSummary: () => ({ mutateAsync: mockGenerateSummary }),
}))
// Mock downloadUrl utility
const mockDownloadUrl = vi.fn()
vi.mock('@/utils/download', () => ({
downloadUrl: (params: { url: string, fileName: string }) => mockDownloadUrl(params),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
isDownloadPending = false
})
describe('Operations', () => {
const mockOnUpdate = vi.fn()
const mockOnSelectedIdChange = vi.fn()
const defaultDetail = {
id: 'doc-1',
name: 'Test Document',
enabled: true,
archived: false,
id: 'doc-123',
data_source_type: DataSourceType.FILE,
doc_form: 'text',
data_source_type: 'upload_file',
doc_form: 'text_model',
display_status: 'available',
}
const defaultProps = {
embeddingAvailable: true,
datasetId: 'dataset-1',
detail: defaultDetail,
datasetId: 'dataset-456',
onUpdate: vi.fn(),
scene: 'list' as const,
className: '',
onUpdate: mockOnUpdate,
}
beforeEach(() => {
vi.clearAllMocks()
mockArchive.mockResolvedValue({})
mockUnArchive.mockResolvedValue({})
mockEnable.mockResolvedValue({})
mockDisable.mockResolvedValue({})
mockDelete.mockResolvedValue({})
mockDownload.mockResolvedValue({ url: 'https://example.com/download' })
mockSync.mockResolvedValue({})
mockSyncWebsite.mockResolvedValue({})
mockPause.mockResolvedValue({})
mockResume.mockResolvedValue({})
})
describe('Rendering', () => {
describe('rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
// Should render at least the container
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should render switch in list scene', () => {
const { container } = render(<Operations {...defaultProps} scene="list" />)
// Switch component should be rendered
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toBeInTheDocument()
it('should render buttons when embeddingAvailable', () => {
render(<Operations {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should render settings button when embedding is available', () => {
const { container } = render(<Operations {...defaultProps} />)
// Settings button has RiEqualizer2Line icon inside
const settingsButton = container.querySelector('button.mr-2.cursor-pointer')
expect(settingsButton).toBeInTheDocument()
it('should not render settings when embeddingAvailable is false', () => {
render(<Operations {...defaultProps} embeddingAvailable={false} />)
expect(screen.queryByText('list.action.settings')).not.toBeInTheDocument()
})
it('should render disabled switch when embeddingAvailable is false in list scene', () => {
render(<Operations {...defaultProps} embeddingAvailable={false} scene="list" />)
// Switch component uses opacity-50 class when disabled
const disabledSwitch = document.querySelector('.\\!opacity-50')
expect(disabledSwitch).toBeInTheDocument()
})
})
describe('Switch Behavior', () => {
it('should render enabled switch when document is enabled', () => {
const { container } = render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: true, archived: false }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
describe('switch toggle', () => {
it('should render switch in list scene', () => {
render(<Operations {...defaultProps} scene="list" />)
const switches = document.querySelectorAll('[role="switch"], [class*="switch"]')
expect(switches.length).toBeGreaterThan(0)
})
it('should render disabled switch when document is disabled', () => {
const { container } = render(
it('should render disabled switch when archived', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: false, archived: false }}
scene="list"
detail={{ ...defaultDetail, archived: true }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
const disabledSwitch = document.querySelector('[disabled]')
expect(disabledSwitch).toBeDefined()
})
it('should show tooltip and disable switch when document is archived', () => {
const { container } = render(
it('should call enable when switch is toggled on', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: false }}
/>,
)
const switchElement = document.querySelector('[role="switch"]')
await act(async () => {
fireEvent.click(switchElement!)
})
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(600)
})
expect(mockEnable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
vi.useRealTimers()
})
it('should call disable when switch is toggled off', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: true }}
/>,
)
const switchElement = document.querySelector('[role="switch"]')
await act(async () => {
fireEvent.click(switchElement!)
})
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(600)
})
expect(mockDisable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
vi.useRealTimers()
})
it('should not call enable if already enabled', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: true }}
/>,
)
// Simulate trying to enable when already enabled - this won't happen via switch click
// because the switch would toggle to disable. But handleSwitch has early returns
vi.useRealTimers()
})
})
describe('settings navigation', () => {
it('should navigate to settings when settings button is clicked', async () => {
render(<Operations {...defaultProps} />)
// Get the first button which is the settings button
const buttons = screen.getAllByRole('button')
const settingsButton = buttons[0]
await act(async () => {
fireEvent.click(settingsButton)
})
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1/settings')
})
})
describe('detail scene', () => {
it('should render differently in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
const container = document.querySelector('.flex.items-center')
expect(container).toBeInTheDocument()
})
it('should not render switch in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
// In detail scene, there should be no switch
const switchInParent = document.querySelector('.flex.items-center > [role="switch"]')
expect(switchInParent).toBeNull()
})
})
describe('selectedIds handling', () => {
it('should accept selectedIds prop', () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-1', 'doc-2']}
onSelectedIdChange={mockOnSelectedIdChange}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
describe('popover menu actions', () => {
const openPopover = async () => {
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
}
it('should open popover when more button is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
// Check if popover content is visible
expect(screen.getByText('list.table.rename')).toBeInTheDocument()
})
it('should call archive when archive action is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const archiveButton = screen.getByText('list.action.archive')
await act(async () => {
fireEvent.click(archiveButton)
})
await waitFor(() => {
expect(mockArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should call un_archive when unarchive action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
// Archived documents have visually disabled switch (CSS-based)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
})
describe('Embedding Not Available', () => {
it('should show disabled switch when embedding not available in list scene', () => {
const { container } = render(
<Operations
{...defaultProps}
embeddingAvailable={false}
scene="list"
/>,
)
const switchEl = container.querySelector('[role="switch"]')
// Switch is visually disabled (CSS-based)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
it('should not show settings or popover when embedding not available', () => {
render(
<Operations
{...defaultProps}
embeddingAvailable={false}
/>,
)
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument()
})
})
describe('More Actions Popover', () => {
it('should show rename option for non-archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
/>,
)
// Click on the more actions button
const moreButton = document.querySelector('[class*="commonIcon"]')
expect(moreButton).toBeInTheDocument()
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const unarchiveButton = screen.getByText('list.action.unarchive')
await act(async () => {
fireEvent.click(unarchiveButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
expect(mockUnArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show download option for FILE type documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.FILE }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
it('should show delete confirmation modal when delete is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Check if confirmation modal is shown
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
})
it('should call delete when confirm is clicked in delete modal', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Click confirm button
const confirmButton = screen.getByText('operation.sure')
await act(async () => {
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
expect(mockDelete).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show sync option for notion documents', async () => {
it('should close delete modal when cancel is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Verify modal is shown
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
// Find and click the cancel button (text: operation.cancel)
const cancelButton = screen.getByText('operation.cancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Modal should be closed - title shouldn't be visible
await waitFor(() => {
expect(screen.queryByText('list.delete.title')).not.toBeInTheDocument()
})
})
it('should update selectedIds after delete operation', async () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-1', 'doc-2']}
onSelectedIdChange={mockOnSelectedIdChange}
/>,
)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
const confirmButton = screen.getByText('operation.sure')
await act(async () => {
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(mockOnSelectedIdChange).toHaveBeenCalledWith(['doc-2'])
})
})
it('should show rename modal when rename is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const renameButton = screen.getByText('list.table.rename')
await act(async () => {
fireEvent.click(renameButton)
})
// Rename modal should be shown
await waitFor(() => {
expect(screen.getByDisplayValue('Test Document')).toBeInTheDocument()
})
})
it('should call sync for notion data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const syncButton = screen.getByText('list.action.sync')
await act(async () => {
fireEvent.click(syncButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
expect(mockSync).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show sync option for web documents', async () => {
it('should call syncWebsite for web data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const syncButton = screen.getByText('list.action.sync')
await act(async () => {
fireEvent.click(syncButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
expect(mockSyncWebsite).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show archive option for non-archived documents', async () => {
it('should call pause when pause action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
detail={{ ...defaultDetail, display_status: 'indexing' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const pauseButton = screen.getByText('list.action.pause')
await act(async () => {
fireEvent.click(pauseButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
expect(mockPause).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show unarchive option for archived documents', async () => {
it('should call resume when resume action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
detail={{ ...defaultDetail, display_status: 'paused' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const resumeButton = screen.getByText('list.action.resume')
await act(async () => {
fireEvent.click(resumeButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
expect(mockResume).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show delete option', async () => {
it('should download file when download action is clicked', async () => {
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/download', fileName: 'Test Document' })
})
})
it('should show pause option when status is indexing', async () => {
it('should show download option for archived file data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
})
await openPopover()
expect(screen.getByText('list.action.download')).toBeInTheDocument()
})
it('should show resume option when status is paused', async () => {
it('should download archived file when download is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
})
describe('Delete Confirmation Modal', () => {
it('should show delete confirmation modal when delete is clicked', async () => {
describe('error handling', () => {
it('should show error notification when operation fails', async () => {
mockArchive.mockRejectedValue(new Error('API Error'))
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
const deleteButton = screen.getByText(/list\.action\.delete/i)
fireEvent.click(deleteButton)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const archiveButton = screen.getByText('list.action.archive')
await act(async () => {
fireEvent.click(archiveButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.modifiedUnsuccessfully',
})
})
})
it('should show error notification when download fails', async () => {
mockDownload.mockRejectedValue(new Error('Download Error'))
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.downloadUnsuccessfully',
})
})
})
it('should show error notification when download returns no url', async () => {
mockDownload.mockResolvedValue({})
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.downloadUnsuccessfully',
})
})
})
})
describe('Scene Variations', () => {
it('should render correctly in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
// Settings button should still be visible
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
it('should apply different styles in detail scene', () => {
const { container } = render(<Operations {...defaultProps} scene="detail" />)
// The component should render without the list-specific styles
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined detail properties', () => {
describe('display status', () => {
it('should render pause action when status is indexing', () => {
render(
<Operations
{...defaultProps}
detail={{
name: '',
enabled: false,
archived: false,
id: '',
data_source_type: '',
doc_form: '',
display_status: undefined,
}}
detail={{ ...defaultDetail, display_status: 'indexing' }}
/>,
)
// Should not crash
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should stop event propagation on click', () => {
const parentHandler = vi.fn()
it('should render resume action when status is paused', () => {
render(
<div onClick={parentHandler}>
<Operations {...defaultProps} />
</div>,
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused' }}
/>,
)
const container = document.querySelector('.flex.items-center')
if (container)
fireEvent.click(container)
// Parent handler should not be called due to stopPropagation
expect(parentHandler).not.toHaveBeenCalled()
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should handle custom className', () => {
it('should not show pause/resume for available status', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'available' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
expect(screen.queryByText('list.action.pause')).not.toBeInTheDocument()
expect(screen.queryByText('list.action.resume')).not.toBeInTheDocument()
})
})
describe('data source types', () => {
it('should handle notion data source type', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should handle web data source type', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should not show download for non-file data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
expect(screen.queryByText('list.action.download')).not.toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Operations as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
describe('className prop', () => {
it('should accept custom className prop', () => {
// The className is passed to CustomPopover, verify component renders without errors
render(<Operations {...defaultProps} className="custom-class" />)
// Component should render with the custom class
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
describe('Selected IDs Handling', () => {
it('should pass selectedIds to operations', () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-123', 'doc-456']}
onSelectedIdChange={vi.fn()}
/>,
)
// Component should render correctly with selectedIds
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,38 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render without crashing', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -1,792 +1,137 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import Card from './card'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ApiAccess from './index'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}))
// Dataset context mock data
const mockDataset: Partial<DataSet> = {
id: 'dataset-123',
name: 'Test Dataset',
enable_api: true,
}
// Mock use-context-selector
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({ dataset: mockDataset })),
useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })),
createContext: vi.fn(() => ({})),
}))
// Mock dataset detail context
const mockMutateDatasetRes = vi.fn()
// Mock context and hooks for Card component
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({
dataset: mockDataset,
mutateDatasetRes: mockMutateDatasetRes,
})),
useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
),
useDatasetDetailContextWithSelector: vi.fn(() => 'test-dataset-id'),
}))
// Mock app context for workspace permissions
let mockIsCurrentWorkspaceManager = true
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) =>
selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }),
),
useSelector: vi.fn(() => true),
}))
// Mock service hooks
const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: vi.fn(() => 'https://api.example.com/docs'),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiBaseUrl: vi.fn(() => ({
data: { api_base_url: 'https://api.example.com' },
isLoading: false,
})),
useEnableDatasetServiceApi: vi.fn(() => ({
mutateAsync: mockEnableDatasetServiceApi,
isPending: false,
})),
useDisableDatasetServiceApi: vi.fn(() => ({
mutateAsync: mockDisableDatasetServiceApi,
isPending: false,
})),
useEnableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
useDisableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
}))
// Mock API access URL hook
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'),
}))
// ============================================================================
// ApiAccess Component Tests
// ============================================================================
afterEach(() => {
cleanup()
})
describe('ApiAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
it('should render without crashing', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should render API title when expanded', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should not render API title when collapsed', () => {
render(<ApiAccess expand={false} apiEnabled={true} />)
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
})
it('should render ApiAggregate icon', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const indicatorElement = container.querySelector('.relative.flex.h-8')
expect(indicatorElement).toBeInTheDocument()
})
it('should render with proper container padding', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('p-3', 'pt-2')
})
it('should render API access text when expanded', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should apply compressed layout when expand is false', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const triggerContainer = container.querySelector('[class*="w-8"]')
expect(triggerContainer).toBeInTheDocument()
})
it('should apply full width when expand is true', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.w-full')
expect(trigger).toBeInTheDocument()
})
it('should pass apiEnabled=true to Indicator with green color', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Indicator uses color prop - test the visual presence
const indicatorContainer = container.querySelector('.relative.flex.h-8')
expect(indicatorContainer).toBeInTheDocument()
})
it('should pass apiEnabled=false to Indicator with yellow color', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={false} />)
const indicatorContainer = container.querySelector('.relative.flex.h-8')
expect(indicatorContainer).toBeInTheDocument()
})
it('should position Indicator absolutely when collapsed', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
// When collapsed, Indicator has 'absolute -right-px -top-px' classes
const triggerDiv = container.querySelector('[class*="w-8"][class*="justify-center"]')
expect(triggerDiv).toBeInTheDocument()
})
it('should not render API access text when collapsed', () => {
render(<ApiAccess expand={false} apiEnabled={true} />)
expect(screen.queryByText('appMenus.apiAccess')).not.toBeInTheDocument()
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should toggle popup open state on click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
expect(trigger).toBeInTheDocument()
if (trigger)
await user.click(trigger)
// After click, the popup should toggle (Card should be rendered via portal)
})
it('should apply hover styles on trigger', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]')
expect(trigger).toHaveClass('cursor-pointer')
})
it('should toggle open state from false to true on first click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// The handleToggle function should flip open from false to true
})
it('should toggle open state back to false on second click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger) {
await user.click(trigger) // open
await user.click(trigger) // close
}
// The handleToggle function should flip open from true to false
})
it('should apply open state styling when popup is open', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// When open, the trigger should have bg-state-base-hover class
})
it('should render with apiEnabled=true', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// --------------------------------------------------------------------------
// Portal and Card Integration Tests
// --------------------------------------------------------------------------
describe('Portal and Card Integration', () => {
it('should render Card component inside portal when open', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for portal content to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
})
it('should pass apiEnabled prop to Card component', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={false} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
})
it('should use correct portal placement configuration', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
// PortalToFollowElem is configured with placement="top-start"
// The component should render without errors
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should use correct portal offset configuration', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
// PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
// The component should render without errors
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle rapid toggle clicks gracefully', async () => {
const user = userEvent.setup()
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Use a more specific selector to find the trigger in the main component
const trigger = container.querySelector('.p-3 [class*="cursor-pointer"]')
if (trigger) {
// Rapid clicks
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
}
// Component should handle state changes without errors - use getAllByText since Card may be open
const elements = screen.getAllByText(/appMenus\.apiAccess/i)
expect(elements.length).toBeGreaterThan(0)
})
it('should render correctly when both expand and apiEnabled are false', () => {
render(<ApiAccess expand={false} apiEnabled={false} />)
// Should render without title but with indicator
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
})
it('should maintain state across prop changes', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
rerender(<ApiAccess expand={true} apiEnabled={false} />)
// Component should still render after prop change
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should not re-render unnecessarily with same props', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Card Component Tests
// ============================================================================
describe('Card (api-access)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should display enabled status when API is enabled', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should display disabled status when API is disabled', () => {
render(<Card apiEnabled={false} />)
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
it('should render switch component', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should render API Reference link', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<Card apiEnabled={true} />)
// Indicator is rendered - verify card structure
const cardContainer = container.querySelector('.w-\\[208px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should render description tip text', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccessTip/i)).toBeInTheDocument()
})
it('should apply success text color when enabled', () => {
render(<Card apiEnabled={true} />)
const statusText = screen.getByText(/serviceApi\.enabled/i)
expect(statusText).toHaveClass('text-text-success')
})
it('should apply warning text color when disabled', () => {
render(<Card apiEnabled={false} />)
const statusText = screen.getByText(/serviceApi\.disabled/i)
expect(statusText).toHaveClass('text-text-warning')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call enableDatasetServiceApi when switch is toggled on', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
})
})
it('should call disableDatasetServiceApi when switch is toggled off', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
})
})
it('should call mutateDatasetRes after successful API enable', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should call mutateDatasetRes after successful API disable', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should not call mutateDatasetRes on API enable failure', async () => {
mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
})
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
})
it('should not call mutateDatasetRes on API disable failure', async () => {
mockDisableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalled()
})
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
})
it('should have correct href for API Reference link', () => {
render(<Card apiEnabled={true} />)
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should open API Reference in new tab', () => {
render(<Card apiEnabled={true} />)
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('target', '_blank')
expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
// --------------------------------------------------------------------------
// Permission Handling Tests
// --------------------------------------------------------------------------
describe('Permission Handling', () => {
it('should disable switch when user is not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
expect(switchButton).toHaveClass('!cursor-not-allowed')
expect(switchButton).toHaveClass('!opacity-50')
})
it('should enable switch when user is workspace manager', () => {
mockIsCurrentWorkspaceManager = true
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
expect(switchButton).not.toHaveClass('!cursor-not-allowed')
expect(switchButton).not.toHaveClass('!opacity-50')
})
it('should not trigger API call when switch is disabled and clicked', async () => {
mockIsCurrentWorkspaceManager = false
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
// API should not be called when disabled
expect(mockEnableDatasetServiceApi).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty datasetId gracefully', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
return selector({
dataset: { ...mockDataset, id: '' } as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined datasetId gracefully when enabling API', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
const partialDataset = { ...mockDataset } as Partial<DataSet>
delete partialDataset.id
return selector({
dataset: partialDataset as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
// Should use fallback empty string
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined datasetId gracefully when disabling API', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
const partialDataset = { ...mockDataset } as Partial<DataSet>
delete partialDataset.id
return selector({
dataset: partialDataset as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
// Should use fallback empty string for disableDatasetServiceApi
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined mutateDatasetRes gracefully', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
return selector({
dataset: mockDataset as DataSet,
mutateDatasetRes: undefined,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
})
// Should not throw error when mutateDatasetRes is undefined
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Card apiEnabled={true} />)
rerender(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should use useCallback for onToggle handler', () => {
const { rerender } = render(<Card apiEnabled={true} />)
rerender(<Card apiEnabled={true} />)
// Component should render without issues with memoized callbacks
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should update when apiEnabled prop changes', () => {
const { rerender } = render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
rerender(<Card apiEnabled={false} />)
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('ApiAccess Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
})
it('should open Card popup and toggle API status', async () => {
const user = userEvent.setup()
it('should render with apiEnabled=false', () => {
render(<ApiAccess expand={true} apiEnabled={false} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
it('should be wrapped with React.memo', () => {
expect((ApiAccess as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
describe('toggle functionality', () => {
it('should toggle open state when trigger is clicked', async () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
// Click to open
await act(async () => {
fireEvent.click(trigger!)
})
// The component should update its state - check for state change via class
expect(trigger).toBeInTheDocument()
})
// Toggle API on
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
it('should toggle open state multiple times', async () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.cursor-pointer')
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
// First click - open
await act(async () => {
fireEvent.click(trigger!)
})
// Second click - close
await act(async () => {
fireEvent.click(trigger!)
})
expect(trigger).toBeInTheDocument()
})
it('should work when collapsed', async () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const trigger = container.querySelector('.cursor-pointer')
await act(async () => {
fireEvent.click(trigger!)
})
expect(trigger).toBeInTheDocument()
})
})
it('should complete full workflow: open -> view status -> toggle -> verify callback', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Verify enabled status is shown
await waitFor(() => {
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
describe('indicator color', () => {
it('should render with green indicator when apiEnabled is true', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Indicator component should be present
const indicator = container.querySelector('.shrink-0')
expect(indicator).toBeInTheDocument()
})
// Toggle API off
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
// Verify API call and callback
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
expect(mockMutateDatasetRes).toHaveBeenCalled()
it('should render with yellow indicator when apiEnabled is false', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={false} />)
const indicator = container.querySelector('.shrink-0')
expect(indicator).toBeInTheDocument()
})
})
it('should navigate to API Reference from Card', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
describe('layout', () => {
it('should have justify-center when collapsed', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const trigger = container.querySelector('.justify-center')
expect(trigger).toBeInTheDocument()
})
// Verify link
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
it('should not have justify-center when expanded', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const innerDiv = container.querySelector('.cursor-pointer')
// When expanded, should have gap-2 and text, not justify-center
expect(innerDiv).not.toHaveClass('justify-center')
})
})
})

View File

@ -0,0 +1,87 @@
import type { RelatedApp, RelatedAppResponse } from '@/models/datasets'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import Statistics from './statistics'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
afterEach(() => {
cleanup()
})
describe('Statistics', () => {
const mockRelatedApp: RelatedApp = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji',
icon: '🤖',
icon_background: '#ffffff',
icon_url: '',
}
const mockRelatedApps: RelatedAppResponse = {
data: [mockRelatedApp],
total: 1,
}
it('should render document count', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should render document label', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('datasetMenus.documents')).toBeInTheDocument()
})
it('should render related apps total', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should render related app label', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('datasetMenus.relatedApp')).toBeInTheDocument()
})
it('should render -- for undefined document count', () => {
render(<Statistics expand={true} relatedApps={mockRelatedApps} />)
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render -- for undefined related apps total', () => {
render(<Statistics expand={true} documentCount={5} />)
const dashes = screen.getAllByText('--')
expect(dashes.length).toBeGreaterThan(0)
})
it('should render with zero document count', () => {
render(<Statistics expand={true} documentCount={0} relatedApps={mockRelatedApps} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should render with empty related apps', () => {
const emptyRelatedApps: RelatedAppResponse = {
data: [],
total: 0,
}
render(<Statistics expand={true} documentCount={5} relatedApps={emptyRelatedApps} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should be wrapped with React.memo', () => {
expect((Statistics as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -0,0 +1,21 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import DatasetsLoading from './loading'
afterEach(() => {
cleanup()
})
describe('DatasetsLoading', () => {
it('should render null', () => {
const { container } = render(<DatasetsLoading />)
expect(container.firstChild).toBeNull()
})
it('should not throw on multiple renders', () => {
expect(() => {
render(<DatasetsLoading />)
render(<DatasetsLoading />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,58 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import NoLinkedAppsPanel from './no-linked-apps-panel'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
afterEach(() => {
cleanup()
})
describe('NoLinkedAppsPanel', () => {
it('should render without crashing', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
})
it('should render the empty tip text', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
})
it('should render the view doc link', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument()
})
it('should render link with correct href', () => {
render(<NoLinkedAppsPanel />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/integrate-knowledge-within-application')
})
it('should render link with target="_blank"', () => {
render(<NoLinkedAppsPanel />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render link with rel="noopener noreferrer"', () => {
render(<NoLinkedAppsPanel />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should be wrapped with React.memo', () => {
expect((NoLinkedAppsPanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -0,0 +1,25 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import DatasetPreview from './index'
afterEach(() => {
cleanup()
})
describe('DatasetPreview', () => {
it('should render null', () => {
const { container } = render(<DatasetPreview />)
expect(container.firstChild).toBeNull()
})
it('should be a valid function component', () => {
expect(typeof DatasetPreview).toBe('function')
})
it('should not throw on multiple renders', () => {
expect(() => {
render(<DatasetPreview />)
render(<DatasetPreview />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,220 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import ApiServer from './ApiServer'
// Mock the secret-key-modal since it involves complex API interactions
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (
isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>Close Modal</button></div> : null
),
}))
describe('ApiServer', () => {
const defaultProps = {
apiBaseUrl: 'https://api.example.com',
}
describe('rendering', () => {
it('should render the API server label', () => {
render(<ApiServer {...defaultProps} />)
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
})
it('should render the API base URL', () => {
render(<ApiServer {...defaultProps} />)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render the OK status badge', () => {
render(<ApiServer {...defaultProps} />)
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
})
it('should render the API key button', () => {
render(<ApiServer {...defaultProps} />)
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
})
it('should render CopyFeedback component', () => {
render(<ApiServer {...defaultProps} />)
// CopyFeedback renders a button for copying
const copyButtons = screen.getAllByRole('button')
expect(copyButtons.length).toBeGreaterThan(0)
})
})
describe('with different API URLs', () => {
it('should render localhost URL', () => {
render(<ApiServer apiBaseUrl="http://localhost:3000/api" />)
expect(screen.getByText('http://localhost:3000/api')).toBeInTheDocument()
})
it('should render production URL', () => {
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" />)
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
})
it('should render URL with path', () => {
render(<ApiServer apiBaseUrl="https://api.example.com/v1/chat" />)
expect(screen.getByText('https://api.example.com/v1/chat')).toBeInTheDocument()
})
})
describe('with appId prop', () => {
it('should render without appId', () => {
render(<ApiServer apiBaseUrl="https://api.example.com" />)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render with appId', () => {
render(<ApiServer apiBaseUrl="https://api.example.com" appId="app-123" />)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
})
describe('SecretKeyButton interaction', () => {
it('should open modal when API key button is clicked', async () => {
const user = userEvent.setup()
render(<ApiServer {...defaultProps} appId="app-123" />)
const apiKeyButton = screen.getByText('appApi.apiKey')
await act(async () => {
await user.click(apiKeyButton)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
it('should close modal when close button is clicked', async () => {
const user = userEvent.setup()
render(<ApiServer {...defaultProps} appId="app-123" />)
// Open modal
const apiKeyButton = screen.getByText('appApi.apiKey')
await act(async () => {
await user.click(apiKeyButton)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByText('Close Modal')
await act(async () => {
await user.click(closeButton)
})
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
describe('styling', () => {
it('should have flex layout with wrap', () => {
const { container } = render(<ApiServer {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('flex')
expect(wrapper.className).toContain('flex-wrap')
})
it('should have items-center alignment', () => {
const { container } = render(<ApiServer {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('items-center')
})
it('should have gap-y-2 for vertical spacing', () => {
const { container } = render(<ApiServer {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('gap-y-2')
})
it('should apply green styling to OK badge', () => {
render(<ApiServer {...defaultProps} />)
const okBadge = screen.getByText('appApi.ok')
expect(okBadge.className).toContain('bg-[#ECFDF3]')
expect(okBadge.className).toContain('text-[#039855]')
})
it('should have border styling on URL container', () => {
render(<ApiServer {...defaultProps} />)
const urlText = screen.getByText('https://api.example.com')
const urlContainer = urlText.closest('div[class*="rounded-lg"]')
expect(urlContainer).toBeInTheDocument()
})
})
describe('API server label', () => {
it('should have correct styling for label', () => {
render(<ApiServer {...defaultProps} />)
const label = screen.getByText('appApi.apiServer')
expect(label.className).toContain('rounded-md')
expect(label.className).toContain('border')
})
it('should have tertiary text color on label', () => {
render(<ApiServer {...defaultProps} />)
const label = screen.getByText('appApi.apiServer')
expect(label.className).toContain('text-text-tertiary')
})
})
describe('URL display', () => {
it('should have truncate class for long URLs', () => {
render(<ApiServer {...defaultProps} />)
const urlText = screen.getByText('https://api.example.com')
expect(urlText.className).toContain('truncate')
})
it('should have font-medium class on URL', () => {
render(<ApiServer {...defaultProps} />)
const urlText = screen.getByText('https://api.example.com')
expect(urlText.className).toContain('font-medium')
})
it('should have secondary text color on URL', () => {
render(<ApiServer {...defaultProps} />)
const urlText = screen.getByText('https://api.example.com')
expect(urlText.className).toContain('text-text-secondary')
})
})
describe('divider', () => {
it('should render vertical divider between URL and copy button', () => {
const { container } = render(<ApiServer {...defaultProps} />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider).toBeInTheDocument()
})
it('should have correct divider dimensions', () => {
const { container } = render(<ApiServer {...defaultProps} />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider?.className).toContain('h-[14px]')
expect(divider?.className).toContain('w-[1px]')
})
})
describe('SecretKeyButton styling', () => {
it('should have shrink-0 class to prevent shrinking', () => {
render(<ApiServer {...defaultProps} appId="app-123" />)
// The SecretKeyButton wraps a Button component
const button = screen.getByRole('button', { name: /apiKey/i })
// Check parent container has shrink-0
const buttonContainer = button.closest('.shrink-0')
expect(buttonContainer).toBeInTheDocument()
})
})
describe('accessibility', () => {
it('should have accessible button for API key', () => {
render(<ApiServer {...defaultProps} />)
const button = screen.getByRole('button', { name: /apiKey/i })
expect(button).toBeInTheDocument()
})
it('should have multiple buttons (copy + API key)', () => {
render(<ApiServer {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
})
})

View File

@ -0,0 +1,590 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Code, CodeGroup, Embed, Pre } from './code'
// Mock the clipboard utility
vi.mock('@/utils/clipboard', () => ({
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
}))
describe('code.tsx components', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
describe('Code', () => {
it('should render children', () => {
render(<Code>const x = 1</Code>)
expect(screen.getByText('const x = 1')).toBeInTheDocument()
})
it('should render as code element', () => {
render(<Code>code snippet</Code>)
const codeElement = screen.getByText('code snippet')
expect(codeElement.tagName).toBe('CODE')
})
it('should pass through additional props', () => {
render(<Code data-testid="custom-code" className="custom-class">snippet</Code>)
const codeElement = screen.getByTestId('custom-code')
expect(codeElement).toHaveClass('custom-class')
})
it('should render with complex children', () => {
render(
<Code>
<span>part1</span>
<span>part2</span>
</Code>,
)
expect(screen.getByText('part1')).toBeInTheDocument()
expect(screen.getByText('part2')).toBeInTheDocument()
})
})
describe('Embed', () => {
it('should render value prop', () => {
render(<Embed value="embedded content">ignored children</Embed>)
expect(screen.getByText('embedded content')).toBeInTheDocument()
})
it('should render as span element', () => {
render(<Embed value="test value">children</Embed>)
const span = screen.getByText('test value')
expect(span.tagName).toBe('SPAN')
})
it('should pass through additional props', () => {
render(<Embed value="content" data-testid="embed-test" className="embed-class">children</Embed>)
const embed = screen.getByTestId('embed-test')
expect(embed).toHaveClass('embed-class')
})
it('should not render children, only value', () => {
render(<Embed value="shown">hidden children</Embed>)
expect(screen.getByText('shown')).toBeInTheDocument()
expect(screen.queryByText('hidden children')).not.toBeInTheDocument()
})
})
describe('CodeGroup', () => {
describe('with string targetCode', () => {
it('should render code from targetCode string', () => {
render(
<CodeGroup targetCode="const hello = 'world'">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument()
})
it('should have shadow and rounded styles', () => {
const { container } = render(
<CodeGroup targetCode="code here">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.shadow-md')
expect(codeGroup).toBeInTheDocument()
expect(codeGroup).toHaveClass('rounded-2xl')
})
it('should have bg-zinc-900 background', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
})
describe('with array targetCode', () => {
it('should render single code example without tabs', () => {
const examples = [{ code: 'single example' }]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('single example')).toBeInTheDocument()
})
it('should render multiple code examples with tabs', () => {
const examples = [
{ title: 'JavaScript', code: 'console.log("js")' },
{ title: 'Python', code: 'print("py")' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByRole('tab', { name: 'JavaScript' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Python' })).toBeInTheDocument()
})
it('should show first tab content by default', () => {
const examples = [
{ title: 'Tab1', code: 'first content' },
{ title: 'Tab2', code: 'second content' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('first content')).toBeInTheDocument()
})
it('should switch tabs on click', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const examples = [
{ title: 'Tab1', code: 'first content' },
{ title: 'Tab2', code: 'second content' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const tab2 = screen.getByRole('tab', { name: 'Tab2' })
await act(async () => {
await user.click(tab2)
})
await waitFor(() => {
expect(screen.getByText('second content')).toBeInTheDocument()
})
})
it('should use "Code" as default title when title not provided', () => {
const examples = [
{ code: 'example 1' },
{ code: 'example 2' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeTabs = screen.getAllByRole('tab', { name: 'Code' })
expect(codeTabs).toHaveLength(2)
})
})
describe('with title prop', () => {
it('should render title in header', () => {
render(
<CodeGroup title="API Example" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('API Example')).toBeInTheDocument()
})
it('should render title in h3 element', () => {
render(
<CodeGroup title="Example Title" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const h3 = screen.getByRole('heading', { level: 3 })
expect(h3).toHaveTextContent('Example Title')
})
})
describe('with tag and label props', () => {
it('should render tag in code panel header', () => {
render(
<CodeGroup tag="GET" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should render label in code panel header', () => {
render(
<CodeGroup label="/api/users" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('/api/users')).toBeInTheDocument()
})
it('should render both tag and label with separator', () => {
const { container } = render(
<CodeGroup tag="POST" label="/api/create" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('POST')).toBeInTheDocument()
expect(screen.getByText('/api/create')).toBeInTheDocument()
// Separator should be present
const separator = container.querySelector('.rounded-full.bg-zinc-500')
expect(separator).toBeInTheDocument()
})
})
describe('CopyButton functionality', () => {
it('should render copy button', () => {
render(
<CodeGroup targetCode="copyable code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const copyButton = screen.getByRole('button')
expect(copyButton).toBeInTheDocument()
})
it('should show "Copy" text initially', () => {
render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('Copy')).toBeInTheDocument()
})
it('should show "Copied!" after clicking copy button', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { writeTextToClipboard } = await import('@/utils/clipboard')
render(
<CodeGroup targetCode="code to copy">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const copyButton = screen.getByRole('button')
await act(async () => {
await user.click(copyButton)
})
await waitFor(() => {
expect(writeTextToClipboard).toHaveBeenCalledWith('code to copy')
})
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
it('should reset copy state after timeout', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const copyButton = screen.getByRole('button')
await act(async () => {
await user.click(copyButton)
})
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
// Advance time past the timeout
await act(async () => {
vi.advanceTimersByTime(1500)
})
await waitFor(() => {
expect(screen.getByText('Copy')).toBeInTheDocument()
})
})
})
describe('without targetCode (using children)', () => {
it('should render children when no targetCode provided', () => {
render(
<CodeGroup>
<pre><code>child code content</code></pre>
</CodeGroup>,
)
expect(screen.getByText('child code content')).toBeInTheDocument()
})
})
describe('styling', () => {
it('should have not-prose class to prevent prose styling', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.not-prose')
expect(codeGroup).toBeInTheDocument()
})
it('should have my-6 margin', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.my-6')
expect(codeGroup).toBeInTheDocument()
})
it('should have overflow-hidden', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.overflow-hidden')
expect(codeGroup).toBeInTheDocument()
})
})
})
describe('Pre', () => {
describe('when outside CodeGroup context', () => {
it('should wrap children in CodeGroup', () => {
const { container } = render(
<Pre>
<pre><code>code content</code></pre>
</Pre>,
)
// Should render within a CodeGroup structure
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
it('should pass props to CodeGroup', () => {
render(
<Pre title="Pre Title">
<pre><code>code</code></pre>
</Pre>,
)
expect(screen.getByText('Pre Title')).toBeInTheDocument()
})
})
describe('when inside CodeGroup context (isGrouped)', () => {
it('should return children directly without wrapping', () => {
render(
<CodeGroup targetCode="outer code">
<Pre>
<code>inner code</code>
</Pre>
</CodeGroup>,
)
// The outer code should be rendered (from targetCode)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
})
})
describe('CodePanelHeader (via CodeGroup)', () => {
it('should not render when neither tag nor label provided', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const headerDivider = container.querySelector('.border-b-white\\/7\\.5')
expect(headerDivider).not.toBeInTheDocument()
})
it('should render when only tag is provided', () => {
render(
<CodeGroup tag="GET" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should render when only label is provided', () => {
render(
<CodeGroup label="/api/endpoint" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('/api/endpoint')).toBeInTheDocument()
})
it('should render label with font-mono styling', () => {
render(
<CodeGroup label="/api/test" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const label = screen.getByText('/api/test')
expect(label.className).toContain('font-mono')
expect(label.className).toContain('text-xs')
})
})
describe('CodeGroupHeader (via CodeGroup with multiple tabs)', () => {
it('should render tab list for multiple examples', () => {
const examples = [
{ title: 'cURL', code: 'curl example' },
{ title: 'Node.js', code: 'node example' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByRole('tablist')).toBeInTheDocument()
})
it('should style active tab differently', () => {
const examples = [
{ title: 'Active', code: 'active code' },
{ title: 'Inactive', code: 'inactive code' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const activeTab = screen.getByRole('tab', { name: 'Active' })
expect(activeTab.className).toContain('border-emerald-500')
expect(activeTab.className).toContain('text-emerald-400')
})
it('should have header background styling', () => {
const examples = [
{ title: 'Tab1', code: 'code1' },
{ title: 'Tab2', code: 'code2' },
]
const { container } = render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const header = container.querySelector('.bg-zinc-800')
expect(header).toBeInTheDocument()
})
})
describe('CodePanel (via CodeGroup)', () => {
it('should render code in pre element', () => {
render(
<CodeGroup targetCode="pre content">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('pre content').closest('pre')
expect(preElement).toBeInTheDocument()
})
it('should have text-white class on pre', () => {
render(
<CodeGroup targetCode="white text">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('white text').closest('pre')
expect(preElement?.className).toContain('text-white')
})
it('should have text-xs class on pre', () => {
render(
<CodeGroup targetCode="small text">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('small text').closest('pre')
expect(preElement?.className).toContain('text-xs')
})
it('should have overflow-x-auto on pre', () => {
render(
<CodeGroup targetCode="scrollable">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('scrollable').closest('pre')
expect(preElement?.className).toContain('overflow-x-auto')
})
it('should have p-4 padding on pre', () => {
render(
<CodeGroup targetCode="padded">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('padded').closest('pre')
expect(preElement?.className).toContain('p-4')
})
})
describe('ClipboardIcon (via CopyButton in CodeGroup)', () => {
it('should render clipboard icon in copy button', () => {
render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const copyButton = screen.getByRole('button')
const svg = copyButton.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(svg).toHaveAttribute('viewBox', '0 0 20 20')
})
})
describe('edge cases', () => {
it('should handle empty string targetCode', () => {
render(
<CodeGroup targetCode="">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
// Should render copy button even with empty code
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle targetCode with special characters', () => {
const specialCode = '<div class="test">&amp;</div>'
render(
<CodeGroup targetCode={specialCode}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText(specialCode)).toBeInTheDocument()
})
it('should handle multiline targetCode', () => {
const multilineCode = `line1
line2
line3`
render(
<CodeGroup targetCode={multilineCode}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
// Multiline code should be rendered - use a partial match
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line2/)).toBeInTheDocument()
expect(screen.getByText(/line3/)).toBeInTheDocument()
})
it('should handle examples with tag property', () => {
const examples = [
{ title: 'Example', tag: 'v1', code: 'versioned code' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('versioned code')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,339 @@
import { render, screen } from '@testing-library/react'
import DevelopMain from './index'
// Mock the app store with a factory function to control state
const mockAppDetailValue: { current: unknown } = { current: undefined }
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: unknown) => unknown) => {
const state = { appDetail: mockAppDetailValue.current }
return selector(state)
},
}))
// Mock the Doc component since it has complex dependencies
vi.mock('@/app/components/develop/doc', () => ({
default: ({ appDetail }: { appDetail: { name?: string } | null }) => (
<div data-testid="doc-component">
Doc Component -
{appDetail?.name}
</div>
),
}))
// Mock the ApiServer component
vi.mock('@/app/components/develop/ApiServer', () => ({
default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => (
<div data-testid="api-server">
API Server -
{apiBaseUrl}
{' '}
-
{appId}
</div>
),
}))
describe('DevelopMain', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetailValue.current = undefined
})
describe('loading state', () => {
it('should show loading when appDetail is undefined', () => {
mockAppDetailValue.current = undefined
render(<DevelopMain appId="app-123" />)
// Loading component renders with role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show loading when appDetail is null', () => {
mockAppDetailValue.current = null
render(<DevelopMain appId="app-123" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should have centered loading container', () => {
mockAppDetailValue.current = undefined
const { container } = render(<DevelopMain appId="app-123" />)
const loadingContainer = container.querySelector('.flex.h-full.items-center.justify-center')
expect(loadingContainer).toBeInTheDocument()
})
it('should have correct background on loading state', () => {
mockAppDetailValue.current = undefined
const { container } = render(<DevelopMain appId="app-123" />)
const loadingContainer = container.querySelector('.bg-background-default')
expect(loadingContainer).toBeInTheDocument()
})
})
describe('with appDetail loaded', () => {
const mockAppDetail = {
id: 'app-123',
name: 'Test Application',
api_base_url: 'https://api.example.com/v1',
mode: 'chat',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should render ApiServer component', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('api-server')).toBeInTheDocument()
})
it('should pass api_base_url to ApiServer', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('api-server')).toHaveTextContent('https://api.example.com/v1')
})
it('should pass appId to ApiServer', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('api-server')).toHaveTextContent('app-123')
})
it('should render Doc component', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('doc-component')).toBeInTheDocument()
})
it('should pass appDetail to Doc component', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('doc-component')).toHaveTextContent('Test Application')
})
it('should not show loading when appDetail exists', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
describe('layout structure', () => {
const mockAppDetail = {
id: 'app-123',
name: 'Test Application',
api_base_url: 'https://api.example.com',
mode: 'chat',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should have flex column layout', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.className).toContain('flex')
expect(mainContainer.className).toContain('flex-col')
})
it('should have relative positioning', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.className).toContain('relative')
})
it('should have full height', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.className).toContain('h-full')
})
it('should have overflow-hidden', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.className).toContain('overflow-hidden')
})
})
describe('header section', () => {
const mockAppDetail = {
id: 'app-123',
name: 'Test Application',
api_base_url: 'https://api.example.com',
mode: 'chat',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should have header with border', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.border-b')
expect(header).toBeInTheDocument()
})
it('should have shrink-0 on header to prevent shrinking', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.shrink-0')
expect(header).toBeInTheDocument()
})
it('should have horizontal padding on header', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.px-6')
expect(header).toBeInTheDocument()
})
it('should have vertical padding on header', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.py-2')
expect(header).toBeInTheDocument()
})
it('should have items centered in header', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.items-center')
expect(header).toBeInTheDocument()
})
it('should have justify-between in header', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.justify-between')
expect(header).toBeInTheDocument()
})
})
describe('content section', () => {
const mockAppDetail = {
id: 'app-123',
name: 'Test Application',
api_base_url: 'https://api.example.com',
mode: 'chat',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should have grow class for content area', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const content = container.querySelector('.grow')
expect(content).toBeInTheDocument()
})
it('should have overflow-auto for content scrolling', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const content = container.querySelector('.overflow-auto')
expect(content).toBeInTheDocument()
})
it('should have horizontal padding on content', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const content = container.querySelector('.px-4')
expect(content).toBeInTheDocument()
})
it('should have vertical padding on content', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const content = container.querySelector('.py-4')
expect(content).toBeInTheDocument()
})
it('should have responsive padding', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const content = container.querySelector('[class*="sm:px-10"]')
expect(content).toBeInTheDocument()
})
})
describe('with different appIds', () => {
const mockAppDetail = {
id: 'app-456',
name: 'Another App',
api_base_url: 'https://another-api.com',
mode: 'completion',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should pass different appId to ApiServer', () => {
render(<DevelopMain appId="app-456" />)
expect(screen.getByTestId('api-server')).toHaveTextContent('app-456')
})
it('should handle app with different api_base_url', () => {
render(<DevelopMain appId="app-456" />)
expect(screen.getByTestId('api-server')).toHaveTextContent('https://another-api.com')
})
})
describe('empty state handling', () => {
it('should handle appDetail with minimal properties', () => {
mockAppDetailValue.current = {
api_base_url: 'https://api.test.com',
}
render(<DevelopMain appId="app-minimal" />)
expect(screen.getByTestId('api-server')).toBeInTheDocument()
})
it('should handle appDetail with empty api_base_url', () => {
mockAppDetailValue.current = {
api_base_url: '',
name: 'Empty URL App',
}
render(<DevelopMain appId="app-empty-url" />)
expect(screen.getByTestId('api-server')).toBeInTheDocument()
})
})
describe('title element', () => {
const mockAppDetail = {
id: 'app-123',
name: 'Test Application',
api_base_url: 'https://api.example.com',
mode: 'chat',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should have title div with correct styling', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const title = container.querySelector('.text-lg.font-medium.text-text-primary')
expect(title).toBeInTheDocument()
})
it('should render empty title div', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const title = container.querySelector('.text-lg.font-medium.text-text-primary')
expect(title?.textContent).toBe('')
})
})
describe('border styling', () => {
const mockAppDetail = {
id: 'app-123',
name: 'Test Application',
api_base_url: 'https://api.example.com',
mode: 'chat',
}
beforeEach(() => {
mockAppDetailValue.current = mockAppDetail
})
it('should have solid border style', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.border-solid')
expect(header).toBeInTheDocument()
})
it('should have divider regular color on border', () => {
const { container } = render(<DevelopMain appId="app-123" />)
const header = container.querySelector('.border-b-divider-regular')
expect(header).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,655 @@
import { render, screen } from '@testing-library/react'
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from './md'
describe('md.tsx components', () => {
describe('Heading', () => {
const defaultProps = {
url: '/api/messages',
method: 'GET' as const,
title: 'Get Messages',
name: '#get-messages',
}
describe('rendering', () => {
it('should render the method badge', () => {
render(<Heading {...defaultProps} />)
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should render the url', () => {
render(<Heading {...defaultProps} />)
expect(screen.getByText('/api/messages')).toBeInTheDocument()
})
it('should render the title as a link', () => {
render(<Heading {...defaultProps} />)
const link = screen.getByRole('link', { name: 'Get Messages' })
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '#get-messages')
})
it('should render an anchor span with correct id', () => {
const { container } = render(<Heading {...defaultProps} />)
const anchor = container.querySelector('#get-messages')
expect(anchor).toBeInTheDocument()
})
it('should strip # prefix from name for id', () => {
const { container } = render(<Heading {...defaultProps} name="#with-hash" />)
const anchor = container.querySelector('#with-hash')
expect(anchor).toBeInTheDocument()
})
})
describe('method styling', () => {
it('should apply emerald styles for GET method', () => {
render(<Heading {...defaultProps} method="GET" />)
const badge = screen.getByText('GET')
expect(badge.className).toContain('text-emerald')
expect(badge.className).toContain('bg-emerald-400/10')
expect(badge.className).toContain('ring-emerald-300')
})
it('should apply sky styles for POST method', () => {
render(<Heading {...defaultProps} method="POST" />)
const badge = screen.getByText('POST')
expect(badge.className).toContain('text-sky')
expect(badge.className).toContain('bg-sky-400/10')
expect(badge.className).toContain('ring-sky-300')
})
it('should apply amber styles for PUT method', () => {
render(<Heading {...defaultProps} method="PUT" />)
const badge = screen.getByText('PUT')
expect(badge.className).toContain('text-amber')
expect(badge.className).toContain('bg-amber-400/10')
expect(badge.className).toContain('ring-amber-300')
})
it('should apply rose styles for DELETE method', () => {
render(<Heading {...defaultProps} method="DELETE" />)
const badge = screen.getByText('DELETE')
expect(badge.className).toContain('text-red')
expect(badge.className).toContain('bg-rose')
expect(badge.className).toContain('ring-rose')
})
it('should apply violet styles for PATCH method', () => {
render(<Heading {...defaultProps} method="PATCH" />)
const badge = screen.getByText('PATCH')
expect(badge.className).toContain('text-violet')
expect(badge.className).toContain('bg-violet-400/10')
expect(badge.className).toContain('ring-violet-300')
})
})
describe('badge base styles', () => {
it('should have rounded-lg class', () => {
render(<Heading {...defaultProps} />)
const badge = screen.getByText('GET')
expect(badge.className).toContain('rounded-lg')
})
it('should have font-mono class', () => {
render(<Heading {...defaultProps} />)
const badge = screen.getByText('GET')
expect(badge.className).toContain('font-mono')
})
it('should have font-semibold class', () => {
render(<Heading {...defaultProps} />)
const badge = screen.getByText('GET')
expect(badge.className).toContain('font-semibold')
})
it('should have ring-1 and ring-inset classes', () => {
render(<Heading {...defaultProps} />)
const badge = screen.getByText('GET')
expect(badge.className).toContain('ring-1')
expect(badge.className).toContain('ring-inset')
})
})
describe('url styles', () => {
it('should have font-mono class on url', () => {
render(<Heading {...defaultProps} />)
const url = screen.getByText('/api/messages')
expect(url.className).toContain('font-mono')
})
it('should have text-xs class on url', () => {
render(<Heading {...defaultProps} />)
const url = screen.getByText('/api/messages')
expect(url.className).toContain('text-xs')
})
it('should have zinc text color on url', () => {
render(<Heading {...defaultProps} />)
const url = screen.getByText('/api/messages')
expect(url.className).toContain('text-zinc-400')
})
})
describe('h2 element', () => {
it('should render title inside h2', () => {
render(<Heading {...defaultProps} />)
const h2 = screen.getByRole('heading', { level: 2 })
expect(h2).toBeInTheDocument()
expect(h2).toHaveTextContent('Get Messages')
})
it('should have scroll-mt-32 class on h2', () => {
render(<Heading {...defaultProps} />)
const h2 = screen.getByRole('heading', { level: 2 })
expect(h2.className).toContain('scroll-mt-32')
})
})
})
describe('Row', () => {
it('should render children', () => {
render(
<Row anchor={false}>
<div>Child 1</div>
<div>Child 2</div>
</Row>,
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
})
it('should have grid layout', () => {
const { container } = render(
<Row anchor={false}>
<div>Content</div>
</Row>,
)
const row = container.firstChild as HTMLElement
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-1')
})
it('should have gap classes', () => {
const { container } = render(
<Row anchor={false}>
<div>Content</div>
</Row>,
)
const row = container.firstChild as HTMLElement
expect(row.className).toContain('gap-x-16')
expect(row.className).toContain('gap-y-10')
})
it('should have xl responsive classes', () => {
const { container } = render(
<Row anchor={false}>
<div>Content</div>
</Row>,
)
const row = container.firstChild as HTMLElement
expect(row.className).toContain('xl:grid-cols-2')
expect(row.className).toContain('xl:!max-w-none')
})
it('should have items-start class', () => {
const { container } = render(
<Row anchor={false}>
<div>Content</div>
</Row>,
)
const row = container.firstChild as HTMLElement
expect(row.className).toContain('items-start')
})
})
describe('Col', () => {
it('should render children', () => {
render(
<Col anchor={false} sticky={false}>
<div>Column Content</div>
</Col>,
)
expect(screen.getByText('Column Content')).toBeInTheDocument()
})
it('should have first/last child margin classes', () => {
const { container } = render(
<Col anchor={false} sticky={false}>
<div>Content</div>
</Col>,
)
const col = container.firstChild as HTMLElement
expect(col.className).toContain('[&>:first-child]:mt-0')
expect(col.className).toContain('[&>:last-child]:mb-0')
})
it('should apply sticky classes when sticky is true', () => {
const { container } = render(
<Col anchor={false} sticky={true}>
<div>Sticky Content</div>
</Col>,
)
const col = container.firstChild as HTMLElement
expect(col.className).toContain('xl:sticky')
expect(col.className).toContain('xl:top-24')
})
it('should not apply sticky classes when sticky is false', () => {
const { container } = render(
<Col anchor={false} sticky={false}>
<div>Non-sticky Content</div>
</Col>,
)
const col = container.firstChild as HTMLElement
expect(col.className).not.toContain('xl:sticky')
expect(col.className).not.toContain('xl:top-24')
})
})
describe('Properties', () => {
it('should render children', () => {
render(
<Properties anchor={false}>
<li>Property 1</li>
<li>Property 2</li>
</Properties>,
)
expect(screen.getByText('Property 1')).toBeInTheDocument()
expect(screen.getByText('Property 2')).toBeInTheDocument()
})
it('should render as ul with role list', () => {
render(
<Properties anchor={false}>
<li>Property</li>
</Properties>,
)
const list = screen.getByRole('list')
expect(list).toBeInTheDocument()
expect(list.tagName).toBe('UL')
})
it('should have my-6 margin class', () => {
const { container } = render(
<Properties anchor={false}>
<li>Property</li>
</Properties>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('my-6')
})
it('should have list-none class on ul', () => {
render(
<Properties anchor={false}>
<li>Property</li>
</Properties>,
)
const list = screen.getByRole('list')
expect(list.className).toContain('list-none')
})
it('should have m-0 and p-0 classes on ul', () => {
render(
<Properties anchor={false}>
<li>Property</li>
</Properties>,
)
const list = screen.getByRole('list')
expect(list.className).toContain('m-0')
expect(list.className).toContain('p-0')
})
it('should have divide-y class on ul', () => {
render(
<Properties anchor={false}>
<li>Property</li>
</Properties>,
)
const list = screen.getByRole('list')
expect(list.className).toContain('divide-y')
})
it('should have max-w constraint class', () => {
render(
<Properties anchor={false}>
<li>Property</li>
</Properties>,
)
const list = screen.getByRole('list')
expect(list.className).toContain('max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))]')
})
})
describe('Property', () => {
const defaultProps = {
name: 'user_id',
type: 'string',
anchor: false,
}
it('should render name in code element', () => {
render(
<Property {...defaultProps}>
User identifier
</Property>,
)
const code = screen.getByText('user_id')
expect(code.tagName).toBe('CODE')
})
it('should render type', () => {
render(
<Property {...defaultProps}>
User identifier
</Property>,
)
expect(screen.getByText('string')).toBeInTheDocument()
})
it('should render children as description', () => {
render(
<Property {...defaultProps}>
User identifier
</Property>,
)
expect(screen.getByText('User identifier')).toBeInTheDocument()
})
it('should render as li element', () => {
const { container } = render(
<Property {...defaultProps}>
Description
</Property>,
)
expect(container.querySelector('li')).toBeInTheDocument()
})
it('should have m-0 class on li', () => {
const { container } = render(
<Property {...defaultProps}>
Description
</Property>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('m-0')
})
it('should have padding classes on li', () => {
const { container } = render(
<Property {...defaultProps}>
Description
</Property>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('px-0')
expect(li.className).toContain('py-4')
})
it('should have first:pt-0 and last:pb-0 classes', () => {
const { container } = render(
<Property {...defaultProps}>
Description
</Property>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('first:pt-0')
expect(li.className).toContain('last:pb-0')
})
it('should render dl element with proper structure', () => {
const { container } = render(
<Property {...defaultProps}>
Description
</Property>,
)
expect(container.querySelector('dl')).toBeInTheDocument()
})
it('should have sr-only dt elements for accessibility', () => {
const { container } = render(
<Property {...defaultProps}>
User identifier
</Property>,
)
const dtElements = container.querySelectorAll('dt')
expect(dtElements.length).toBe(3)
dtElements.forEach((dt) => {
expect(dt.className).toContain('sr-only')
})
})
it('should have font-mono class on type', () => {
render(
<Property {...defaultProps}>
Description
</Property>,
)
const typeElement = screen.getByText('string')
expect(typeElement.className).toContain('font-mono')
expect(typeElement.className).toContain('text-xs')
})
})
describe('SubProperty', () => {
const defaultProps = {
name: 'sub_field',
type: 'number',
anchor: false,
}
it('should render name in code element', () => {
render(
<SubProperty {...defaultProps}>
Sub field description
</SubProperty>,
)
const code = screen.getByText('sub_field')
expect(code.tagName).toBe('CODE')
})
it('should render type', () => {
render(
<SubProperty {...defaultProps}>
Sub field description
</SubProperty>,
)
expect(screen.getByText('number')).toBeInTheDocument()
})
it('should render children as description', () => {
render(
<SubProperty {...defaultProps}>
Sub field description
</SubProperty>,
)
expect(screen.getByText('Sub field description')).toBeInTheDocument()
})
it('should render as li element', () => {
const { container } = render(
<SubProperty {...defaultProps}>
Description
</SubProperty>,
)
expect(container.querySelector('li')).toBeInTheDocument()
})
it('should have m-0 class on li', () => {
const { container } = render(
<SubProperty {...defaultProps}>
Description
</SubProperty>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('m-0')
})
it('should have different padding than Property (py-1 vs py-4)', () => {
const { container } = render(
<SubProperty {...defaultProps}>
Description
</SubProperty>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('px-0')
expect(li.className).toContain('py-1')
})
it('should have last:pb-0 class', () => {
const { container } = render(
<SubProperty {...defaultProps}>
Description
</SubProperty>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('last:pb-0')
})
it('should render dl element with proper structure', () => {
const { container } = render(
<SubProperty {...defaultProps}>
Description
</SubProperty>,
)
expect(container.querySelector('dl')).toBeInTheDocument()
})
it('should have sr-only dt elements for accessibility', () => {
const { container } = render(
<SubProperty {...defaultProps}>
Sub field description
</SubProperty>,
)
const dtElements = container.querySelectorAll('dt')
expect(dtElements.length).toBe(3)
dtElements.forEach((dt) => {
expect(dt.className).toContain('sr-only')
})
})
it('should have font-mono and text-xs on type', () => {
render(
<SubProperty {...defaultProps}>
Description
</SubProperty>,
)
const typeElement = screen.getByText('number')
expect(typeElement.className).toContain('font-mono')
expect(typeElement.className).toContain('text-xs')
})
})
describe('PropertyInstruction', () => {
it('should render children', () => {
render(
<PropertyInstruction>
This is an instruction
</PropertyInstruction>,
)
expect(screen.getByText('This is an instruction')).toBeInTheDocument()
})
it('should render as li element', () => {
const { container } = render(
<PropertyInstruction>
Instruction text
</PropertyInstruction>,
)
expect(container.querySelector('li')).toBeInTheDocument()
})
it('should have m-0 class', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('m-0')
})
it('should have padding classes', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('px-0')
expect(li.className).toContain('py-4')
})
it('should have italic class', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('italic')
})
it('should have first:pt-0 class', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('first:pt-0')
})
})
describe('integration tests', () => {
it('should render Property inside Properties', () => {
render(
<Properties anchor={false}>
<Property name="id" type="string" anchor={false}>
Unique identifier
</Property>
<Property name="name" type="string" anchor={false}>
Display name
</Property>
</Properties>,
)
expect(screen.getByText('id')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('Unique identifier')).toBeInTheDocument()
expect(screen.getByText('Display name')).toBeInTheDocument()
})
it('should render Col inside Row', () => {
render(
<Row anchor={false}>
<Col anchor={false} sticky={false}>
<div>Left column</div>
</Col>
<Col anchor={false} sticky={true}>
<div>Right column</div>
</Col>
</Row>,
)
expect(screen.getByText('Left column')).toBeInTheDocument()
expect(screen.getByText('Right column')).toBeInTheDocument()
})
it('should render PropertyInstruction inside Properties', () => {
render(
<Properties anchor={false}>
<PropertyInstruction>
Note: All fields are required
</PropertyInstruction>
<Property name="required_field" type="string" anchor={false}>
A required field
</Property>
</Properties>,
)
expect(screen.getByText('Note: All fields are required')).toBeInTheDocument()
expect(screen.getByText('required_field')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,314 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import InputCopy from './input-copy'
// Mock copy-to-clipboard
vi.mock('copy-to-clipboard', () => ({
default: vi.fn().mockReturnValue(true),
}))
describe('InputCopy', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
describe('rendering', () => {
it('should render the value', () => {
render(<InputCopy value="test-api-key-12345" />)
expect(screen.getByText('test-api-key-12345')).toBeInTheDocument()
})
it('should render with empty value by default', () => {
render(<InputCopy />)
// Empty string should be rendered
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render children when provided', () => {
render(
<InputCopy value="key">
<span data-testid="custom-child">Custom Content</span>
</InputCopy>,
)
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
})
it('should render CopyFeedback component', () => {
render(<InputCopy value="test" />)
// CopyFeedback should render a button
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
})
describe('styling', () => {
it('should apply custom className', () => {
const { container } = render(<InputCopy value="test" className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('custom-class')
})
it('should have flex layout', () => {
const { container } = render(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('flex')
})
it('should have items-center alignment', () => {
const { container } = render(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('items-center')
})
it('should have rounded-lg class', () => {
const { container } = render(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('rounded-lg')
})
it('should have background class', () => {
const { container } = render(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-components-input-bg-normal')
})
it('should have hover state', () => {
const { container } = render(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('hover:bg-state-base-hover')
})
it('should have py-2 padding', () => {
const { container } = render(<InputCopy value="test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('py-2')
})
})
describe('copy functionality', () => {
it('should copy value when clicked', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="copy-this-value" />)
const copyableArea = screen.getByText('copy-this-value')
await act(async () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('copy-this-value')
})
it('should update copied state after clicking', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="test-value" />)
const copyableArea = screen.getByText('test-value')
await act(async () => {
await user.click(copyableArea)
})
// Copy function should have been called
expect(copy).toHaveBeenCalledWith('test-value')
})
it('should reset copied state after timeout', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="test-value" />)
const copyableArea = screen.getByText('test-value')
await act(async () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('test-value')
// Advance time to reset the copied state
await act(async () => {
vi.advanceTimersByTime(1500)
})
// Component should still be functional
expect(screen.getByText('test-value')).toBeInTheDocument()
})
it('should render tooltip on value', () => {
render(<InputCopy value="test-value" />)
// Value should be wrapped in tooltip (tooltip shows on hover, not as visible text)
const valueText = screen.getByText('test-value')
expect(valueText).toBeInTheDocument()
})
})
describe('tooltip', () => {
it('should render tooltip wrapper', () => {
render(<InputCopy value="test" />)
const valueText = screen.getByText('test')
expect(valueText).toBeInTheDocument()
})
it('should have cursor-pointer on clickable area', () => {
render(<InputCopy value="test" />)
const valueText = screen.getByText('test')
const clickableArea = valueText.closest('div[class*="cursor-pointer"]')
expect(clickableArea).toBeInTheDocument()
})
})
describe('divider', () => {
it('should render vertical divider', () => {
const { container } = render(<InputCopy value="test" />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider).toBeInTheDocument()
})
it('should have correct divider dimensions', () => {
const { container } = render(<InputCopy value="test" />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider?.className).toContain('h-4')
expect(divider?.className).toContain('w-px')
})
it('should have shrink-0 on divider', () => {
const { container } = render(<InputCopy value="test" />)
const divider = container.querySelector('.bg-divider-regular')
expect(divider?.className).toContain('shrink-0')
})
})
describe('value display', () => {
it('should have truncate class for long values', () => {
render(<InputCopy value="very-long-api-key-value-that-might-overflow" />)
const valueText = screen.getByText('very-long-api-key-value-that-might-overflow')
const container = valueText.closest('div[class*="truncate"]')
expect(container).toBeInTheDocument()
})
it('should have text-secondary color on value', () => {
render(<InputCopy value="test-value" />)
const valueText = screen.getByText('test-value')
expect(valueText.className).toContain('text-text-secondary')
})
it('should have absolute positioning for overlay', () => {
render(<InputCopy value="test" />)
const valueText = screen.getByText('test')
const container = valueText.closest('div[class*="absolute"]')
expect(container).toBeInTheDocument()
})
})
describe('inner container', () => {
it('should have grow class on inner container', () => {
const { container } = render(<InputCopy value="test" />)
const innerContainer = container.querySelector('.grow')
expect(innerContainer).toBeInTheDocument()
})
it('should have h-5 height on inner container', () => {
const { container } = render(<InputCopy value="test" />)
const innerContainer = container.querySelector('.h-5')
expect(innerContainer).toBeInTheDocument()
})
})
describe('with children', () => {
it('should render children before value', () => {
const { container } = render(
<InputCopy value="key">
<span data-testid="prefix">Prefix:</span>
</InputCopy>,
)
const children = container.querySelector('[data-testid="prefix"]')
expect(children).toBeInTheDocument()
})
it('should render both children and value', () => {
render(
<InputCopy value="api-key">
<span>Label:</span>
</InputCopy>,
)
expect(screen.getByText('Label:')).toBeInTheDocument()
expect(screen.getByText('api-key')).toBeInTheDocument()
})
})
describe('CopyFeedback section', () => {
it('should have margin on CopyFeedback container', () => {
const { container } = render(<InputCopy value="test" />)
const copyFeedbackContainer = container.querySelector('.mx-1')
expect(copyFeedbackContainer).toBeInTheDocument()
})
})
describe('relative container', () => {
it('should have relative positioning on value container', () => {
const { container } = render(<InputCopy value="test" />)
const relativeContainer = container.querySelector('.relative')
expect(relativeContainer).toBeInTheDocument()
})
it('should have grow on value container', () => {
const { container } = render(<InputCopy value="test" />)
// Find the relative container that also has grow
const valueContainer = container.querySelector('.relative.grow')
expect(valueContainer).toBeInTheDocument()
})
it('should have full height on value container', () => {
const { container } = render(<InputCopy value="test" />)
const valueContainer = container.querySelector('.relative.h-full')
expect(valueContainer).toBeInTheDocument()
})
})
describe('edge cases', () => {
it('should handle undefined value', () => {
render(<InputCopy value={undefined} />)
// Should not crash
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty string value', () => {
render(<InputCopy value="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long values', () => {
const longValue = 'a'.repeat(500)
render(<InputCopy value={longValue} />)
expect(screen.getByText(longValue)).toBeInTheDocument()
})
it('should handle special characters in value', () => {
const specialValue = 'key-with-special-chars!@#$%^&*()'
render(<InputCopy value={specialValue} />)
expect(screen.getByText(specialValue)).toBeInTheDocument()
})
})
describe('multiple clicks', () => {
it('should handle multiple rapid clicks', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<InputCopy value="test" />)
const copyableArea = screen.getByText('test')
// Click multiple times rapidly
await act(async () => {
await user.click(copyableArea)
await user.click(copyableArea)
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledTimes(3)
})
})
})

View File

@ -0,0 +1,297 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SecretKeyButton from './secret-key-button'
// Mock the SecretKeyModal since it has complex dependencies
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({ isShow, onClose, appId }: { isShow: boolean, onClose: () => void, appId?: string }) => (
isShow
? (
<div data-testid="secret-key-modal">
<span data-testid="modal-app-id">{`Modal for ${appId || 'no-app'}`}</span>
<button onClick={onClose} data-testid="close-modal">Close</button>
</div>
)
: null
),
}))
describe('SecretKeyButton', () => {
describe('rendering', () => {
it('should render the button', () => {
render(<SecretKeyButton />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the API key text', () => {
render(<SecretKeyButton />)
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
})
it('should render the key icon', () => {
const { container } = render(<SecretKeyButton />)
// RiKey2Line icon should be rendered as an svg
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should not show modal initially', () => {
render(<SecretKeyButton />)
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
describe('button interaction', () => {
it('should open modal when button is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
it('should close modal when onClose is called', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
// Open modal
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
})
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
it('should toggle modal visibility', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
const button = screen.getByRole('button')
// Open
await act(async () => {
await user.click(button)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
// Close
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
})
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
// Open again
await act(async () => {
await user.click(button)
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
describe('props', () => {
it('should apply custom className', () => {
render(<SecretKeyButton className="custom-class" />)
const button = screen.getByRole('button')
expect(button.className).toContain('custom-class')
})
it('should pass appId to modal', async () => {
const user = userEvent.setup()
render(<SecretKeyButton appId="app-123" />)
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
})
expect(screen.getByText('Modal for app-123')).toBeInTheDocument()
})
it('should handle undefined appId', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
})
expect(screen.getByText('Modal for no-app')).toBeInTheDocument()
})
it('should apply custom textCls', () => {
render(<SecretKeyButton textCls="custom-text-class" />)
const text = screen.getByText('appApi.apiKey')
expect(text.className).toContain('custom-text-class')
})
})
describe('button styling', () => {
it('should have px-3 padding', () => {
render(<SecretKeyButton />)
const button = screen.getByRole('button')
expect(button.className).toContain('px-3')
})
it('should have small size', () => {
render(<SecretKeyButton />)
const button = screen.getByRole('button')
expect(button.className).toContain('btn-small')
})
it('should have ghost variant', () => {
render(<SecretKeyButton />)
const button = screen.getByRole('button')
expect(button.className).toContain('btn-ghost')
})
})
describe('icon styling', () => {
it('should have icon container with flex layout', () => {
const { container } = render(<SecretKeyButton />)
const iconContainer = container.querySelector('.flex.items-center.justify-center')
expect(iconContainer).toBeInTheDocument()
})
it('should have correct icon dimensions', () => {
const { container } = render(<SecretKeyButton />)
const iconContainer = container.querySelector('.h-3\\.5.w-3\\.5')
expect(iconContainer).toBeInTheDocument()
})
it('should have tertiary text color on icon', () => {
const { container } = render(<SecretKeyButton />)
const icon = container.querySelector('.text-text-tertiary')
expect(icon).toBeInTheDocument()
})
})
describe('text styling', () => {
it('should have system-xs-medium class', () => {
render(<SecretKeyButton />)
const text = screen.getByText('appApi.apiKey')
expect(text.className).toContain('system-xs-medium')
})
it('should have horizontal padding', () => {
render(<SecretKeyButton />)
const text = screen.getByText('appApi.apiKey')
expect(text.className).toContain('px-[3px]')
})
it('should have tertiary text color', () => {
render(<SecretKeyButton />)
const text = screen.getByText('appApi.apiKey')
expect(text.className).toContain('text-text-tertiary')
})
})
describe('modal props', () => {
it('should pass isShow prop to modal', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
// Initially modal should not be visible
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
})
// Now modal should be visible
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
it('should pass onClose callback to modal', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
})
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
})
// Modal should be closed after clicking close
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
describe('accessibility', () => {
it('should have accessible button', () => {
render(<SecretKeyButton />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should be keyboard accessible', async () => {
const user = userEvent.setup()
render(<SecretKeyButton />)
const button = screen.getByRole('button')
button.focus()
expect(document.activeElement).toBe(button)
// Press Enter to activate
await act(async () => {
await user.keyboard('{Enter}')
})
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
})
describe('multiple instances', () => {
it('should work independently when multiple instances exist', async () => {
const user = userEvent.setup()
render(
<>
<SecretKeyButton appId="app-1" />
<SecretKeyButton appId="app-2" />
</>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
// Click first button
await act(async () => {
await user.click(buttons[0])
})
expect(screen.getByText('Modal for app-1')).toBeInTheDocument()
// Close first modal
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
})
// Click second button
await act(async () => {
await user.click(buttons[1])
})
expect(screen.getByText('Modal for app-2')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,302 @@
import type { CreateApiKeyResponse } from '@/models/app'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SecretKeyGenerateModal from './secret-key-generate'
// Helper to create a valid CreateApiKeyResponse
const createMockApiKey = (token: string): CreateApiKeyResponse => ({
id: 'mock-id',
token,
created_at: '2024-01-01T00:00:00Z',
})
describe('SecretKeyGenerateModal', () => {
const defaultProps = {
isShow: true,
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering when shown', () => {
it('should render the modal when isShow is true', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should render the generate tips text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
it('should render the OK button', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument()
})
it('should render the close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal, so query from document.body
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
it('should render InputCopy component', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />)
expect(screen.getByText('test-token-123')).toBeInTheDocument()
})
})
describe('rendering when hidden', () => {
it('should not render content when isShow is false', () => {
render(<SecretKeyGenerateModal {...defaultProps} isShow={false} />)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('newKey prop', () => {
it('should display the token when newKey is provided', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />)
expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument()
})
it('should handle undefined newKey', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />)
// Should not crash and modal should still render
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should handle newKey with empty token', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should display long tokens correctly', () => {
const longToken = `sk-${'a'.repeat(100)}`
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />)
expect(screen.getByText(longToken)).toBeInTheDocument()
})
})
describe('close functionality', () => {
it('should call onClose when X icon is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
await act(async () => {
await user.click(closeIcon!)
})
// HeadlessUI Dialog may trigger onClose multiple times (icon click handler + dialog close)
expect(onClose).toHaveBeenCalled()
})
it('should call onClose when OK button is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
const okButton = screen.getByRole('button', { name: /ok/i })
await act(async () => {
await user.click(okButton)
})
// HeadlessUI Dialog calls onClose both from button click and modal close
expect(onClose).toHaveBeenCalled()
})
})
describe('className prop', () => {
it('should apply custom className', () => {
render(
<SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />,
)
// Modal renders via portal
const modal = document.body.querySelector('.custom-modal-class')
expect(modal).toBeInTheDocument()
})
it('should apply shrink-0 class', () => {
render(
<SecretKeyGenerateModal {...defaultProps} className="shrink-0" />,
)
// Modal renders via portal
const modal = document.body.querySelector('.shrink-0')
expect(modal).toBeInTheDocument()
})
})
describe('modal styling', () => {
it('should have px-8 padding', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const modal = document.body.querySelector('.px-8')
expect(modal).toBeInTheDocument()
})
})
describe('close icon styling', () => {
it('should have cursor-pointer class on close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
it('should have correct dimensions on close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg[class*="h-6"][class*="w-6"]')
expect(closeIcon).toBeInTheDocument()
})
it('should have tertiary text color on close icon', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg[class*="text-text-tertiary"]')
expect(closeIcon).toBeInTheDocument()
})
})
describe('header section', () => {
it('should have flex justify-end on close container', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
expect(closeContainer?.className).toContain('flex')
expect(closeContainer?.className).toContain('justify-end')
})
it('should have negative margin on close container', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
expect(closeContainer?.className).toContain('-mr-2')
expect(closeContainer?.className).toContain('-mt-6')
})
it('should have bottom margin on close container', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
// Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
expect(closeContainer?.className).toContain('mb-4')
})
})
describe('tips text styling', () => {
it('should have mt-1 margin on tips', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('mt-1')
})
it('should have correct font size', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('text-[13px]')
})
it('should have normal font weight', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('font-normal')
})
it('should have leading-5 line height', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('leading-5')
})
it('should have tertiary text color', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('text-text-tertiary')
})
})
describe('InputCopy section', () => {
it('should render InputCopy with token value', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />)
expect(screen.getByText('test-token')).toBeInTheDocument()
})
it('should have w-full class on InputCopy', () => {
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />)
// The InputCopy component should have w-full
const inputText = screen.getByText('test')
const inputContainer = inputText.closest('.w-full')
expect(inputContainer).toBeInTheDocument()
})
})
describe('OK button section', () => {
it('should render OK button', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const button = screen.getByRole('button', { name: /ok/i })
expect(button).toBeInTheDocument()
})
it('should have button container with flex layout', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const button = screen.getByRole('button', { name: /ok/i })
const container = button.parentElement
expect(container).toBeInTheDocument()
expect(container?.className).toContain('flex')
})
it('should have shrink-0 on button', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const button = screen.getByRole('button', { name: /ok/i })
expect(button.className).toContain('shrink-0')
})
})
describe('button text styling', () => {
it('should have text-xs font size on button text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('text-xs')
})
it('should have font-medium on button text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('font-medium')
})
it('should have secondary text color on button text', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('text-text-secondary')
})
})
describe('default prop values', () => {
it('should default isShow to false', () => {
// When isShow is explicitly set to false
render(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('modal title', () => {
it('should display the correct title', () => {
render(<SecretKeyGenerateModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,614 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SecretKeyModal from './secret-key-modal'
// Mock the app context
const mockCurrentWorkspace = vi.fn().mockReturnValue({
id: 'workspace-1',
name: 'Test Workspace',
})
const mockIsCurrentWorkspaceManager = vi.fn().mockReturnValue(true)
const mockIsCurrentWorkspaceEditor = vi.fn().mockReturnValue(true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: mockCurrentWorkspace(),
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
}),
}))
// Mock the timestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`),
formatDate: vi.fn((value: string, _format: string) => `Formatted: ${value}`),
}),
}))
// Mock API services
const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' })
const mockDelAppApikey = vi.fn().mockResolvedValue({})
vi.mock('@/service/apps', () => ({
createApikey: (...args: unknown[]) => mockCreateAppApikey(...args),
delApikey: (...args: unknown[]) => mockDelAppApikey(...args),
}))
const mockCreateDatasetApikey = vi.fn().mockResolvedValue({ token: 'new-dataset-token-123' })
const mockDelDatasetApikey = vi.fn().mockResolvedValue({})
vi.mock('@/service/datasets', () => ({
createApikey: (...args: unknown[]) => mockCreateDatasetApikey(...args),
delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args),
}))
// Mock React Query hooks for apps
const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] })
const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false)
const mockInvalidateAppApiKeys = vi.fn()
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: (_appId: string, _options: unknown) => ({
data: mockAppApiKeysData(),
isLoading: mockIsAppApiKeysLoading(),
}),
useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys,
}))
// Mock React Query hooks for datasets
const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] })
const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false)
const mockInvalidateDatasetApiKeys = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: (_options: unknown) => ({
data: mockDatasetApiKeysData(),
isLoading: mockIsDatasetApiKeysLoading(),
}),
useInvalidateDatasetApiKeys: () => mockInvalidateDatasetApiKeys,
}))
describe('SecretKeyModal', () => {
const defaultProps = {
isShow: true,
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockAppApiKeysData.mockReturnValue({ data: [] })
mockIsAppApiKeysLoading.mockReturnValue(false)
mockDatasetApiKeysData.mockReturnValue({ data: [] })
mockIsDatasetApiKeysLoading.mockReturnValue(false)
})
describe('rendering when shown', () => {
it('should render the modal when isShow is true', () => {
render(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should render the tips text', () => {
render(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
})
it('should render the create new key button', () => {
render(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
})
it('should render the close icon', () => {
render(<SecretKeyModal {...defaultProps} />)
// Modal renders via portal, so we need to query from document.body
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
})
describe('rendering when hidden', () => {
it('should not render content when isShow is false', () => {
render(<SecretKeyModal {...defaultProps} isShow={false} />)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('loading state', () => {
it('should show loading when app API keys are loading', () => {
mockIsAppApiKeysLoading.mockReturnValue(true)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show loading when dataset API keys are loading', () => {
mockIsDatasetApiKeysLoading.mockReturnValue(true)
render(<SecretKeyModal {...defaultProps} />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should not show loading when data is loaded', () => {
mockIsAppApiKeysLoading.mockReturnValue(false)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
describe('API keys list for app', () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
{ id: 'key-2', token: 'sk-xyz987wvu654tsr321', created_at: 1700050000, last_used_at: null },
]
beforeEach(() => {
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
})
it('should render API keys when available', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789'
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
})
it('should render created time for keys', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument()
})
it('should render last used time for keys', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument()
})
it('should render "never" for keys without last_used_at', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('appApi.never')).toBeInTheDocument()
})
it('should render delete button for managers', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Delete button contains RiDeleteBinLine SVG - look for SVGs with h-4 w-4 class within buttons
const buttons = screen.getAllByRole('button')
// There should be at least 3 buttons: copy feedback, delete, and create
expect(buttons.length).toBeGreaterThanOrEqual(2)
// Check for delete icon SVG - Modal renders via portal
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
expect(deleteIcon).toBeInTheDocument()
})
it('should not render delete button for non-managers', () => {
mockIsCurrentWorkspaceManager.mockReturnValue(false)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// The specific delete action button should not be present
const actionButtons = screen.getAllByRole('button')
// Should only have copy and create buttons, not delete
expect(actionButtons.length).toBeGreaterThan(0)
})
it('should render table headers', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument()
})
})
describe('API keys list for dataset', () => {
const datasetKeys = [
{ id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
]
beforeEach(() => {
mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys })
})
it('should render dataset API keys when no appId', () => {
render(<SecretKeyModal {...defaultProps} />)
// Token 'dk-abc123def456ghi789' (21 chars) -> first 3 'dk-' + '...' + last 20 'k-abc123def456ghi789'
expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument()
})
})
describe('close functionality', () => {
it('should call onClose when X icon is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<SecretKeyModal {...defaultProps} onClose={onClose} />)
// Modal renders via portal, so we need to query from document.body
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
await act(async () => {
await user.click(closeIcon!)
})
expect(onClose).toHaveBeenCalledTimes(1)
})
})
describe('create new key', () => {
it('should call create API for app when button is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
})
await waitFor(() => {
expect(mockCreateAppApikey).toHaveBeenCalledWith({
url: '/apps/app-123/api-keys',
body: {},
})
})
})
it('should call create API for dataset when no appId', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
})
await waitFor(() => {
expect(mockCreateDatasetApikey).toHaveBeenCalledWith({
url: '/datasets/api-keys',
body: {},
})
})
})
it('should show generate modal after creating key', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
})
await waitFor(() => {
// The SecretKeyGenerateModal should be shown with the new token
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
})
it('should invalidate app API keys after creating', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
})
await waitFor(() => {
expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123')
})
})
it('should invalidate dataset API keys after creating (no appId)', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
})
await waitFor(() => {
expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled()
})
})
it('should disable create button when no workspace', () => {
mockCurrentWorkspace.mockReturnValue(null)
render(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
expect(createButton).toBeDisabled()
})
it('should disable create button when not editor', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<SecretKeyModal {...defaultProps} />)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
expect(createButton).toBeDisabled()
})
})
describe('delete key', () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
]
beforeEach(() => {
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
})
it('should render delete button for managers', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find buttons that contain SVG (delete/copy buttons)
const actionButtons = screen.getAllByRole('button')
// There should be at least copy, delete, and create buttons
expect(actionButtons.length).toBeGreaterThanOrEqual(3)
})
it('should render API key row with actions', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Verify the truncated token is rendered
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
})
it('should have action buttons in the key row', () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Check for action button containers - Modal renders via portal
const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]')
expect(actionContainers.length).toBeGreaterThan(0)
})
it('should have delete button visible for managers', async () => {
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find the delete button by looking for the button with the delete icon
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
const deleteButton = deleteIcon?.closest('button')
expect(deleteButton).toBeInTheDocument()
})
it('should show confirm dialog when delete button is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find delete button by action-btn class (second action button after copy)
const actionButtons = document.body.querySelectorAll('button.action-btn')
// The delete button is the second action button (first is copy)
const deleteButton = actionButtons[1]
expect(deleteButton).toBeInTheDocument()
await act(async () => {
await user.click(deleteButton!)
})
// Confirm dialog should appear
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument()
})
})
it('should call delete API for app when confirmed', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
// Find and click the confirm button
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
})
await waitFor(() => {
expect(mockDelAppApikey).toHaveBeenCalledWith({
url: '/apps/app-123/api-keys/key-1',
params: {},
})
})
})
it('should invalidate app API keys after deleting', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
})
await waitFor(() => {
expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123')
})
})
it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
})
// Wait for confirm dialog
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
// Click cancel button
const cancelButton = screen.getByText('common.operation.cancel')
await act(async () => {
await user.click(cancelButton)
})
// Confirm dialog should close
await waitFor(() => {
expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument()
})
// Delete API should not be called
expect(mockDelAppApikey).not.toHaveBeenCalled()
})
})
describe('delete key for dataset', () => {
const datasetKeys = [
{ id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
]
beforeEach(() => {
mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys })
})
it('should call delete API for dataset when no appId', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
})
await waitFor(() => {
expect(mockDelDatasetApikey).toHaveBeenCalledWith({
url: '/datasets/api-keys/dk-1',
params: {},
})
})
})
it('should invalidate dataset API keys after deleting', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} />)
// Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
})
// Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
})
await waitFor(() => {
expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled()
})
})
})
describe('token truncation', () => {
it('should truncate token correctly', () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
]
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Token format: first 3 chars + ... + last 20 chars
// 'sk-abcdefghijklmnopqrstuvwxyz1234567890' -> 'sk-...qrstuvwxyz1234567890'
expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument()
})
})
describe('styling', () => {
it('should render modal with expected structure', () => {
render(<SecretKeyModal {...defaultProps} />)
// Modal should render and contain the title
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
it('should render create button with flex styling', () => {
render(<SecretKeyModal {...defaultProps} />)
// Modal renders via portal, so query from document.body
const flexContainers = document.body.querySelectorAll('[class*="flex"]')
expect(flexContainers.length).toBeGreaterThan(0)
})
})
describe('empty state', () => {
it('should not render table when no keys', () => {
mockAppApiKeysData.mockReturnValue({ data: [] })
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
})
it('should not render table when data is null', () => {
mockAppApiKeysData.mockReturnValue(null)
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
})
})
describe('SecretKeyGenerateModal', () => {
it('should close generate modal on close', async () => {
const user = userEvent.setup()
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
// Create a new key to open generate modal
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
})
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
// Find and click the close/OK button in generate modal
const okButton = screen.getByText('appApi.actionMsg.ok')
await act(async () => {
await user.click(okButton)
})
await waitFor(() => {
expect(screen.queryByText('appApi.apiKeyModal.generateTips')).not.toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,242 @@
import { render, screen } from '@testing-library/react'
import { Tag } from './tag'
describe('Tag', () => {
describe('rendering', () => {
it('should render children text', () => {
render(<Tag>GET</Tag>)
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should render as a span element', () => {
render(<Tag>POST</Tag>)
const tag = screen.getByText('POST')
expect(tag.tagName).toBe('SPAN')
})
})
describe('default color mapping based on HTTP methods', () => {
it('should apply emerald color for GET method', () => {
render(<Tag>GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('text-emerald')
})
it('should apply sky color for POST method', () => {
render(<Tag>POST</Tag>)
const tag = screen.getByText('POST')
expect(tag.className).toContain('text-sky')
})
it('should apply amber color for PUT method', () => {
render(<Tag>PUT</Tag>)
const tag = screen.getByText('PUT')
expect(tag.className).toContain('text-amber')
})
it('should apply rose color for DELETE method', () => {
render(<Tag>DELETE</Tag>)
const tag = screen.getByText('DELETE')
expect(tag.className).toContain('text-red')
})
it('should apply emerald color for unknown methods', () => {
render(<Tag>UNKNOWN</Tag>)
const tag = screen.getByText('UNKNOWN')
expect(tag.className).toContain('text-emerald')
})
it('should handle lowercase method names', () => {
render(<Tag>get</Tag>)
const tag = screen.getByText('get')
expect(tag.className).toContain('text-emerald')
})
it('should handle mixed case method names', () => {
render(<Tag>Post</Tag>)
const tag = screen.getByText('Post')
expect(tag.className).toContain('text-sky')
})
})
describe('custom color prop', () => {
it('should override default color with custom emerald color', () => {
render(<Tag color="emerald">CUSTOM</Tag>)
const tag = screen.getByText('CUSTOM')
expect(tag.className).toContain('text-emerald')
})
it('should override default color with custom sky color', () => {
render(<Tag color="sky">CUSTOM</Tag>)
const tag = screen.getByText('CUSTOM')
expect(tag.className).toContain('text-sky')
})
it('should override default color with custom amber color', () => {
render(<Tag color="amber">CUSTOM</Tag>)
const tag = screen.getByText('CUSTOM')
expect(tag.className).toContain('text-amber')
})
it('should override default color with custom rose color', () => {
render(<Tag color="rose">CUSTOM</Tag>)
const tag = screen.getByText('CUSTOM')
expect(tag.className).toContain('text-red')
})
it('should override default color with custom zinc color', () => {
render(<Tag color="zinc">CUSTOM</Tag>)
const tag = screen.getByText('CUSTOM')
expect(tag.className).toContain('text-zinc')
})
it('should override automatic color mapping with explicit color', () => {
render(<Tag color="sky">GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('text-sky')
})
})
describe('variant styles', () => {
it('should apply medium variant styles by default', () => {
render(<Tag>GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('rounded-lg')
expect(tag.className).toContain('px-1.5')
expect(tag.className).toContain('ring-1')
expect(tag.className).toContain('ring-inset')
})
it('should apply small variant styles', () => {
render(<Tag variant="small">GET</Tag>)
const tag = screen.getByText('GET')
// Small variant should not have ring styles
expect(tag.className).not.toContain('rounded-lg')
expect(tag.className).not.toContain('ring-1')
})
})
describe('base styles', () => {
it('should always have font-mono class', () => {
render(<Tag>GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('font-mono')
})
it('should always have correct font-size class', () => {
render(<Tag>GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('text-[0.625rem]')
})
it('should always have font-semibold class', () => {
render(<Tag>GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('font-semibold')
})
it('should always have leading-6 class', () => {
render(<Tag>GET</Tag>)
const tag = screen.getByText('GET')
expect(tag.className).toContain('leading-6')
})
})
describe('color styles for medium variant', () => {
it('should apply full emerald medium styles', () => {
render(<Tag color="emerald" variant="medium">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('ring-emerald-300')
expect(tag.className).toContain('bg-emerald-400/10')
expect(tag.className).toContain('text-emerald-500')
})
it('should apply full sky medium styles', () => {
render(<Tag color="sky" variant="medium">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('ring-sky-300')
expect(tag.className).toContain('bg-sky-400/10')
expect(tag.className).toContain('text-sky-500')
})
it('should apply full amber medium styles', () => {
render(<Tag color="amber" variant="medium">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('ring-amber-300')
expect(tag.className).toContain('bg-amber-400/10')
expect(tag.className).toContain('text-amber-500')
})
it('should apply full rose medium styles', () => {
render(<Tag color="rose" variant="medium">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('ring-rose-200')
expect(tag.className).toContain('bg-rose-50')
expect(tag.className).toContain('text-red-500')
})
it('should apply full zinc medium styles', () => {
render(<Tag color="zinc" variant="medium">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('ring-zinc-200')
expect(tag.className).toContain('bg-zinc-50')
expect(tag.className).toContain('text-zinc-500')
})
})
describe('color styles for small variant', () => {
it('should apply emerald small styles', () => {
render(<Tag color="emerald" variant="small">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('text-emerald-500')
// Small variant should not have background/ring styles
expect(tag.className).not.toContain('bg-emerald-400/10')
expect(tag.className).not.toContain('ring-emerald-300')
})
it('should apply sky small styles', () => {
render(<Tag color="sky" variant="small">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('text-sky-500')
})
it('should apply amber small styles', () => {
render(<Tag color="amber" variant="small">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('text-amber-500')
})
it('should apply rose small styles', () => {
render(<Tag color="rose" variant="small">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('text-red-500')
})
it('should apply zinc small styles', () => {
render(<Tag color="zinc" variant="small">TEST</Tag>)
const tag = screen.getByText('TEST')
expect(tag.className).toContain('text-zinc-400')
})
})
describe('HTTP method color combinations', () => {
it('should correctly map PATCH to emerald (default)', () => {
render(<Tag>PATCH</Tag>)
const tag = screen.getByText('PATCH')
// PATCH is not in the valueColorMap, so it defaults to emerald
expect(tag.className).toContain('text-emerald')
})
it('should correctly render all standard HTTP methods', () => {
const methods = ['GET', 'POST', 'PUT', 'DELETE']
const expectedColors = ['emerald', 'sky', 'amber', 'red']
methods.forEach((method, index) => {
const { unmount } = render(<Tag>{method}</Tag>)
const tag = screen.getByText(method)
expect(tag.className).toContain(`text-${expectedColors[index]}`)
unmount()
})
})
})
})

View File

@ -0,0 +1,483 @@
import type { Banner } from '@/models/app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BannerItem } from './banner-item'
const mockScrollTo = vi.fn()
const mockSlideNodes = vi.fn()
vi.mock('@/app/components/base/carousel', () => ({
useCarousel: () => ({
api: {
scrollTo: mockScrollTo,
slideNodes: mockSlideNodes,
},
selectedIndex: 0,
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'banner.viewMore': 'View More',
}
return translations[key] || key
},
}),
}))
const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
id: 'banner-1',
status: 'enabled',
link: 'https://example.com',
content: {
'category': 'Featured',
'title': 'Test Banner Title',
'description': 'Test banner description text',
'img-src': 'https://example.com/image.png',
},
...overrides,
} as Banner)
// Mock ResizeObserver methods declared at module level and initialized
const mockResizeObserverObserve = vi.fn()
const mockResizeObserverDisconnect = vi.fn()
// Create mock class outside of describe block for proper hoisting
class MockResizeObserver {
constructor(_callback: ResizeObserverCallback) {
// Store callback if needed
}
observe(...args: Parameters<ResizeObserver['observe']>) {
mockResizeObserverObserve(...args)
}
disconnect() {
mockResizeObserverDisconnect()
}
unobserve() {
// No-op
}
}
describe('BannerItem', () => {
let mockWindowOpen: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
mockSlideNodes.mockReturnValue([{}, {}, {}]) // 3 slides
vi.stubGlobal('ResizeObserver', MockResizeObserver)
// Mock window.innerWidth for responsive tests
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1400, // Above RESPONSIVE_BREAKPOINT (1200)
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
mockWindowOpen.mockRestore()
})
describe('basic rendering', () => {
it('renders banner category', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Featured')).toBeInTheDocument()
})
it('renders banner title', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('renders banner description', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Test banner description text')).toBeInTheDocument()
})
it('renders banner image with correct src and alt', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/image.png')
expect(image).toHaveAttribute('alt', 'Test Banner Title')
})
it('renders view more text', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('View More')).toBeInTheDocument()
})
})
describe('click handling', () => {
it('opens banner link in new tab when clicked', () => {
const banner = createMockBanner({ link: 'https://test-link.com' })
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://test-link.com',
'_blank',
'noopener,noreferrer',
)
})
it('does not open window when banner has no link', () => {
const banner = createMockBanner({ link: '' })
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockWindowOpen).not.toHaveBeenCalled()
})
})
describe('slide indicators', () => {
it('renders correct number of indicator buttons', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
})
it('renders indicator buttons with correct numbers', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('01')).toBeInTheDocument()
expect(screen.getByText('02')).toBeInTheDocument()
expect(screen.getByText('03')).toBeInTheDocument()
})
it('calls scrollTo when indicator is clicked', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const secondIndicator = screen.getByText('02').closest('button')
fireEvent.click(secondIndicator!)
expect(mockScrollTo).toHaveBeenCalledWith(1)
})
it('renders no indicators when no slides', () => {
mockSlideNodes.mockReturnValue([])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('isPaused prop', () => {
it('defaults isPaused to false', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// Component should render without issues
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('accepts isPaused prop', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
isPaused={true}
/>,
)
// Component should render with isPaused
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
})
describe('responsive behavior', () => {
it('sets up ResizeObserver on mount', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(mockResizeObserverObserve).toHaveBeenCalled()
})
it('adds resize event listener on mount', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
addEventListenerSpy.mockRestore()
})
it('removes resize event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const banner = createMockBanner()
const { unmount } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
removeEventListenerSpy.mockRestore()
})
it('sets maxWidth when window width is below breakpoint', () => {
// Set window width below RESPONSIVE_BREAKPOINT (1200)
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1000,
})
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// Component should render and apply responsive styles
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('applies responsive styles when below breakpoint', () => {
// Set window width below RESPONSIVE_BREAKPOINT (1200)
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 800,
})
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// The component should render even with responsive mode
expect(screen.getByText('View More')).toBeInTheDocument()
})
})
describe('content variations', () => {
it('renders long category text', () => {
const banner = createMockBanner({
content: {
'category': 'Very Long Category Name',
'title': 'Title',
'description': 'Description',
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Very Long Category Name')).toBeInTheDocument()
})
it('renders long title with truncation class', () => {
const banner = createMockBanner({
content: {
'category': 'Category',
'title': 'A Very Long Title That Should Be Truncated Eventually',
'description': 'Description',
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually')
expect(titleElement).toHaveClass('line-clamp-2')
})
it('renders long description with truncation class', () => {
const banner = createMockBanner({
content: {
'category': 'Category',
'title': 'Title',
'description': 'A very long description that should be limited to a certain number of lines for proper display in the banner component.',
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const descriptionElement = screen.getByText(/A very long description/)
expect(descriptionElement).toHaveClass('line-clamp-4')
})
})
describe('slide calculation', () => {
it('calculates next index correctly for first slide', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// With selectedIndex=0 and 3 slides, nextIndex should be 1
// The second indicator button should show the "next slide" state
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
})
it('handles single slide case', () => {
mockSlideNodes.mockReturnValue([{}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
})
})
describe('wrapper styling', () => {
it('has cursor-pointer class', () => {
const banner = createMockBanner()
const { container } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('cursor-pointer')
})
it('has rounded-2xl class', () => {
const banner = createMockBanner()
const { container } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('rounded-2xl')
})
})
})

View File

@ -0,0 +1,472 @@
import type * as React from 'react'
import type { Banner as BannerType } from '@/models/app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Banner from './banner'
const mockUseGetBanners = vi.fn()
vi.mock('@/service/use-explore', () => ({
useGetBanners: (...args: unknown[]) => mockUseGetBanners(...args),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/base/carousel', () => ({
Carousel: Object.assign(
({ children, onMouseEnter, onMouseLeave, className }: {
children: React.ReactNode
onMouseEnter?: () => void
onMouseLeave?: () => void
className?: string
}) => (
<div
data-testid="carousel"
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</div>
),
{
Content: ({ children }: { children: React.ReactNode }) => (
<div data-testid="carousel-content">{children}</div>
),
Item: ({ children }: { children: React.ReactNode }) => (
<div data-testid="carousel-item">{children}</div>
),
Plugin: {
Autoplay: (config: Record<string, unknown>) => ({ type: 'autoplay', ...config }),
},
},
),
useCarousel: () => ({
api: {
scrollTo: vi.fn(),
slideNodes: () => [],
},
selectedIndex: 0,
}),
}))
vi.mock('./banner-item', () => ({
BannerItem: ({ banner, autoplayDelay, isPaused }: {
banner: BannerType
autoplayDelay: number
isPaused?: boolean
}) => (
<div
data-testid="banner-item"
data-banner-id={banner.id}
data-autoplay-delay={autoplayDelay}
data-is-paused={isPaused}
>
BannerItem:
{' '}
{banner.content.title}
</div>
),
}))
const createMockBanner = (id: string, status: string = 'enabled', title: string = 'Test Banner'): BannerType => ({
id,
status,
link: 'https://example.com',
content: {
'category': 'Featured',
title,
'description': 'Test description',
'img-src': 'https://example.com/image.png',
},
} as BannerType)
describe('Banner', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.useRealTimers()
})
describe('loading state', () => {
it('renders loading state when isLoading is true', () => {
mockUseGetBanners.mockReturnValue({
data: null,
isLoading: true,
isError: false,
})
render(<Banner />)
// Loading component renders a spinner
const loadingWrapper = document.querySelector('[style*="min-height"]')
expect(loadingWrapper).toBeInTheDocument()
})
it('shows loading indicator with correct minimum height', () => {
mockUseGetBanners.mockReturnValue({
data: null,
isLoading: true,
isError: false,
})
render(<Banner />)
const loadingWrapper = document.querySelector('[style*="min-height: 168px"]')
expect(loadingWrapper).toBeInTheDocument()
})
})
describe('error state', () => {
it('returns null when isError is true', () => {
mockUseGetBanners.mockReturnValue({
data: null,
isLoading: false,
isError: true,
})
const { container } = render(<Banner />)
expect(container.firstChild).toBeNull()
})
})
describe('empty state', () => {
it('returns null when banners array is empty', () => {
mockUseGetBanners.mockReturnValue({
data: [],
isLoading: false,
isError: false,
})
const { container } = render(<Banner />)
expect(container.firstChild).toBeNull()
})
it('returns null when all banners are disabled', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'disabled'),
createMockBanner('2', 'disabled'),
],
isLoading: false,
isError: false,
})
const { container } = render(<Banner />)
expect(container.firstChild).toBeNull()
})
it('returns null when data is undefined', () => {
mockUseGetBanners.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
})
const { container } = render(<Banner />)
expect(container.firstChild).toBeNull()
})
})
describe('successful render', () => {
it('renders carousel when enabled banners exist', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(screen.getByTestId('carousel')).toBeInTheDocument()
})
it('renders only enabled banners', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'enabled', 'Enabled Banner 1'),
createMockBanner('2', 'disabled', 'Disabled Banner'),
createMockBanner('3', 'enabled', 'Enabled Banner 2'),
],
isLoading: false,
isError: false,
})
render(<Banner />)
const bannerItems = screen.getAllByTestId('banner-item')
expect(bannerItems).toHaveLength(2)
expect(screen.getByText('BannerItem: Enabled Banner 1')).toBeInTheDocument()
expect(screen.getByText('BannerItem: Enabled Banner 2')).toBeInTheDocument()
expect(screen.queryByText('BannerItem: Disabled Banner')).not.toBeInTheDocument()
})
it('passes correct autoplayDelay to BannerItem', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-autoplay-delay', '5000')
})
it('renders carousel with correct class', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(screen.getByTestId('carousel')).toHaveClass('rounded-2xl')
})
})
describe('hover behavior', () => {
it('sets isPaused to true on mouse enter', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
const carousel = screen.getByTestId('carousel')
fireEvent.mouseEnter(carousel)
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
})
it('sets isPaused to false on mouse leave', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
const carousel = screen.getByTestId('carousel')
// Enter and then leave
fireEvent.mouseEnter(carousel)
fireEvent.mouseLeave(carousel)
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'false')
})
})
describe('resize behavior', () => {
it('pauses animation during resize', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
// Trigger resize event
act(() => {
window.dispatchEvent(new Event('resize'))
})
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
})
it('resumes animation after resize debounce delay', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
// Trigger resize event
act(() => {
window.dispatchEvent(new Event('resize'))
})
// Wait for debounce delay (50ms)
act(() => {
vi.advanceTimersByTime(50)
})
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'false')
})
it('resets debounce timer on multiple resize events', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
// Trigger first resize event
act(() => {
window.dispatchEvent(new Event('resize'))
})
// Wait partial time
act(() => {
vi.advanceTimersByTime(30)
})
// Trigger second resize event
act(() => {
window.dispatchEvent(new Event('resize'))
})
// Wait another 30ms (total 60ms from second resize but only 30ms after)
act(() => {
vi.advanceTimersByTime(30)
})
// Should still be paused (debounce resets)
let bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
// Wait remaining time
act(() => {
vi.advanceTimersByTime(20)
})
bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'false')
})
})
describe('cleanup', () => {
it('removes resize event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
const { unmount } = render(<Banner />)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
removeEventListenerSpy.mockRestore()
})
it('clears resize timer on unmount', () => {
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
const { unmount } = render(<Banner />)
// Trigger resize to create timer
act(() => {
window.dispatchEvent(new Event('resize'))
})
unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
clearTimeoutSpy.mockRestore()
})
})
describe('hook calls', () => {
it('calls useGetBanners with correct locale', () => {
mockUseGetBanners.mockReturnValue({
data: [],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(mockUseGetBanners).toHaveBeenCalledWith('en-US')
})
})
describe('multiple banners', () => {
it('renders all enabled banners in carousel items', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'enabled', 'Banner 1'),
createMockBanner('2', 'enabled', 'Banner 2'),
createMockBanner('3', 'enabled', 'Banner 3'),
],
isLoading: false,
isError: false,
})
render(<Banner />)
const carouselItems = screen.getAllByTestId('carousel-item')
expect(carouselItems).toHaveLength(3)
})
it('preserves banner order', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'enabled', 'First Banner'),
createMockBanner('2', 'enabled', 'Second Banner'),
createMockBanner('3', 'enabled', 'Third Banner'),
],
isLoading: false,
isError: false,
})
render(<Banner />)
const bannerItems = screen.getAllByTestId('banner-item')
expect(bannerItems[0]).toHaveAttribute('data-banner-id', '1')
expect(bannerItems[1]).toHaveAttribute('data-banner-id', '2')
expect(bannerItems[2]).toHaveAttribute('data-banner-id', '3')
})
})
describe('React.memo behavior', () => {
it('renders as memoized component', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
const { rerender } = render(<Banner />)
// Re-render with same props
rerender(<Banner />)
// Component should still be present (memo doesn't break rendering)
expect(screen.getByTestId('carousel')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,448 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndicatorButton } from './indicator-button'
describe('IndicatorButton', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.useRealTimers()
})
describe('basic rendering', () => {
it('renders button with correct index number', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText('01')).toBeInTheDocument()
})
it('renders two-digit index numbers', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={9}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('pads single digit index numbers with leading zero', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={4}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(screen.getByText('05')).toBeInTheDocument()
})
})
describe('active state', () => {
it('applies active styles when index equals selectedIndex', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={2}
selectedIndex={2}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-text-primary')
})
it('applies inactive styles when index does not equal selectedIndex', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-components-panel-on-panel-item-bg')
})
})
describe('click handling', () => {
it('calls onClick when button is clicked', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('stops event propagation when clicked', () => {
const mockOnClick = vi.fn()
const mockParentClick = vi.fn()
render(
<div onClick={mockParentClick}>
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>
</div>,
)
fireEvent.click(screen.getByRole('button'))
expect(mockOnClick).toHaveBeenCalledTimes(1)
expect(mockParentClick).not.toHaveBeenCalled()
})
})
describe('progress indicator', () => {
it('does not show progress indicator when not next slide', () => {
const mockOnClick = vi.fn()
const { container } = render(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Check for conic-gradient style which indicates progress indicator
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
expect(progressIndicator).not.toBeInTheDocument()
})
it('shows progress indicator when isNextSlide is true and not active', () => {
const mockOnClick = vi.fn()
const { container } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
expect(progressIndicator).toBeInTheDocument()
})
it('does not show progress indicator when isNextSlide but also active', () => {
const mockOnClick = vi.fn()
const { container } = render(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
expect(progressIndicator).not.toBeInTheDocument()
})
})
describe('animation behavior', () => {
it('starts progress from 0 when isNextSlide becomes true', () => {
const mockOnClick = vi.fn()
const { container, rerender } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Initially no progress indicator
expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument()
// Rerender with isNextSlide=true
rerender(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Now progress indicator should be visible
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
})
it('resets progress when resetKey changes', () => {
const mockOnClick = vi.fn()
const { rerender, container } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Progress indicator should be present
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
expect(progressIndicator).toBeInTheDocument()
// Rerender with new resetKey - this should reset the progress animation
rerender(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={1}
isPaused={false}
onClick={mockOnClick}
/>,
)
const newProgressIndicator = container.querySelector('[style*="conic-gradient"]')
// The progress indicator should still be present after reset
expect(newProgressIndicator).toBeInTheDocument()
})
it('stops animation when isPaused is true', () => {
const mockOnClick = vi.fn()
const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame')
render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={true}
onClick={mockOnClick}
/>,
)
// The component should still render but animation should be paused
// requestAnimationFrame might still be called for polling but progress won't update
expect(screen.getByRole('button')).toBeInTheDocument()
mockRequestAnimationFrame.mockRestore()
})
it('cancels animation frame on unmount', () => {
const mockOnClick = vi.fn()
const mockCancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame')
const { unmount } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Trigger animation frame
act(() => {
vi.advanceTimersToNextTimer()
})
unmount()
expect(mockCancelAnimationFrame).toHaveBeenCalled()
mockCancelAnimationFrame.mockRestore()
})
it('cancels animation frame when isNextSlide becomes false', () => {
const mockOnClick = vi.fn()
const mockCancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame')
const { rerender } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Trigger animation frame
act(() => {
vi.advanceTimersToNextTimer()
})
// Change isNextSlide to false - this should cancel the animation frame
rerender(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(mockCancelAnimationFrame).toHaveBeenCalled()
mockCancelAnimationFrame.mockRestore()
})
it('continues polling when document is hidden', () => {
const mockOnClick = vi.fn()
const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame')
// Mock document.hidden to be true
Object.defineProperty(document, 'hidden', {
writable: true,
configurable: true,
value: true,
})
render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
// Component should still render
expect(screen.getByRole('button')).toBeInTheDocument()
// Reset document.hidden
Object.defineProperty(document, 'hidden', {
writable: true,
configurable: true,
value: false,
})
mockRequestAnimationFrame.mockRestore()
})
})
describe('isPaused prop default', () => {
it('defaults isPaused to false when not provided', () => {
const mockOnClick = vi.fn()
const { container } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
onClick={mockOnClick}
/>,
)
// Progress indicator should be visible (animation running)
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
})
})
describe('button styling', () => {
it('has correct base classes', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={0}
selectedIndex={1}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
const button = screen.getByRole('button')
expect(button).toHaveClass('relative')
expect(button).toHaveClass('flex')
expect(button).toHaveClass('items-center')
expect(button).toHaveClass('justify-center')
expect(button).toHaveClass('rounded-[7px]')
expect(button).toHaveClass('border')
expect(button).toHaveClass('transition-colors')
})
})
})

View File

@ -0,0 +1,395 @@
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AppInfo from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'types.advanced': 'Advanced',
'types.chatbot': 'Chatbot',
'types.agent': 'Agent',
'types.workflow': 'Workflow',
'types.completion': 'Completion',
'tryApp.createFromSampleApp': 'Create from Sample',
'tryApp.category': 'Category',
'tryApp.requirements': 'Requirements',
}
return translations[key] || key
},
}),
}))
const mockUseGetRequirements = vi.fn()
vi.mock('./use-get-requirements', () => ({
default: (...args: unknown[]) => mockUseGetRequirements(...args),
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
id: 'test-app-id',
name: 'Test App Name',
description: 'Test App Description',
mode,
site: {
title: 'Test Site Title',
icon: '🚀',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
},
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: {
datasets: {
datasets: [],
},
},
agent_mode: {
tools: [],
},
user_input_form: [],
},
...overrides,
} as unknown as TryAppInfo)
describe('AppInfo', () => {
beforeEach(() => {
mockUseGetRequirements.mockReturnValue({
requirements: [],
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('app name and icon', () => {
it('renders app name', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('Test App Name')).toBeInTheDocument()
})
it('renders app name with title attribute', () => {
const appDetail = createMockAppDetail('chat', {
name: 'Very Long App Name That Should Be Truncated',
} as Partial<TryAppInfo>)
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
const nameElement = screen.getByText('Very Long App Name That Should Be Truncated')
expect(nameElement).toHaveAttribute('title', 'Very Long App Name That Should Be Truncated')
})
})
describe('app type', () => {
it('displays ADVANCED for advanced-chat mode', () => {
const appDetail = createMockAppDetail('advanced-chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('ADVANCED')).toBeInTheDocument()
})
it('displays CHATBOT for chat mode', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('CHATBOT')).toBeInTheDocument()
})
it('displays AGENT for agent-chat mode', () => {
const appDetail = createMockAppDetail('agent-chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('AGENT')).toBeInTheDocument()
})
it('displays WORKFLOW for workflow mode', () => {
const appDetail = createMockAppDetail('workflow')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('WORKFLOW')).toBeInTheDocument()
})
it('displays COMPLETION for completion mode', () => {
const appDetail = createMockAppDetail('completion')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('COMPLETION')).toBeInTheDocument()
})
})
describe('description', () => {
it('renders description when provided', () => {
const appDetail = createMockAppDetail('chat', {
description: 'This is a test description',
} as Partial<TryAppInfo>)
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('does not render description when empty', () => {
const appDetail = createMockAppDetail('chat', {
description: '',
} as Partial<TryAppInfo>)
const mockOnCreate = vi.fn()
const { container } = render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
// Check that there's no element with the description class that has empty content
const descriptionElements = container.querySelectorAll('.system-sm-regular.mt-\\[14px\\]')
expect(descriptionElements.length).toBe(0)
})
})
describe('create button', () => {
it('renders create button with correct text', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('Create from Sample')).toBeInTheDocument()
})
it('calls onCreate when button is clicked', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
fireEvent.click(screen.getByText('Create from Sample'))
expect(mockOnCreate).toHaveBeenCalledTimes(1)
})
})
describe('category', () => {
it('renders category when provided', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
category="AI Assistant"
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('Category')).toBeInTheDocument()
expect(screen.getByText('AI Assistant')).toBeInTheDocument()
})
it('does not render category section when not provided', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.queryByText('Category')).not.toBeInTheDocument()
})
})
describe('requirements', () => {
it('renders requirements when available', () => {
mockUseGetRequirements.mockReturnValue({
requirements: [
{ name: 'OpenAI GPT-4', iconUrl: 'https://example.com/icon1.png' },
{ name: 'Google Search', iconUrl: 'https://example.com/icon2.png' },
],
})
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.getByText('Requirements')).toBeInTheDocument()
expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument()
expect(screen.getByText('Google Search')).toBeInTheDocument()
})
it('does not render requirements section when empty', () => {
mockUseGetRequirements.mockReturnValue({
requirements: [],
})
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(screen.queryByText('Requirements')).not.toBeInTheDocument()
})
it('renders requirement icons with correct background image', () => {
mockUseGetRequirements.mockReturnValue({
requirements: [
{ name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' },
],
})
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
const { container } = render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({ backgroundImage: 'url(https://example.com/test-icon.png)' })
})
})
describe('className prop', () => {
it('applies custom className', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
const { container } = render(
<AppInfo
appId="test-app-id"
appDetail={appDetail}
className="custom-class"
onCreate={mockOnCreate}
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('hook calls', () => {
it('calls useGetRequirements with correct parameters', () => {
const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn()
render(
<AppInfo
appId="my-app-id"
appDetail={appDetail}
onCreate={mockOnCreate}
/>,
)
expect(mockUseGetRequirements).toHaveBeenCalledWith({
appDetail,
appId: 'my-app-id',
})
})
})
})

View File

@ -0,0 +1,425 @@
import type { TryAppInfo } from '@/service/try-app'
import { renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import useGetRequirements from './use-get-requirements'
const mockUseGetTryAppFlowPreview = vi.fn()
vi.mock('@/service/use-try-app', () => ({
useGetTryAppFlowPreview: (...args: unknown[]) => mockUseGetTryAppFlowPreview(...args),
}))
vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.api',
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test Description',
mode,
site: {
title: 'Test Site Title',
icon: 'icon',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
},
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: {
datasets: {
datasets: [],
},
},
agent_mode: {
tools: [],
},
user_input_form: [],
},
...overrides,
} as unknown as TryAppInfo)
describe('useGetRequirements', () => {
afterEach(() => {
vi.clearAllMocks()
})
describe('basic app modes (chat, completion, agent-chat)', () => {
it('returns model provider for chat mode', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('chat')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(1)
expect(result.current.requirements[0].name).toBe('openai')
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/langgenius/openai/icon')
})
it('returns model provider for completion mode', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('completion', {
model_config: {
model: {
provider: 'anthropic/claude/claude',
name: 'claude-3',
mode: 'completion',
},
dataset_configs: { datasets: { datasets: [] } },
agent_mode: { tools: [] },
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(1)
expect(result.current.requirements[0].name).toBe('claude')
})
it('returns model provider and tools for agent-chat mode', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('agent-chat', {
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: { datasets: { datasets: [] } },
agent_mode: {
tools: [
{
enabled: true,
provider_id: 'langgenius/google_search/google_search',
tool_label: 'Google Search',
},
{
enabled: true,
provider_id: 'langgenius/web_scraper/web_scraper',
tool_label: 'Web Scraper',
},
{
enabled: false,
provider_id: 'langgenius/disabled_tool/disabled_tool',
tool_label: 'Disabled Tool',
},
],
},
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(3)
expect(result.current.requirements.map(r => r.name)).toContain('openai')
expect(result.current.requirements.map(r => r.name)).toContain('Google Search')
expect(result.current.requirements.map(r => r.name)).toContain('Web Scraper')
expect(result.current.requirements.map(r => r.name)).not.toContain('Disabled Tool')
})
it('filters out disabled tools in agent mode', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('agent-chat', {
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: { datasets: { datasets: [] } },
agent_mode: {
tools: [
{
enabled: false,
provider_id: 'langgenius/tool1/tool1',
tool_label: 'Tool 1',
},
{
enabled: false,
provider_id: 'langgenius/tool2/tool2',
tool_label: 'Tool 2',
},
],
},
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
// Only model provider should be included, no disabled tools
expect(result.current.requirements).toHaveLength(1)
expect(result.current.requirements[0].name).toBe('openai')
})
})
describe('advanced app modes (workflow, advanced-chat)', () => {
it('returns requirements from flow data for workflow mode', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [
{
data: {
type: 'llm',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
},
},
},
{
data: {
type: 'tool',
provider_id: 'langgenius/google/google',
tool_label: 'Google Tool',
},
},
],
},
},
})
const appDetail = createMockAppDetail('workflow')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(2)
expect(result.current.requirements.map(r => r.name)).toContain('gpt-4')
expect(result.current.requirements.map(r => r.name)).toContain('Google Tool')
})
it('returns requirements from flow data for advanced-chat mode', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [
{
data: {
type: 'llm',
model: {
provider: 'anthropic/claude/claude',
name: 'claude-3-opus',
},
},
},
],
},
},
})
const appDetail = createMockAppDetail('advanced-chat')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(1)
expect(result.current.requirements[0].name).toBe('claude-3-opus')
})
it('returns empty requirements when flow data has no nodes', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [],
},
},
})
const appDetail = createMockAppDetail('workflow')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(0)
})
it('returns empty requirements when flow data is null', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: null,
})
const appDetail = createMockAppDetail('workflow')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(0)
})
it('extracts multiple LLM nodes from flow data', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [
{
data: {
type: 'llm',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
},
},
},
{
data: {
type: 'llm',
model: {
provider: 'anthropic/claude/claude',
name: 'claude-3',
},
},
},
],
},
},
})
const appDetail = createMockAppDetail('workflow')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(2)
expect(result.current.requirements.map(r => r.name)).toContain('gpt-4')
expect(result.current.requirements.map(r => r.name)).toContain('claude-3')
})
it('extracts multiple tool nodes from flow data', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [
{
data: {
type: 'tool',
provider_id: 'langgenius/tool1/tool1',
tool_label: 'Tool 1',
},
},
{
data: {
type: 'tool',
provider_id: 'langgenius/tool2/tool2',
tool_label: 'Tool 2',
},
},
],
},
},
})
const appDetail = createMockAppDetail('workflow')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(2)
expect(result.current.requirements.map(r => r.name)).toContain('Tool 1')
expect(result.current.requirements.map(r => r.name)).toContain('Tool 2')
})
})
describe('deduplication', () => {
it('removes duplicate requirements by name', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [
{
data: {
type: 'llm',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
},
},
},
{
data: {
type: 'llm',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
},
},
},
],
},
},
})
const appDetail = createMockAppDetail('workflow')
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements).toHaveLength(1)
expect(result.current.requirements[0].name).toBe('gpt-4')
})
})
describe('icon URL generation', () => {
it('generates correct icon URL for model providers', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('chat', {
model_config: {
model: {
provider: 'org/plugin/model',
name: 'model-name',
mode: 'chat',
},
dataset_configs: { datasets: { datasets: [] } },
agent_mode: { tools: [] },
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
const { result } = renderHook(() =>
useGetRequirements({ appDetail, appId: 'test-app-id' }),
)
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/org/plugin/icon')
})
})
describe('hook calls', () => {
it('calls useGetTryAppFlowPreview with correct parameters for basic apps', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('chat')
renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
expect(mockUseGetTryAppFlowPreview).toHaveBeenCalledWith('test-app-id', true)
})
it('calls useGetTryAppFlowPreview with correct parameters for advanced apps', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
const appDetail = createMockAppDetail('workflow')
renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
expect(mockUseGetTryAppFlowPreview).toHaveBeenCalledWith('test-app-id', false)
})
})
})

View File

@ -0,0 +1,357 @@
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import TryApp from './chat'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'chat.resetChat': 'Reset Chat',
'tryApp.tryInfo': 'This is try mode info',
}
return translations[key] || key
},
}),
}))
const mockRemoveConversationIdInfo = vi.fn()
const mockHandleNewConversation = vi.fn()
const mockUseEmbeddedChatbot = vi.fn()
vi.mock('@/app/components/base/chat/embedded-chatbot/hooks', () => ({
useEmbeddedChatbot: (...args: unknown[]) => mockUseEmbeddedChatbot(...args),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'pc',
MediaType: {
mobile: 'mobile',
pc: 'pc',
},
}))
vi.mock('../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
useThemeContext: () => ({
primaryColor: '#1890ff',
}),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/chat-wrapper', () => ({
default: () => <div data-testid="chat-wrapper">ChatWrapper</div>,
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
default: () => <div data-testid="view-form-dropdown">ViewFormDropdown</div>,
}))
const createMockAppDetail = (overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
id: 'test-app-id',
name: 'Test Chat App',
description: 'Test Description',
mode: 'chat',
site: {
title: 'Test Site Title',
icon: '💬',
icon_type: 'emoji',
icon_background: '#4F46E5',
icon_url: '',
},
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: {
datasets: {
datasets: [],
},
},
agent_mode: {
tools: [],
},
user_input_form: [],
},
...overrides,
} as unknown as TryAppInfo)
describe('TryApp (chat.tsx)', () => {
beforeEach(() => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: null,
inputsForms: [],
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('basic rendering', () => {
it('renders app name', () => {
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(screen.getByText('Test Chat App')).toBeInTheDocument()
})
it('renders app name with title attribute', () => {
const appDetail = createMockAppDetail({ name: 'Long App Name' } as Partial<TryAppInfo>)
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
const nameElement = screen.getByText('Long App Name')
expect(nameElement).toHaveAttribute('title', 'Long App Name')
})
it('renders ChatWrapper', () => {
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(screen.getByTestId('chat-wrapper')).toBeInTheDocument()
})
it('renders alert with try info', () => {
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(screen.getByText('This is try mode info')).toBeInTheDocument()
})
it('applies className prop', () => {
const appDetail = createMockAppDetail()
const { container } = render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="custom-class"
/>,
)
// The component wraps with EmbeddedChatbotContext.Provider, first child is the div with className
const innerDiv = container.querySelector('.custom-class')
expect(innerDiv).toBeInTheDocument()
})
})
describe('reset button', () => {
it('does not render reset button when no conversation', () => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: null,
inputsForms: [],
})
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
// Reset button should not be present
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders reset button when conversation exists', () => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: 'conv-123',
inputsForms: [],
})
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
// Should have a button (the reset button)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('calls handleNewConversation when reset button is clicked', () => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: 'conv-123',
inputsForms: [],
})
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(mockRemoveConversationIdInfo).toHaveBeenCalledWith('test-app-id')
expect(mockHandleNewConversation).toHaveBeenCalled()
})
})
describe('view form dropdown', () => {
it('does not render view form dropdown when no conversation', () => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: null,
inputsForms: [{ id: 'form1' }],
})
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
it('does not render view form dropdown when no input forms', () => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: 'conv-123',
inputsForms: [],
})
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
it('renders view form dropdown when conversation and input forms exist', () => {
mockUseEmbeddedChatbot.mockReturnValue({
removeConversationIdInfo: mockRemoveConversationIdInfo,
handleNewConversation: mockHandleNewConversation,
currentConversationId: 'conv-123',
inputsForms: [{ id: 'form1' }],
})
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument()
})
})
describe('alert hiding', () => {
it('hides alert when onHide is called', () => {
const appDetail = createMockAppDetail()
render(
<TryApp
appId="test-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
// Find and click the hide button on the alert
const alertElement = screen.getByText('This is try mode info').closest('[class*="alert"]')?.parentElement
const hideButton = alertElement?.querySelector('button, [role="button"], svg')
if (hideButton) {
fireEvent.click(hideButton)
// After hiding, the alert should not be visible
expect(screen.queryByText('This is try mode info')).not.toBeInTheDocument()
}
})
})
describe('hook calls', () => {
it('calls useEmbeddedChatbot with correct parameters', () => {
const appDetail = createMockAppDetail()
render(
<TryApp
appId="my-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(mockUseEmbeddedChatbot).toHaveBeenCalledWith('tryApp', 'my-app-id')
})
it('calls removeConversationIdInfo on mount', () => {
const appDetail = createMockAppDetail()
render(
<TryApp
appId="my-app-id"
appDetail={appDetail}
className="test-class"
/>,
)
expect(mockRemoveConversationIdInfo).toHaveBeenCalledWith('my-app-id')
})
})
})

View File

@ -0,0 +1,188 @@
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import TryApp from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('./chat', () => ({
default: ({ appId, appDetail, className }: { appId: string, appDetail: TryAppInfo, className: string }) => (
<div data-testid="chat-component" data-app-id={appId} data-mode={appDetail.mode} className={className}>
Chat Component
</div>
),
}))
vi.mock('./text-generation', () => ({
default: ({
appId,
className,
isWorkflow,
appData,
}: { appId: string, className: string, isWorkflow: boolean, appData: { mode: string } }) => (
<div
data-testid="text-generation-component"
data-app-id={appId}
data-is-workflow={isWorkflow}
data-mode={appData?.mode}
className={className}
>
TextGeneration Component
</div>
),
}))
const createMockAppDetail = (mode: string): TryAppInfo => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test Description',
mode,
site: {
title: 'Test Site Title',
icon: 'icon',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
},
model_config: {
model: {
provider: 'test/provider',
name: 'test-model',
mode: 'chat',
},
dataset_configs: {
datasets: {
datasets: [],
},
},
agent_mode: {
tools: [],
},
user_input_form: [],
},
} as unknown as TryAppInfo)
describe('TryApp (app/index.tsx)', () => {
afterEach(() => {
cleanup()
})
describe('chat mode rendering', () => {
it('renders Chat component for chat mode', () => {
const appDetail = createMockAppDetail('chat')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-component')).not.toBeInTheDocument()
})
it('renders Chat component for advanced-chat mode', () => {
const appDetail = createMockAppDetail('advanced-chat')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-component')).not.toBeInTheDocument()
})
it('renders Chat component for agent-chat mode', () => {
const appDetail = createMockAppDetail('agent-chat')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-component')).not.toBeInTheDocument()
})
it('passes correct props to Chat component', () => {
const appDetail = createMockAppDetail('chat')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
const chatComponent = screen.getByTestId('chat-component')
expect(chatComponent).toHaveAttribute('data-app-id', 'test-app-id')
expect(chatComponent).toHaveAttribute('data-mode', 'chat')
expect(chatComponent).toHaveClass('h-full', 'grow')
})
})
describe('completion mode rendering', () => {
it('renders TextGeneration component for completion mode', () => {
const appDetail = createMockAppDetail('completion')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('text-generation-component')).toBeInTheDocument()
expect(screen.queryByTestId('chat-component')).not.toBeInTheDocument()
})
it('renders TextGeneration component for workflow mode', () => {
const appDetail = createMockAppDetail('workflow')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('text-generation-component')).toBeInTheDocument()
expect(screen.queryByTestId('chat-component')).not.toBeInTheDocument()
})
it('passes isWorkflow=true for workflow mode', () => {
const appDetail = createMockAppDetail('workflow')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
const textGenComponent = screen.getByTestId('text-generation-component')
expect(textGenComponent).toHaveAttribute('data-is-workflow', 'true')
})
it('passes isWorkflow=false for completion mode', () => {
const appDetail = createMockAppDetail('completion')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
const textGenComponent = screen.getByTestId('text-generation-component')
expect(textGenComponent).toHaveAttribute('data-is-workflow', 'false')
})
it('passes correct props to TextGeneration component', () => {
const appDetail = createMockAppDetail('completion')
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
const textGenComponent = screen.getByTestId('text-generation-component')
expect(textGenComponent).toHaveAttribute('data-app-id', 'test-app-id')
expect(textGenComponent).toHaveClass('h-full', 'grow')
})
})
describe('document title', () => {
it('calls useDocumentTitle with site title', async () => {
const useDocumentTitle = (await import('@/hooks/use-document-title')).default
const appDetail = createMockAppDetail('chat')
appDetail.site.title = 'My App Title'
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(useDocumentTitle).toHaveBeenCalledWith('My App Title')
})
it('calls useDocumentTitle with empty string when site.title is undefined', async () => {
const useDocumentTitle = (await import('@/hooks/use-document-title')).default
const appDetail = createMockAppDetail('chat')
appDetail.site = undefined as unknown as TryAppInfo['site']
render(<TryApp appId="test-app-id" appDetail={appDetail} />)
expect(useDocumentTitle).toHaveBeenCalledWith('')
})
})
describe('wrapper styling', () => {
it('renders with correct wrapper classes', () => {
const appDetail = createMockAppDetail('chat')
const { container } = render(<TryApp appId="test-app-id" appDetail={appDetail} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'h-full', 'w-full')
})
})
})

View File

@ -0,0 +1,468 @@
import type { AppData } from '@/models/share'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import TextGeneration from './text-generation'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'tryApp.tryInfo': 'This is a try app notice',
}
return translations[key] || key
},
}),
}))
const mockUpdateAppInfo = vi.fn()
const mockUpdateAppParams = vi.fn()
const mockAppParams = {
user_input_form: [],
more_like_this: { enabled: false },
file_upload: null,
text_to_speech: { enabled: false },
system_parameters: {},
}
let mockStoreAppParams: typeof mockAppParams | null = mockAppParams
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: unknown) => unknown) => {
const state = {
updateAppInfo: mockUpdateAppInfo,
updateAppParams: mockUpdateAppParams,
appParams: mockStoreAppParams,
}
return selector(state)
},
}))
const mockUseGetTryAppParams = vi.fn()
vi.mock('@/service/use-try-app', () => ({
useGetTryAppParams: (...args: unknown[]) => mockUseGetTryAppParams(...args),
}))
let mockMediaType = 'pc'
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => mockMediaType,
MediaType: {
mobile: 'mobile',
pc: 'pc',
},
}))
vi.mock('@/app/components/share/text-generation/run-once', () => ({
default: ({
siteInfo,
onSend,
onInputsChange,
}: { siteInfo: { title: string }, onSend: () => void, onInputsChange: (inputs: Record<string, unknown>) => void }) => (
<div data-testid="run-once">
<span data-testid="site-title">{siteInfo?.title}</span>
<button data-testid="send-button" onClick={onSend}>Send</button>
<button data-testid="inputs-change-button" onClick={() => onInputsChange({ testInput: 'testValue' })}>Change Inputs</button>
</div>
),
}))
vi.mock('@/app/components/share/text-generation/result', () => ({
default: ({
isWorkflow,
appId,
onCompleted,
onRunStart,
}: { isWorkflow: boolean, appId: string, onCompleted: () => void, onRunStart: () => void }) => (
<div data-testid="result-component" data-is-workflow={isWorkflow} data-app-id={appId}>
<button data-testid="complete-button" onClick={onCompleted}>Complete</button>
<button data-testid="run-start-button" onClick={onRunStart}>Run Start</button>
</div>
),
}))
const createMockAppData = (overrides: Partial<AppData> = {}): AppData => ({
app_id: 'test-app-id',
site: {
title: 'Test App Title',
description: 'Test App Description',
icon: '🚀',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
default_language: 'en',
prompt_public: true,
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
},
custom_config: {
remove_webapp_brand: false,
},
...overrides,
} as AppData)
describe('TextGeneration', () => {
beforeEach(() => {
mockStoreAppParams = mockAppParams
mockMediaType = 'pc'
mockUseGetTryAppParams.mockReturnValue({
data: mockAppParams,
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('loading state', () => {
it('renders loading when appData is null', () => {
render(
<TextGeneration
appId="test-app-id"
appData={null}
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders loading when appParams is not available', () => {
mockStoreAppParams = null
mockUseGetTryAppParams.mockReturnValue({
data: null,
})
render(
<TextGeneration
appId="test-app-id"
appData={createMockAppData()}
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('content rendering', () => {
it('renders app title', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
// Multiple elements may have the title (header and RunOnce mock)
const titles = screen.getAllByText('Test App Title')
expect(titles.length).toBeGreaterThan(0)
})
})
it('renders app description when available', async () => {
const appData = createMockAppData({
site: {
title: 'Test App',
description: 'This is a description',
icon: '🚀',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
default_language: 'en',
prompt_public: true,
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
},
} as unknown as Partial<AppData>)
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByText('This is a description')).toBeInTheDocument()
})
})
it('renders RunOnce component', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('run-once')).toBeInTheDocument()
})
})
it('renders Result component', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('result-component')).toBeInTheDocument()
})
})
})
describe('workflow mode', () => {
it('passes isWorkflow=true to Result when isWorkflow prop is true', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
isWorkflow
/>,
)
await waitFor(() => {
const resultComponent = screen.getByTestId('result-component')
expect(resultComponent).toHaveAttribute('data-is-workflow', 'true')
})
})
it('passes isWorkflow=false to Result when isWorkflow prop is false', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
isWorkflow={false}
/>,
)
await waitFor(() => {
const resultComponent = screen.getByTestId('result-component')
expect(resultComponent).toHaveAttribute('data-is-workflow', 'false')
})
})
})
describe('send functionality', () => {
it('triggers send when RunOnce sends', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('send-button'))
// The send should work without errors
expect(screen.getByTestId('result-component')).toBeInTheDocument()
})
})
describe('completion handling', () => {
it('shows alert after completion', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('complete-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('complete-button'))
await waitFor(() => {
expect(screen.getByText('This is a try app notice')).toBeInTheDocument()
})
})
})
describe('className prop', () => {
it('applies custom className', async () => {
const appData = createMockAppData()
const { container } = render(
<TextGeneration
appId="test-app-id"
appData={appData}
className="custom-class"
/>,
)
await waitFor(() => {
const element = container.querySelector('.custom-class')
expect(element).toBeInTheDocument()
})
})
})
describe('hook effects', () => {
it('calls updateAppInfo when appData changes', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(appData)
})
})
it('calls updateAppParams when tryAppParams changes', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
})
})
it('calls useGetTryAppParams with correct appId', () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="my-app-id"
appData={appData}
/>,
)
expect(mockUseGetTryAppParams).toHaveBeenCalledWith('my-app-id')
})
})
describe('result panel visibility', () => {
it('shows result panel after run starts', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('run-start-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('run-start-button'))
// Result panel should remain visible
expect(screen.getByTestId('result-component')).toBeInTheDocument()
})
})
describe('input handling', () => {
it('handles input changes from RunOnce', async () => {
const appData = createMockAppData()
render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('inputs-change-button')).toBeInTheDocument()
})
// Trigger input change which should call setInputs callback
fireEvent.click(screen.getByTestId('inputs-change-button'))
// The component should handle the input change without errors
expect(screen.getByTestId('run-once')).toBeInTheDocument()
})
})
describe('mobile behavior', () => {
it('renders mobile toggle panel on mobile', async () => {
mockMediaType = 'mobile'
const appData = createMockAppData()
const { container } = render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
// Mobile toggle panel should be rendered
const togglePanel = container.querySelector('.cursor-grab')
expect(togglePanel).toBeInTheDocument()
})
})
it('toggles result panel visibility on mobile', async () => {
mockMediaType = 'mobile'
const appData = createMockAppData()
const { container } = render(
<TextGeneration
appId="test-app-id"
appData={appData}
/>,
)
await waitFor(() => {
const togglePanel = container.querySelector('.cursor-grab')
expect(togglePanel).toBeInTheDocument()
})
// Click to show result panel
const toggleParent = container.querySelector('.cursor-grab')?.parentElement
if (toggleParent) {
fireEvent.click(toggleParent)
}
// Click again to hide result panel
await waitFor(() => {
const newToggleParent = container.querySelector('.cursor-grab')?.parentElement
if (newToggleParent) {
fireEvent.click(newToggleParent)
}
})
// Component should handle both show and hide without errors
expect(screen.getByTestId('result-component')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,411 @@
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import TryApp from './index'
import { TypeEnum } from './tab'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'tryApp.tabHeader.try': 'Try',
'tryApp.tabHeader.detail': 'Detail',
}
return translations[key] || key
},
}),
}))
const mockUseGetTryAppInfo = vi.fn()
vi.mock('@/service/use-try-app', () => ({
useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args),
}))
vi.mock('./app', () => ({
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
<div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}>
App Component
</div>
),
}))
vi.mock('./preview', () => ({
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
<div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}>
Preview Component
</div>
),
}))
vi.mock('./app-info', () => ({
default: ({
appId,
appDetail,
category,
className,
onCreate,
}: { appId: string, appDetail: TryAppInfo, category?: string, className?: string, onCreate: () => void }) => (
<div
data-testid="app-info-component"
data-app-id={appId}
data-category={category}
className={className}
>
<button data-testid="create-button" onClick={onCreate}>Create</button>
App Info:
{' '}
{appDetail?.name}
</div>
),
}))
const createMockAppDetail = (mode: string = 'chat'): TryAppInfo => ({
id: 'test-app-id',
name: 'Test App Name',
description: 'Test Description',
mode,
site: {
title: 'Test Site Title',
icon: '🚀',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
},
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
dataset_configs: {
datasets: {
datasets: [],
},
},
agent_mode: {
tools: [],
},
user_input_form: [],
},
} as unknown as TryAppInfo)
describe('TryApp (main index.tsx)', () => {
beforeEach(() => {
mockUseGetTryAppInfo.mockReturnValue({
data: createMockAppDetail(),
isLoading: false,
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('loading state', () => {
it('renders loading when isLoading is true', () => {
mockUseGetTryAppInfo.mockReturnValue({
data: null,
isLoading: true,
})
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
})
})
describe('content rendering', () => {
it('renders Tab component', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
expect(screen.getByText('Try')).toBeInTheDocument()
expect(screen.getByText('Detail')).toBeInTheDocument()
})
})
it('renders App component by default (TRY mode)', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument()
expect(document.body.querySelector('[data-testid="preview-component"]')).not.toBeInTheDocument()
})
})
it('renders AppInfo component', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
expect(document.body.querySelector('[data-testid="app-info-component"]')).toBeInTheDocument()
})
})
it('renders close button', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
// Find the close button (the one with RiCloseLine icon)
const buttons = document.body.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
})
})
})
describe('tab switching', () => {
it('switches to Preview when Detail tab is clicked', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
expect(screen.getByText('Detail')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Detail'))
await waitFor(() => {
expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument()
expect(document.body.querySelector('[data-testid="app-component"]')).not.toBeInTheDocument()
})
})
it('switches back to App when Try tab is clicked', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
expect(screen.getByText('Detail')).toBeInTheDocument()
})
// First switch to Detail
fireEvent.click(screen.getByText('Detail'))
await waitFor(() => {
expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument()
})
// Then switch back to Try
fireEvent.click(screen.getByText('Try'))
await waitFor(() => {
expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument()
})
})
})
describe('close functionality', () => {
it('calls onClose when close button is clicked', async () => {
const mockOnClose = vi.fn()
render(
<TryApp
appId="test-app-id"
onClose={mockOnClose}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
// Find the button with close icon
const buttons = document.body.querySelectorAll('button')
const closeButton = Array.from(buttons).find(btn =>
btn.querySelector('svg') || btn.className.includes('rounded-[10px]'),
)
expect(closeButton).toBeInTheDocument()
if (closeButton)
fireEvent.click(closeButton)
})
expect(mockOnClose).toHaveBeenCalled()
})
})
describe('create functionality', () => {
it('calls onCreate when create button in AppInfo is clicked', async () => {
const mockOnCreate = vi.fn()
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={mockOnCreate}
/>,
)
await waitFor(() => {
const createButton = document.body.querySelector('[data-testid="create-button"]')
expect(createButton).toBeInTheDocument()
if (createButton)
fireEvent.click(createButton)
})
expect(mockOnCreate).toHaveBeenCalledTimes(1)
})
})
describe('category prop', () => {
it('passes category to AppInfo when provided', async () => {
render(
<TryApp
appId="test-app-id"
category="AI Assistant"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
const appInfo = document.body.querySelector('[data-testid="app-info-component"]')
expect(appInfo).toHaveAttribute('data-category', 'AI Assistant')
})
})
it('does not pass category to AppInfo when not provided', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
const appInfo = document.body.querySelector('[data-testid="app-info-component"]')
expect(appInfo).not.toHaveAttribute('data-category', expect.any(String))
})
})
})
describe('hook calls', () => {
it('calls useGetTryAppInfo with correct appId', () => {
render(
<TryApp
appId="my-specific-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
expect(mockUseGetTryAppInfo).toHaveBeenCalledWith('my-specific-app-id')
})
})
describe('props passing', () => {
it('passes appId to App component', async () => {
render(
<TryApp
appId="my-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
const appComponent = document.body.querySelector('[data-testid="app-component"]')
expect(appComponent).toHaveAttribute('data-app-id', 'my-app-id')
})
})
it('passes appId to Preview component when in Detail mode', async () => {
render(
<TryApp
appId="my-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
expect(screen.getByText('Detail')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Detail'))
await waitFor(() => {
const previewComponent = document.body.querySelector('[data-testid="preview-component"]')
expect(previewComponent).toHaveAttribute('data-app-id', 'my-app-id')
})
})
it('passes appId to AppInfo component', async () => {
render(
<TryApp
appId="my-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
const appInfoComponent = document.body.querySelector('[data-testid="app-info-component"]')
expect(appInfoComponent).toHaveAttribute('data-app-id', 'my-app-id')
})
})
it('passes appDetail to AppInfo component', async () => {
render(
<TryApp
appId="test-app-id"
onClose={vi.fn()}
onCreate={vi.fn()}
/>,
)
await waitFor(() => {
const appInfoComponent = document.body.querySelector('[data-testid="app-info-component"]')
expect(appInfoComponent?.textContent).toContain('Test App Name')
})
})
})
describe('TypeEnum export', () => {
it('exports TypeEnum correctly', () => {
expect(TypeEnum.TRY).toBe('try')
expect(TypeEnum.DETAIL).toBe('detail')
})
})
})

View File

@ -0,0 +1,527 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BasicAppPreview from './basic-app-preview'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockUseGetTryAppInfo = vi.fn()
const mockUseAllToolProviders = vi.fn()
const mockUseGetTryAppDataSets = vi.fn()
const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.fn()
vi.mock('@/service/use-try-app', () => ({
useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args),
useGetTryAppDataSets: (...args: unknown[]) => mockUseGetTryAppDataSets(...args),
}))
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => mockUseAllToolProviders(),
}))
vi.mock('../../../header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) =>
mockUseTextGenerationCurrentProviderAndModelAndModelList(...args),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'pc',
MediaType: {
mobile: 'mobile',
pc: 'pc',
},
}))
vi.mock('@/app/components/app/configuration/config', () => ({
default: () => <div data-testid="config-component">Config</div>,
}))
vi.mock('@/app/components/app/configuration/debug', () => ({
default: () => <div data-testid="debug-component">Debug</div>,
}))
vi.mock('@/app/components/base/features', () => ({
FeaturesProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="features-provider">{children}</div>
),
}))
const createMockAppDetail = (mode: string = 'chat'): Record<string, unknown> => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test Description',
mode,
site: {
title: 'Test Site Title',
icon: '🚀',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
},
model_config: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
},
pre_prompt: 'You are a helpful assistant',
user_input_form: [] as unknown[],
external_data_tools: [] as unknown[],
dataset_configs: {
datasets: {
datasets: [] as unknown[],
},
},
agent_mode: {
tools: [] as unknown[],
enabled: false,
},
more_like_this: { enabled: false },
opening_statement: 'Hello!',
suggested_questions: ['Question 1'],
sensitive_word_avoidance: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null as unknown,
suggested_questions_after_answer: null,
retriever_resource: null,
annotation_reply: null,
},
deleted_tools: [] as unknown[],
})
describe('BasicAppPreview', () => {
beforeEach(() => {
mockUseGetTryAppInfo.mockReturnValue({
data: createMockAppDetail(),
isLoading: false,
})
mockUseAllToolProviders.mockReturnValue({
data: [],
isLoading: false,
})
mockUseGetTryAppDataSets.mockReturnValue({
data: { data: [] },
isLoading: false,
})
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [],
},
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('loading state', () => {
it('renders loading when app detail is loading', () => {
mockUseGetTryAppInfo.mockReturnValue({
data: null,
isLoading: true,
})
render(<BasicAppPreview appId="test-app-id" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders loading when tool providers are loading', () => {
mockUseAllToolProviders.mockReturnValue({
data: null,
isLoading: true,
})
render(<BasicAppPreview appId="test-app-id" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders loading when datasets are loading', () => {
mockUseGetTryAppDataSets.mockReturnValue({
data: null,
isLoading: true,
})
render(<BasicAppPreview appId="test-app-id" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})
describe('content rendering', () => {
it('renders Config component when data is loaded', async () => {
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
it('renders Debug component when data is loaded on PC', async () => {
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('debug-component')).toBeInTheDocument()
})
})
it('renders FeaturesProvider', async () => {
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('features-provider')).toBeInTheDocument()
})
})
})
describe('different app modes', () => {
it('handles chat mode', async () => {
mockUseGetTryAppInfo.mockReturnValue({
data: createMockAppDetail('chat'),
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
it('handles completion mode', async () => {
mockUseGetTryAppInfo.mockReturnValue({
data: createMockAppDetail('completion'),
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
it('handles agent-chat mode', async () => {
const agentAppDetail = createMockAppDetail('agent-chat')
const modelConfig = agentAppDetail.model_config as Record<string, unknown>
modelConfig.agent_mode = {
tools: [
{
provider_id: 'test-provider',
provider_name: 'test-provider',
provider_type: 'builtin',
tool_name: 'test-tool',
enabled: true,
},
],
enabled: true,
max_iteration: 5,
}
mockUseGetTryAppInfo.mockReturnValue({
data: agentAppDetail,
isLoading: false,
})
mockUseAllToolProviders.mockReturnValue({
data: [
{
id: 'test-provider',
is_team_authorization: true,
icon: '/icon.png',
},
],
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
})
describe('hook calls', () => {
it('calls useGetTryAppInfo with correct appId', () => {
render(<BasicAppPreview appId="my-app-id" />)
expect(mockUseGetTryAppInfo).toHaveBeenCalledWith('my-app-id')
})
it('calls useTextGenerationCurrentProviderAndModelAndModelList with model config', async () => {
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(mockUseTextGenerationCurrentProviderAndModelAndModelList).toHaveBeenCalled()
})
})
})
describe('model features', () => {
it('handles vision feature', async () => {
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: ['vision'],
},
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
it('handles document feature', async () => {
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: ['document'],
},
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
it('handles audio feature', async () => {
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: ['audio'],
},
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
it('handles video feature', async () => {
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: ['video'],
},
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
})
describe('dataset handling', () => {
it('handles app with datasets in agent mode', async () => {
const appWithDatasets = createMockAppDetail('agent-chat')
const modelConfig = appWithDatasets.model_config as Record<string, unknown>
modelConfig.agent_mode = {
tools: [
{
dataset: {
enabled: true,
id: 'dataset-1',
},
},
],
enabled: true,
}
mockUseGetTryAppInfo.mockReturnValue({
data: appWithDatasets,
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(mockUseGetTryAppDataSets).toHaveBeenCalled()
})
})
it('handles app with datasets in dataset_configs', async () => {
const appWithDatasets = createMockAppDetail('chat')
const modelConfig = appWithDatasets.model_config as Record<string, unknown>
modelConfig.dataset_configs = {
datasets: {
datasets: [
{ dataset: { id: 'dataset-1' } },
{ dataset: { id: 'dataset-2' } },
],
},
}
mockUseGetTryAppInfo.mockReturnValue({
data: appWithDatasets,
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(mockUseGetTryAppDataSets).toHaveBeenCalled()
})
})
})
describe('advanced prompt mode', () => {
it('handles advanced prompt mode', async () => {
const appWithAdvancedPrompt = createMockAppDetail('chat')
const modelConfig = appWithAdvancedPrompt.model_config as Record<string, unknown>
modelConfig.prompt_type = 'advanced'
modelConfig.chat_prompt_config = {
prompt: [{ role: 'system', text: 'You are helpful' }],
}
mockUseGetTryAppInfo.mockReturnValue({
data: appWithAdvancedPrompt,
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
})
describe('file upload config', () => {
it('handles file upload config', async () => {
const appWithFileUpload = createMockAppDetail('chat')
const modelConfig = appWithFileUpload.model_config as Record<string, unknown>
modelConfig.file_upload = {
enabled: true,
image: {
enabled: true,
detail: 'high',
number_limits: 5,
transfer_methods: ['local_file', 'remote_url'],
},
allowed_file_types: ['image'],
allowed_file_extensions: ['.jpg', '.png'],
allowed_file_upload_methods: ['local_file'],
number_limits: 3,
}
mockUseGetTryAppInfo.mockReturnValue({
data: appWithFileUpload,
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
})
describe('external data tools', () => {
it('handles app with external_data_tools', async () => {
const appWithExternalTools = createMockAppDetail('chat')
const modelConfig = appWithExternalTools.model_config as Record<string, unknown>
modelConfig.external_data_tools = [
{
variable: 'test_var',
label: 'Test Label',
enabled: true,
type: 'text',
config: {},
icon: '/icon.png',
icon_background: '#FFFFFF',
},
]
mockUseGetTryAppInfo.mockReturnValue({
data: appWithExternalTools,
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
})
describe('deleted tools handling', () => {
it('handles app with deleted tools', async () => {
const agentAppDetail = createMockAppDetail('agent-chat')
const modelConfig = agentAppDetail.model_config as Record<string, unknown>
modelConfig.agent_mode = {
tools: [
{
id: 'tool-1',
provider_id: 'test-provider',
provider_name: 'test-provider',
provider_type: 'builtin',
tool_name: 'test-tool',
enabled: true,
},
],
enabled: true,
max_iteration: 5,
}
agentAppDetail.deleted_tools = [
{
id: 'tool-1',
tool_name: 'test-tool',
},
]
mockUseGetTryAppInfo.mockReturnValue({
data: agentAppDetail,
isLoading: false,
})
mockUseAllToolProviders.mockReturnValue({
data: [
{
id: 'test-provider',
is_team_authorization: false,
icon: '/icon.png',
},
],
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
await waitFor(() => {
expect(screen.getByTestId('config-component')).toBeInTheDocument()
})
})
})
describe('edge cases', () => {
it('handles app without model_config', async () => {
const appWithoutModelConfig = createMockAppDetail('chat')
appWithoutModelConfig.model_config = undefined
mockUseGetTryAppInfo.mockReturnValue({
data: appWithoutModelConfig,
isLoading: false,
})
render(<BasicAppPreview appId="test-app-id" />)
// Should still render (with default model config)
await waitFor(() => {
expect(mockUseGetTryAppDataSets).toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,179 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import FlowAppPreview from './flow-app-preview'
const mockUseGetTryAppFlowPreview = vi.fn()
vi.mock('@/service/use-try-app', () => ({
useGetTryAppFlowPreview: (...args: unknown[]) => mockUseGetTryAppFlowPreview(...args),
}))
vi.mock('@/app/components/workflow/workflow-preview', () => ({
default: ({
className,
miniMapToRight,
nodes,
edges,
}: { className?: string, miniMapToRight?: boolean, nodes?: unknown[], edges?: unknown[] }) => (
<div
data-testid="workflow-preview"
className={className}
data-mini-map-to-right={miniMapToRight}
data-nodes-count={nodes?.length}
data-edges-count={edges?.length}
>
WorkflowPreview
</div>
),
}))
describe('FlowAppPreview', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('loading state', () => {
it('renders Loading component when isLoading is true', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: null,
isLoading: true,
})
render(<FlowAppPreview appId="test-app-id" />)
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
})
})
describe('no data state', () => {
it('returns null when data is null', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: null,
isLoading: false,
})
const { container } = render(<FlowAppPreview appId="test-app-id" />)
expect(container.firstChild).toBeNull()
})
it('returns null when data is undefined', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: undefined,
isLoading: false,
})
const { container } = render(<FlowAppPreview appId="test-app-id" />)
expect(container.firstChild).toBeNull()
})
})
describe('data loaded state', () => {
it('renders WorkflowPreview when data is loaded', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [{ id: 'node1' }],
edges: [{ id: 'edge1' }],
},
},
isLoading: false,
})
render(<FlowAppPreview appId="test-app-id" />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('passes graph data to WorkflowPreview', () => {
const mockNodes = [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }]
const mockEdges = [{ id: 'edge1' }, { id: 'edge2' }]
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: mockNodes,
edges: mockEdges,
},
},
isLoading: false,
})
render(<FlowAppPreview appId="test-app-id" />)
const workflowPreview = screen.getByTestId('workflow-preview')
expect(workflowPreview).toHaveAttribute('data-nodes-count', '3')
expect(workflowPreview).toHaveAttribute('data-edges-count', '2')
})
it('passes miniMapToRight=true to WorkflowPreview', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [],
edges: [],
},
},
isLoading: false,
})
render(<FlowAppPreview appId="test-app-id" />)
const workflowPreview = screen.getByTestId('workflow-preview')
expect(workflowPreview).toHaveAttribute('data-mini-map-to-right', 'true')
})
it('passes className to WorkflowPreview', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [],
edges: [],
},
},
isLoading: false,
})
render(<FlowAppPreview appId="test-app-id" className="custom-class" />)
const workflowPreview = screen.getByTestId('workflow-preview')
expect(workflowPreview).toHaveClass('custom-class')
})
})
describe('hook calls', () => {
it('calls useGetTryAppFlowPreview with correct appId', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: null,
isLoading: true,
})
render(<FlowAppPreview appId="my-specific-app-id" />)
expect(mockUseGetTryAppFlowPreview).toHaveBeenCalledWith('my-specific-app-id')
})
})
describe('wrapper styling', () => {
it('renders with correct wrapper classes when data is loaded', () => {
mockUseGetTryAppFlowPreview.mockReturnValue({
data: {
graph: {
nodes: [],
edges: [],
},
},
isLoading: false,
})
const { container } = render(<FlowAppPreview appId="test-app-id" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-full', 'w-full')
})
})
})

View File

@ -0,0 +1,127 @@
import type { TryAppInfo } from '@/service/try-app'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Preview from './index'
vi.mock('./basic-app-preview', () => ({
default: ({ appId }: { appId: string }) => (
<div data-testid="basic-app-preview" data-app-id={appId}>
BasicAppPreview
</div>
),
}))
vi.mock('./flow-app-preview', () => ({
default: ({ appId, className }: { appId: string, className?: string }) => (
<div data-testid="flow-app-preview" data-app-id={appId} className={className}>
FlowAppPreview
</div>
),
}))
const createMockAppDetail = (mode: string): TryAppInfo => ({
id: 'test-app-id',
name: 'Test App',
description: 'Test Description',
mode,
site: {
title: 'Test Site Title',
icon: 'icon',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: '',
},
model_config: {
model: {
provider: 'test/provider',
name: 'test-model',
mode: 'chat',
},
dataset_configs: {
datasets: {
datasets: [],
},
},
agent_mode: {
tools: [],
},
user_input_form: [],
},
} as unknown as TryAppInfo)
describe('Preview', () => {
afterEach(() => {
cleanup()
})
describe('basic app rendering', () => {
it('renders BasicAppPreview for agent-chat mode', () => {
const appDetail = createMockAppDetail('agent-chat')
render(<Preview appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('basic-app-preview')).toBeInTheDocument()
expect(screen.queryByTestId('flow-app-preview')).not.toBeInTheDocument()
})
it('renders BasicAppPreview for chat mode', () => {
const appDetail = createMockAppDetail('chat')
render(<Preview appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('basic-app-preview')).toBeInTheDocument()
expect(screen.queryByTestId('flow-app-preview')).not.toBeInTheDocument()
})
it('renders BasicAppPreview for completion mode', () => {
const appDetail = createMockAppDetail('completion')
render(<Preview appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('basic-app-preview')).toBeInTheDocument()
expect(screen.queryByTestId('flow-app-preview')).not.toBeInTheDocument()
})
it('passes appId to BasicAppPreview', () => {
const appDetail = createMockAppDetail('chat')
render(<Preview appId="my-app-id" appDetail={appDetail} />)
const basicPreview = screen.getByTestId('basic-app-preview')
expect(basicPreview).toHaveAttribute('data-app-id', 'my-app-id')
})
})
describe('flow app rendering', () => {
it('renders FlowAppPreview for workflow mode', () => {
const appDetail = createMockAppDetail('workflow')
render(<Preview appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('flow-app-preview')).toBeInTheDocument()
expect(screen.queryByTestId('basic-app-preview')).not.toBeInTheDocument()
})
it('renders FlowAppPreview for advanced-chat mode', () => {
const appDetail = createMockAppDetail('advanced-chat')
render(<Preview appId="test-app-id" appDetail={appDetail} />)
expect(screen.getByTestId('flow-app-preview')).toBeInTheDocument()
expect(screen.queryByTestId('basic-app-preview')).not.toBeInTheDocument()
})
it('passes appId and className to FlowAppPreview', () => {
const appDetail = createMockAppDetail('workflow')
render(<Preview appId="my-flow-app-id" appDetail={appDetail} />)
const flowPreview = screen.getByTestId('flow-app-preview')
expect(flowPreview).toHaveAttribute('data-app-id', 'my-flow-app-id')
expect(flowPreview).toHaveClass('h-full')
})
})
describe('wrapper styling', () => {
it('renders with correct wrapper classes', () => {
const appDetail = createMockAppDetail('chat')
const { container } = render(<Preview appId="test-app-id" appDetail={appDetail} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-full', 'w-full')
})
})
})

View File

@ -0,0 +1,58 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Tab, { TypeEnum } from './tab'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'tryApp.tabHeader.try': 'Try',
'tryApp.tabHeader.detail': 'Detail',
}
return translations[key] || key
},
}),
}))
describe('Tab', () => {
afterEach(() => {
cleanup()
})
it('renders tab with TRY value selected', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
expect(screen.getByText('Try')).toBeInTheDocument()
expect(screen.getByText('Detail')).toBeInTheDocument()
})
it('renders tab with DETAIL value selected', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
expect(screen.getByText('Try')).toBeInTheDocument()
expect(screen.getByText('Detail')).toBeInTheDocument()
})
it('calls onChange when clicking a tab', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('Detail'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL)
})
it('calls onChange when clicking Try tab', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('Try'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
})
it('exports TypeEnum correctly', () => {
expect(TypeEnum.TRY).toBe('try')
expect(TypeEnum.DETAIL).toBe('detail')
})
})

View File

@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import FooterTip from './footer-tip'
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('FooterTip', () => {
describe('rendering', () => {
it('should render without crashing', () => {
render(<FooterTip />)
expect(screen.getByText('Drag to adjust grouping')).toBeInTheDocument()
})
it('should render the drag tip text', () => {
render(<FooterTip />)
expect(screen.getByText('Drag to adjust grouping')).toBeInTheDocument()
})
it('should have correct container classes', () => {
const { container } = render(<FooterTip />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'shrink-0', 'items-center', 'justify-center', 'gap-x-2', 'py-4')
})
it('should have correct text styling', () => {
render(<FooterTip />)
const text = screen.getByText('Drag to adjust grouping')
expect(text).toHaveClass('system-xs-regular')
})
it('should have correct text color', () => {
const { container } = render(<FooterTip />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('text-text-quaternary')
})
it('should render the drag icon', () => {
const { container } = render(<FooterTip />)
// The RiDragDropLine icon should be rendered
const icon = container.querySelector('.size-4')
expect(icon).toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((FooterTip as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})

View File

@ -0,0 +1,166 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useFloatingRight } from './hooks'
// Mock reactflow
const mockGetNodes = vi.fn()
vi.mock('reactflow', () => ({
useStore: (selector: (s: { getNodes: () => { id: string, data: { selected: boolean } }[] }) => unknown) => {
return selector({ getNodes: mockGetNodes })
},
}))
// Mock zustand/react/shallow
vi.mock('zustand/react/shallow', () => ({
useShallow: (fn: (...args: unknown[]) => unknown) => fn,
}))
// Mock workflow store
let mockNodePanelWidth = 400
let mockWorkflowCanvasWidth: number | undefined = 1200
let mockOtherPanelWidth = 0
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
return selector({
nodePanelWidth: mockNodePanelWidth,
workflowCanvasWidth: mockWorkflowCanvasWidth,
otherPanelWidth: mockOtherPanelWidth,
})
},
}))
beforeEach(() => {
mockNodePanelWidth = 400
mockWorkflowCanvasWidth = 1200
mockOtherPanelWidth = 0
mockGetNodes.mockReturnValue([])
})
afterEach(() => {
vi.clearAllMocks()
})
describe('useFloatingRight', () => {
describe('initial state', () => {
it('should return floatingRight as false initially', () => {
mockGetNodes.mockReturnValue([])
const { result } = renderHook(() => useFloatingRight(600))
expect(result.current.floatingRight).toBe(false)
})
it('should return floatingRightWidth as target width when not floating', () => {
mockGetNodes.mockReturnValue([])
const { result } = renderHook(() => useFloatingRight(600))
expect(result.current.floatingRightWidth).toBe(600)
})
})
describe('with no selected node', () => {
it('should calculate space without node panel width', () => {
mockGetNodes.mockReturnValue([{ id: 'node-1', data: { selected: false } }])
mockWorkflowCanvasWidth = 1000
const { result } = renderHook(() => useFloatingRight(400))
// leftWidth = 1000 - 0 (no selected node) - 0 - 400 - 4 = 596
// 596 >= 404 so floatingRight should be false
expect(result.current.floatingRight).toBe(false)
})
})
describe('with selected node', () => {
it('should subtract node panel width from available space', () => {
mockGetNodes.mockReturnValue([{ id: 'node-1', data: { selected: true } }])
mockWorkflowCanvasWidth = 1200
const { result } = renderHook(() => useFloatingRight(400))
// leftWidth = 1200 - 400 (node panel) - 0 - 400 - 4 = 396
// 396 < 404 so floatingRight should be true
expect(result.current.floatingRight).toBe(true)
})
})
describe('floatingRightWidth calculation', () => {
it('should return target width when not floating', () => {
mockGetNodes.mockReturnValue([])
mockWorkflowCanvasWidth = 2000
const { result } = renderHook(() => useFloatingRight(600))
expect(result.current.floatingRightWidth).toBe(600)
})
it('should return minimum of target width and available panel widths when floating with no selected node', () => {
mockGetNodes.mockReturnValue([])
mockWorkflowCanvasWidth = 500
mockOtherPanelWidth = 200
const { result } = renderHook(() => useFloatingRight(600))
// When floating and no selected node, width = min(600, 0 + 200) = 200
expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600)
})
it('should include node panel width when node is selected', () => {
mockGetNodes.mockReturnValue([{ id: 'node-1', data: { selected: true } }])
mockWorkflowCanvasWidth = 500
mockNodePanelWidth = 300
mockOtherPanelWidth = 100
const { result } = renderHook(() => useFloatingRight(600))
// When floating with selected node, width = min(600, 300 + 100) = 400
expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600)
})
})
describe('edge cases', () => {
it('should handle undefined workflowCanvasWidth', () => {
mockGetNodes.mockReturnValue([])
mockWorkflowCanvasWidth = undefined
const { result } = renderHook(() => useFloatingRight(400))
// Should not throw and should maintain initial state
expect(result.current.floatingRight).toBe(false)
})
it('should handle zero target element width', () => {
mockGetNodes.mockReturnValue([])
const { result } = renderHook(() => useFloatingRight(0))
expect(result.current.floatingRightWidth).toBe(0)
})
it('should handle very large target element width', () => {
mockGetNodes.mockReturnValue([])
mockWorkflowCanvasWidth = 500
const { result } = renderHook(() => useFloatingRight(10000))
// Should be floating due to limited space
expect(result.current.floatingRight).toBe(true)
})
it('should return first selected node id when multiple nodes exist', () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { selected: false } },
{ id: 'node-2', data: { selected: true } },
{ id: 'node-3', data: { selected: false } },
])
mockWorkflowCanvasWidth = 1200
const { result } = renderHook(() => useFloatingRight(400))
// Should have selected node so node panel is considered
expect(result.current).toBeDefined()
})
})
})

View File

@ -0,0 +1,212 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import Datasource from './datasource'
import GlobalInputs from './global-inputs'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock BlockIcon
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ type, toolIcon, className }: { type: BlockEnum, toolIcon?: string, className?: string }) => (
<div
data-testid="block-icon"
data-type={type}
data-tool-icon={toolIcon || ''}
className={className}
/>
),
}))
// Mock useToolIcon
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon',
}))
// Mock Tooltip
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => (
<div data-testid="tooltip" data-content={popupContent} className={popupClassName} />
),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('Datasource', () => {
const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
title: 'Test Data Source',
desc: 'Test description',
type: BlockEnum.DataSource,
provider_name: 'test-provider',
provider_type: 'api',
datasource_name: 'test-datasource',
datasource_label: 'Test Datasource',
plugin_id: 'test-plugin',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType)
describe('rendering', () => {
it('should render without crashing', () => {
const nodeData = createMockNodeData()
render(<Datasource nodeData={nodeData} />)
expect(screen.getByTestId('block-icon')).toBeInTheDocument()
})
it('should render the node title', () => {
const nodeData = createMockNodeData({ title: 'My Custom Data Source' })
render(<Datasource nodeData={nodeData} />)
expect(screen.getByText('My Custom Data Source')).toBeInTheDocument()
})
it('should render BlockIcon with correct type', () => {
const nodeData = createMockNodeData()
render(<Datasource nodeData={nodeData} />)
const blockIcon = screen.getByTestId('block-icon')
expect(blockIcon).toHaveAttribute('data-type', BlockEnum.DataSource)
})
it('should pass toolIcon from useToolIcon hook', () => {
const nodeData = createMockNodeData({ provider_name: 'custom-provider' })
render(<Datasource nodeData={nodeData} />)
const blockIcon = screen.getByTestId('block-icon')
expect(blockIcon).toHaveAttribute('data-tool-icon', 'custom-provider')
})
it('should have correct icon container styling', () => {
const nodeData = createMockNodeData()
const { container } = render(<Datasource nodeData={nodeData} />)
const iconContainer = container.querySelector('.size-5')
expect(iconContainer).toBeInTheDocument()
expect(iconContainer).toHaveClass('flex', 'items-center', 'justify-center', 'rounded-md')
})
it('should have correct text styling', () => {
const nodeData = createMockNodeData()
render(<Datasource nodeData={nodeData} />)
const titleElement = screen.getByText('Test Data Source')
expect(titleElement).toHaveClass('system-sm-medium', 'text-text-secondary')
})
it('should have correct container layout', () => {
const nodeData = createMockNodeData()
const { container } = render(<Datasource nodeData={nodeData} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-x-1.5')
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Datasource as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
describe('edge cases', () => {
it('should handle empty title', () => {
const nodeData = createMockNodeData({ title: '' })
render(<Datasource nodeData={nodeData} />)
// Should still render without the title text
expect(screen.getByTestId('block-icon')).toBeInTheDocument()
})
it('should handle long title', () => {
const longTitle = 'A'.repeat(100)
const nodeData = createMockNodeData({ title: longTitle })
render(<Datasource nodeData={nodeData} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle special characters in title', () => {
const nodeData = createMockNodeData({ title: 'Test <script>alert("xss")</script>' })
render(<Datasource nodeData={nodeData} />)
expect(screen.getByText('Test <script>alert("xss")</script>')).toBeInTheDocument()
})
})
})
describe('GlobalInputs', () => {
describe('rendering', () => {
it('should render without crashing', () => {
render(<GlobalInputs />)
expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument()
})
it('should render title with correct translation key', () => {
render(<GlobalInputs />)
expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument()
})
it('should render tooltip component', () => {
render(<GlobalInputs />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should pass correct tooltip content', () => {
render(<GlobalInputs />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveAttribute('data-content', 'inputFieldPanel.globalInputs.tooltip')
})
it('should have correct tooltip className', () => {
render(<GlobalInputs />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveClass('w-[240px]')
})
it('should have correct container layout', () => {
const { container } = render(<GlobalInputs />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-x-1')
})
it('should have correct title styling', () => {
render(<GlobalInputs />)
const titleElement = screen.getByText('inputFieldPanel.globalInputs.title')
expect(titleElement).toHaveClass('system-sm-semibold-uppercase', 'text-text-secondary')
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((GlobalInputs as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})

View File

@ -0,0 +1,129 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PublishToast from './publish-toast'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock workflow store with controllable state
let mockPublishedAt = 0
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
return selector({ publishedAt: mockPublishedAt })
},
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('PublishToast', () => {
beforeEach(() => {
mockPublishedAt = 0
})
describe('rendering', () => {
it('should render when publishedAt is 0', () => {
mockPublishedAt = 0
render(<PublishToast />)
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
})
it('should render toast title', () => {
render(<PublishToast />)
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
})
it('should render toast description', () => {
render(<PublishToast />)
expect(screen.getByText('publishToast.desc')).toBeInTheDocument()
})
it('should not render when publishedAt is set', () => {
mockPublishedAt = Date.now()
const { container } = render(<PublishToast />)
expect(container.firstChild).toBeNull()
})
it('should have correct positioning classes', () => {
render(<PublishToast />)
const container = screen.getByText('publishToast.title').closest('.absolute')
expect(container).toHaveClass('bottom-[45px]', 'left-0', 'right-0', 'z-10')
})
it('should render info icon', () => {
const { container } = render(<PublishToast />)
// The RiInformation2Fill icon should be rendered
const iconContainer = container.querySelector('.text-text-accent')
expect(iconContainer).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<PublishToast />)
// The close button is a div with cursor-pointer, not a semantic button
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
})
})
describe('user interactions', () => {
it('should hide toast when close button is clicked', () => {
const { container } = render(<PublishToast />)
// The close button is a div with cursor-pointer, not a semantic button
const closeButton = container.querySelector('.cursor-pointer')
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
fireEvent.click(closeButton!)
expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
})
it('should remain hidden after close button is clicked', () => {
const { container, rerender } = render(<PublishToast />)
// The close button is a div with cursor-pointer, not a semantic button
const closeButton = container.querySelector('.cursor-pointer')
fireEvent.click(closeButton!)
rerender(<PublishToast />)
expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
})
})
describe('styling', () => {
it('should have gradient overlay', () => {
const { container } = render(<PublishToast />)
const gradientOverlay = container.querySelector('.bg-gradient-to-r')
expect(gradientOverlay).toBeInTheDocument()
})
it('should have correct toast width', () => {
render(<PublishToast />)
const toastContainer = screen.getByText('publishToast.title').closest('.w-\\[420px\\]')
expect(toastContainer).toBeInTheDocument()
})
it('should have rounded border', () => {
render(<PublishToast />)
const toastContainer = screen.getByText('publishToast.title').closest('.rounded-xl')
expect(toastContainer).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,276 @@
import type { PropsWithChildren } from 'react'
import type { Edge, Node, Viewport } from 'reactflow'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import RagPipelineMain from './rag-pipeline-main'
// Mock hooks from ../hooks
vi.mock('../hooks', () => ({
useAvailableNodesMetaData: () => ({ nodes: [], nodesMap: {} }),
useDSL: () => ({
exportCheck: vi.fn(),
handleExportDSL: vi.fn(),
}),
useGetRunAndTraceUrl: () => ({
getWorkflowRunAndTraceUrl: vi.fn(),
}),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: vi.fn(),
syncWorkflowDraftWhenPageClose: vi.fn(),
}),
usePipelineRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
usePipelineRun: () => ({
handleBackupDraft: vi.fn(),
handleLoadBackupDraft: vi.fn(),
handleRestoreFromPublishedWorkflow: vi.fn(),
handleRun: vi.fn(),
handleStopRun: vi.fn(),
}),
usePipelineStartRun: () => ({
handleStartWorkflowRun: vi.fn(),
handleWorkflowStartRunInWorkflow: vi.fn(),
}),
}))
// Mock useConfigsMap
vi.mock('../hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'test-flow-id',
flowType: 'ragPipeline',
fileSettings: {},
}),
}))
// Mock useInspectVarsCrud
vi.mock('../hooks/use-inspect-vars-crud', () => ({
useInspectVarsCrud: () => ({
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}),
}))
// Mock workflow store
const mockSetRagPipelineVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setRagPipelineVariables: mockSetRagPipelineVariables,
setEnvironmentVariables: mockSetEnvironmentVariables,
}),
}),
}))
// Mock workflow hooks
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: vi.fn(),
}),
}))
// Mock WorkflowWithInnerContext
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({ children, onWorkflowDataUpdate }: PropsWithChildren<{ onWorkflowDataUpdate?: (payload: unknown) => void }>) => (
<div data-testid="workflow-inner-context">
{children}
<button
data-testid="trigger-update"
onClick={() => onWorkflowDataUpdate?.({
rag_pipeline_variables: [{ id: '1', name: 'var1' }],
environment_variables: [{ id: '2', name: 'env1' }],
})}
>
Trigger Update
</button>
<button
data-testid="trigger-update-partial"
onClick={() => onWorkflowDataUpdate?.({
rag_pipeline_variables: [{ id: '3', name: 'var2' }],
})}
>
Trigger Partial Update
</button>
</div>
),
}))
// Mock RagPipelineChildren
vi.mock('./rag-pipeline-children', () => ({
default: () => <div data-testid="rag-pipeline-children">Children</div>,
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('RagPipelineMain', () => {
const defaultProps = {
nodes: [] as Node[],
edges: [] as Edge[],
viewport: { x: 0, y: 0, zoom: 1 } as Viewport,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render without crashing', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should render RagPipelineChildren', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('rag-pipeline-children')).toBeInTheDocument()
})
it('should pass nodes to WorkflowWithInnerContext', () => {
const nodes = [{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: {} }] as Node[]
render(<RagPipelineMain {...defaultProps} nodes={nodes} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should pass edges to WorkflowWithInnerContext', () => {
const edges = [{ id: 'edge-1', source: 'node-1', target: 'node-2' }] as Edge[]
render(<RagPipelineMain {...defaultProps} edges={edges} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should pass viewport to WorkflowWithInnerContext', () => {
const viewport = { x: 100, y: 200, zoom: 1.5 }
render(<RagPipelineMain {...defaultProps} viewport={viewport} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
})
describe('handleWorkflowDataUpdate callback', () => {
it('should update rag_pipeline_variables when provided', () => {
render(<RagPipelineMain {...defaultProps} />)
const button = screen.getByTestId('trigger-update')
button.click()
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ id: '1', name: 'var1' }])
})
it('should update environment_variables when provided', () => {
render(<RagPipelineMain {...defaultProps} />)
const button = screen.getByTestId('trigger-update')
button.click()
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: '2', name: 'env1' }])
})
it('should only update rag_pipeline_variables when environment_variables is not provided', () => {
render(<RagPipelineMain {...defaultProps} />)
const button = screen.getByTestId('trigger-update-partial')
button.click()
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ id: '3', name: 'var2' }])
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
})
describe('hooks integration', () => {
it('should use useNodesSyncDraft hook', () => {
render(<RagPipelineMain {...defaultProps} />)
// If the component renders, the hook was called successfully
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use usePipelineRefreshDraft hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use usePipelineRun hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use usePipelineStartRun hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use useAvailableNodesMetaData hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use useGetRunAndTraceUrl hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use useDSL hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use useConfigsMap hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should use useInspectVarsCrud hook', () => {
render(<RagPipelineMain {...defaultProps} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
})
describe('edge cases', () => {
it('should handle empty nodes array', () => {
render(<RagPipelineMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should handle empty edges array', () => {
render(<RagPipelineMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
it('should handle default viewport', () => {
render(<RagPipelineMain nodes={[]} edges={[]} viewport={{ x: 0, y: 0, zoom: 1 }} />)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,536 @@
import type { RAGPipelineVariables, VAR_TYPE_MAP } from '@/models/pipeline'
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { Resolution, TransferMethod } from '@/types/app'
import { FlowType } from '@/types/common'
// ============================================================================
// Import hooks after mocks
// ============================================================================
import {
useAvailableNodesMetaData,
useDSL,
useGetRunAndTraceUrl,
useInputFieldPanel,
useNodesSyncDraft,
usePipelineInit,
usePipelineRefreshDraft,
usePipelineRun,
usePipelineStartRun,
} from './index'
import { useConfigsMap } from './use-configs-map'
import { useConfigurations, useInitialData } from './use-input-fields'
import { usePipelineTemplate } from './use-pipeline-template'
// ============================================================================
// Mocks
// ============================================================================
// Mock the workflow store
const _mockGetState = vi.fn()
const mockUseStore = vi.fn()
const mockUseWorkflowStore = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => mockUseStore(selector),
useWorkflowStore: () => mockUseWorkflowStore(),
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
// Mock event emitter context
const mockEventEmit = vi.fn()
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEventEmit,
},
}),
}))
// Mock i18n docLink
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
START_INITIAL_POSITION: { x: 100, y: 100 },
}))
// Mock workflow constants/node
vi.mock('@/app/components/workflow/constants/node', () => ({
WORKFLOW_COMMON_NODES: [
{
metaData: { type: BlockEnum.Start },
defaultValue: { type: BlockEnum.Start },
},
{
metaData: { type: BlockEnum.End },
defaultValue: { type: BlockEnum.End },
},
],
}))
// Mock data source defaults
vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({
default: {
metaData: { type: BlockEnum.DataSourceEmpty },
defaultValue: { type: BlockEnum.DataSourceEmpty },
},
}))
vi.mock('@/app/components/workflow/nodes/data-source/default', () => ({
default: {
metaData: { type: BlockEnum.DataSource },
defaultValue: { type: BlockEnum.DataSource },
},
}))
vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({
default: {
metaData: { type: BlockEnum.KnowledgeBase },
defaultValue: { type: BlockEnum.KnowledgeBase },
},
}))
// Mock workflow utils with all needed exports
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
generateNewNode: ({ id, data, position }: { id: string, data: object, position: { x: number, y: number } }) => ({
newNode: { id, data, position, type: 'custom' },
}),
}
})
// Mock pipeline service
const mockExportPipelineConfig = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipelineConfig,
}),
}))
// Mock workflow service
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
environment_variables: [],
}),
}))
// ============================================================================
// Tests
// ============================================================================
describe('useConfigsMap', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
pipelineId: 'test-pipeline-id',
fileUploadConfig: { max_file_size: 10 },
}
return selector(state)
})
})
it('should return config map with correct flowId', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.flowId).toBe('test-pipeline-id')
})
it('should return config map with correct flowType', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.flowType).toBe(FlowType.ragPipeline)
})
it('should return file settings with image config', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.image).toEqual({
enabled: false,
detail: Resolution.high,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
})
it('should include fileUploadConfig from store', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current.fileSettings.fileUploadConfig).toEqual({ max_file_size: 10 })
})
})
describe('useGetRunAndTraceUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseWorkflowStore.mockReturnValue({
getState: () => ({
pipelineId: 'pipeline-123',
}),
})
})
it('should return getWorkflowRunAndTraceUrl function', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
expect(result.current.getWorkflowRunAndTraceUrl).toBeDefined()
expect(typeof result.current.getWorkflowRunAndTraceUrl).toBe('function')
})
it('should generate correct run URL', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
const { runUrl } = result.current.getWorkflowRunAndTraceUrl('run-456')
expect(runUrl).toBe('/rag/pipelines/pipeline-123/workflow-runs/run-456')
})
it('should generate correct trace URL', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
const { traceUrl } = result.current.getWorkflowRunAndTraceUrl('run-456')
expect(traceUrl).toBe('/rag/pipelines/pipeline-123/workflow-runs/run-456/node-executions')
})
})
describe('useInputFieldPanel', () => {
const mockSetShowInputFieldPanel = vi.fn()
const mockSetShowInputFieldPreviewPanel = vi.fn()
const mockSetInputFieldEditPanelProps = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
showInputFieldPreviewPanel: false,
inputFieldEditPanelProps: null,
}
return selector(state)
})
mockUseWorkflowStore.mockReturnValue({
getState: () => ({
showInputFieldPreviewPanel: false,
setShowInputFieldPanel: mockSetShowInputFieldPanel,
setShowInputFieldPreviewPanel: mockSetShowInputFieldPreviewPanel,
setInputFieldEditPanelProps: mockSetInputFieldEditPanelProps,
}),
})
})
it('should return isPreviewing as false when showInputFieldPreviewPanel is false', () => {
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isPreviewing).toBe(false)
})
it('should return isPreviewing as true when showInputFieldPreviewPanel is true', () => {
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: null,
}
return selector(state)
})
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isPreviewing).toBe(true)
})
it('should return isEditing as false when inputFieldEditPanelProps is null', () => {
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isEditing).toBe(false)
})
it('should return isEditing as true when inputFieldEditPanelProps exists', () => {
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
showInputFieldPreviewPanel: false,
inputFieldEditPanelProps: { some: 'props' },
}
return selector(state)
})
const { result } = renderHook(() => useInputFieldPanel())
expect(result.current.isEditing).toBe(true)
})
it('should call all setters when closeAllInputFieldPanels is called', () => {
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.closeAllInputFieldPanels()
})
expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(false)
expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false)
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null)
})
it('should toggle preview panel when toggleInputFieldPreviewPanel is called', () => {
const { result } = renderHook(() => useInputFieldPanel())
act(() => {
result.current.toggleInputFieldPreviewPanel()
})
expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(true)
})
it('should set edit panel props when toggleInputFieldEditPanel is called', () => {
const { result } = renderHook(() => useInputFieldPanel())
const editContent = { type: 'edit', data: {} }
act(() => {
// eslint-disable-next-line ts/no-explicit-any
result.current.toggleInputFieldEditPanel(editContent as any)
})
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent)
})
})
describe('useInitialData', () => {
it('should return empty object for empty variables', () => {
const { result } = renderHook(() => useInitialData([], undefined))
expect(result.current).toEqual({})
})
it('should handle text input type with default value', () => {
const variables: RAGPipelineVariables = [
{
type: 'text-input' as keyof typeof VAR_TYPE_MAP,
variable: 'textVar',
label: 'Text',
required: false,
default_value: 'default text',
belong_to_node_id: 'node-1',
},
]
const { result } = renderHook(() => useInitialData(variables, undefined))
expect(result.current.textVar).toBe('default text')
})
it('should use lastRunInputData over default value', () => {
const variables: RAGPipelineVariables = [
{
type: 'text-input' as keyof typeof VAR_TYPE_MAP,
variable: 'textVar',
label: 'Text',
required: false,
default_value: 'default text',
belong_to_node_id: 'node-1',
},
]
const { result } = renderHook(() => useInitialData(variables, { textVar: 'last run value' }))
expect(result.current.textVar).toBe('last run value')
})
it('should handle number input type with default 0', () => {
const variables: RAGPipelineVariables = [
{
type: 'number' as keyof typeof VAR_TYPE_MAP,
variable: 'numVar',
label: 'Number',
required: false,
belong_to_node_id: 'node-1',
},
]
const { result } = renderHook(() => useInitialData(variables, undefined))
expect(result.current.numVar).toBe(0)
})
it('should handle file type with default empty array', () => {
const variables: RAGPipelineVariables = [
{
type: 'file' as keyof typeof VAR_TYPE_MAP,
variable: 'fileVar',
label: 'File',
required: false,
belong_to_node_id: 'node-1',
},
]
const { result } = renderHook(() => useInitialData(variables, undefined))
expect(result.current.fileVar).toEqual([])
})
})
describe('useConfigurations', () => {
it('should return empty array for empty variables', () => {
const { result } = renderHook(() => useConfigurations([]))
expect(result.current).toEqual([])
})
it('should transform variables to configurations', () => {
const variables: RAGPipelineVariables = [
{
type: 'text-input' as keyof typeof VAR_TYPE_MAP,
variable: 'textVar',
label: 'Text Label',
required: true,
max_length: 100,
placeholder: 'Enter text',
tooltips: 'Help text',
belong_to_node_id: 'node-1',
},
]
const { result } = renderHook(() => useConfigurations(variables))
expect(result.current.length).toBe(1)
expect(result.current[0].variable).toBe('textVar')
expect(result.current[0].label).toBe('Text Label')
expect(result.current[0].required).toBe(true)
expect(result.current[0].maxLength).toBe(100)
expect(result.current[0].placeholder).toBe('Enter text')
expect(result.current[0].tooltip).toBe('Help text')
})
it('should transform options correctly', () => {
const variables: RAGPipelineVariables = [
{
type: 'select' as keyof typeof VAR_TYPE_MAP,
variable: 'selectVar',
label: 'Select',
required: false,
options: ['option1', 'option2', 'option3'],
belong_to_node_id: 'node-1',
},
]
const { result } = renderHook(() => useConfigurations(variables))
expect(result.current[0].options).toEqual([
{ label: 'option1', value: 'option1' },
{ label: 'option2', value: 'option2' },
{ label: 'option3', value: 'option3' },
])
})
})
describe('useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return nodes array', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodes).toBeDefined()
expect(Array.isArray(result.current.nodes)).toBe(true)
})
it('should return nodesMap object', () => {
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap).toBeDefined()
expect(typeof result.current.nodesMap).toBe('object')
})
})
describe('usePipelineTemplate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return nodes array with knowledge base node', () => {
const { result } = renderHook(() => usePipelineTemplate())
expect(result.current.nodes).toBeDefined()
expect(Array.isArray(result.current.nodes)).toBe(true)
expect(result.current.nodes.length).toBe(1)
})
it('should return empty edges array', () => {
const { result } = renderHook(() => usePipelineTemplate())
expect(result.current.edges).toEqual([])
})
})
describe('useDSL', () => {
it('should be defined and exported', () => {
expect(useDSL).toBeDefined()
expect(typeof useDSL).toBe('function')
})
})
describe('exports', () => {
it('should export useAvailableNodesMetaData', () => {
expect(useAvailableNodesMetaData).toBeDefined()
})
it('should export useDSL', () => {
expect(useDSL).toBeDefined()
})
it('should export useGetRunAndTraceUrl', () => {
expect(useGetRunAndTraceUrl).toBeDefined()
})
it('should export useInputFieldPanel', () => {
expect(useInputFieldPanel).toBeDefined()
})
it('should export useNodesSyncDraft', () => {
expect(useNodesSyncDraft).toBeDefined()
})
it('should export usePipelineInit', () => {
expect(usePipelineInit).toBeDefined()
})
it('should export usePipelineRefreshDraft', () => {
expect(usePipelineRefreshDraft).toBeDefined()
})
it('should export usePipelineRun', () => {
expect(usePipelineRun).toBeDefined()
})
it('should export usePipelineStartRun', () => {
expect(usePipelineStartRun).toBeDefined()
})
})
afterEach(() => {
vi.clearAllMocks()
})

View File

@ -0,0 +1,368 @@
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Import after mocks
// ============================================================================
import { useDSL } from './use-DSL'
// ============================================================================
// Mocks
// ============================================================================
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
// Mock event emitter context
const mockEmit = vi.fn()
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
// Mock workflow store
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Mock useNodesSyncDraft
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('./use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
// Mock pipeline service
const mockExportPipelineConfig = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipelineConfig,
}),
}))
// Mock workflow service
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
}))
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
// ============================================================================
// Tests
// ============================================================================
describe('useDSL', () => {
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn> }
let originalCreateElement: typeof document.createElement
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
// Create a proper mock link element
mockLink = {
href: '',
download: '',
click: vi.fn(),
}
// Save original and mock selectively - only intercept 'a' elements
originalCreateElement = document.createElement.bind(document)
document.createElement = vi.fn((tagName: string) => {
if (tagName === 'a') {
return mockLink as unknown as HTMLElement
}
return originalCreateElement(tagName)
}) as typeof document.createElement
mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
// Default store state
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
knowledgeName: 'Test Knowledge Base',
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockExportPipelineConfig.mockResolvedValue({ data: 'yaml-content' })
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [],
})
})
afterEach(() => {
document.createElement = originalCreateElement
mockCreateObjectURL.mockRestore()
mockRevokeObjectURL.mockRestore()
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should return exportCheck function', () => {
const { result } = renderHook(() => useDSL())
expect(result.current.exportCheck).toBeDefined()
expect(typeof result.current.exportCheck).toBe('function')
})
it('should return handleExportDSL function', () => {
const { result } = renderHook(() => useDSL())
expect(result.current.handleExportDSL).toBeDefined()
expect(typeof result.current.handleExportDSL).toBe('function')
})
})
describe('handleExportDSL', () => {
it('should not export when pipelineId is missing', async () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: undefined,
knowledgeName: 'Test',
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockExportPipelineConfig).not.toHaveBeenCalled()
})
it('should sync workflow draft before export', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should call exportPipelineConfig with correct params', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true)
})
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'test-pipeline-id',
include: true,
})
})
it('should create and download file', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(document.createElement).toHaveBeenCalledWith('a')
expect(mockCreateObjectURL).toHaveBeenCalled()
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
})
it('should use correct file extension for download', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockLink.download).toBe('Test Knowledge Base.pipeline')
})
it('should trigger download click', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockLink.click).toHaveBeenCalled()
})
it('should show error notification on export failure', async () => {
mockExportPipelineConfig.mockRejectedValue(new Error('Export failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
})
})
})
describe('exportCheck', () => {
it('should not check when pipelineId is missing', async () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: undefined,
knowledgeName: 'Test',
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
})
it('should fetch workflow draft', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
})
it('should directly export when no secret environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [
{ id: '1', value_type: 'string', value: 'test' },
],
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should call doSyncWorkflowDraft (which means handleExportDSL was called)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [
{ id: '1', value_type: 'secret', value: 'secret-value' },
],
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEmit).toHaveBeenCalledWith({
type: 'DSL_EXPORT_CHECK',
payload: {
data: [{ id: '1', value_type: 'secret', value: 'secret-value' }],
},
})
})
it('should show error notification on check failure', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('Fetch failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
})
})
it('should filter only secret environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [
{ id: '1', value_type: 'string', value: 'plain' },
{ id: '2', value_type: 'secret', value: 'secret1' },
{ id: '3', value_type: 'number', value: '123' },
{ id: '4', value_type: 'secret', value: 'secret2' },
],
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEmit).toHaveBeenCalledWith({
type: 'DSL_EXPORT_CHECK',
payload: {
data: [
{ id: '2', value_type: 'secret', value: 'secret1' },
{ id: '4', value_type: 'secret', value: 'secret2' },
],
},
})
})
it('should handle empty environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [],
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should directly call handleExportDSL since no secrets
expect(mockEmit).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should handle undefined environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: undefined,
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should directly call handleExportDSL since no secrets
expect(mockEmit).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,469 @@
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Import after mocks
// ============================================================================
import { useNodesSyncDraft } from './use-nodes-sync-draft'
// ============================================================================
// Mocks
// ============================================================================
// Mock reactflow
const mockGetNodes = vi.fn()
const mockStoreGetState = vi.fn()
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: mockStoreGetState,
}),
}))
// Mock workflow store
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Mock useNodesReadOnly
const mockGetNodesReadOnly = vi.fn()
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: mockGetNodesReadOnly,
}),
}))
// Mock useSerialAsyncCallback - must pass through arguments
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn: () => boolean) => {
return (...args: unknown[]) => {
if (!checkFn()) {
return fn(...args)
}
}
},
}))
// Mock service
const mockSyncWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
}))
// Mock usePipelineRefreshDraft
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
usePipelineRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
// Mock API_PREFIX
vi.mock('@/config', () => ({
API_PREFIX: '/api',
}))
// ============================================================================
// Tests
// ============================================================================
describe('useNodesSyncDraft', () => {
const mockSendBeacon = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Setup navigator.sendBeacon mock
Object.defineProperty(navigator, 'sendBeacon', {
value: mockSendBeacon,
writable: true,
configurable: true,
})
// Default store state
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,
edges: [],
transform: [0, 0, 1],
})
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start', _temp: true }, position: { x: 0, y: 0 } },
{ id: 'node-2', data: { type: 'end' }, position: { x: 100, y: 0 } },
])
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
environmentVariables: [],
syncWorkflowDraftHash: 'test-hash',
ragPipelineVariables: [],
setSyncWorkflowDraftHash: vi.fn(),
setDraftUpdatedAt: vi.fn(),
})
mockGetNodesReadOnly.mockReturnValue(false)
mockSyncWorkflowDraft.mockResolvedValue({
hash: 'new-hash',
updated_at: '2024-01-01T00:00:00Z',
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should return doSyncWorkflowDraft function', () => {
const { result } = renderHook(() => useNodesSyncDraft())
expect(result.current.doSyncWorkflowDraft).toBeDefined()
expect(typeof result.current.doSyncWorkflowDraft).toBe('function')
})
it('should return syncWorkflowDraftWhenPageClose function', () => {
const { result } = renderHook(() => useNodesSyncDraft())
expect(result.current.syncWorkflowDraftWhenPageClose).toBeDefined()
expect(typeof result.current.syncWorkflowDraftWhenPageClose).toBe('function')
})
})
describe('syncWorkflowDraftWhenPageClose', () => {
it('should not call sendBeacon when nodes are read only', () => {
mockGetNodesReadOnly.mockReturnValue(true)
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
})
it('should call sendBeacon with correct URL and params', () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).toHaveBeenCalledWith(
'/api/rag/pipelines/test-pipeline-id/workflows/draft',
expect.any(String),
)
})
it('should not call sendBeacon when pipelineId is missing', () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: undefined,
environmentVariables: [],
syncWorkflowDraftHash: 'test-hash',
ragPipelineVariables: [],
})
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
})
it('should not call sendBeacon when nodes array is empty', () => {
mockGetNodes.mockReturnValue([])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
})
it('should filter out temp nodes', () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start', _isTempNode: true }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
// Should not call sendBeacon because after filtering temp nodes, array is empty
expect(mockSendBeacon).not.toHaveBeenCalled()
})
it('should remove underscore-prefixed data keys from nodes', () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start', _privateData: 'secret' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).toHaveBeenCalled()
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.graph.nodes[0].data._privateData).toBeUndefined()
})
})
describe('doSyncWorkflowDraft', () => {
it('should not sync when nodes are read only', async () => {
mockGetNodesReadOnly.mockReturnValue(true)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should call syncWorkflowDraft service', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).toHaveBeenCalled()
})
it('should call onSuccess callback when sync succeeds', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const onSuccess = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSuccess })
})
expect(onSuccess).toHaveBeenCalled()
})
it('should call onSettled callback after sync completes', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const onSettled = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSettled })
})
expect(onSettled).toHaveBeenCalled()
})
it('should call onError callback when sync fails', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
mockSyncWorkflowDraft.mockRejectedValue(new Error('Sync failed'))
const onError = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onError })
})
expect(onError).toHaveBeenCalled()
})
it('should update hash and draft updated at on success', async () => {
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
environmentVariables: [],
syncWorkflowDraftHash: 'test-hash',
ragPipelineVariables: [],
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
})
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
})
it('should handle draft not sync error', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const mockJsonError = {
json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }),
bodyUsed: false,
}
mockSyncWorkflowDraft.mockRejectedValue(mockJsonError)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
})
// Wait for json to be called
await new Promise(resolve => setTimeout(resolve, 0))
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
})
it('should not refresh when notRefreshWhenSyncError is true', async () => {
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const mockJsonError = {
json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }),
bodyUsed: false,
}
mockSyncWorkflowDraft.mockRejectedValue(mockJsonError)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(true)
})
// Wait for json to be called
await new Promise(resolve => setTimeout(resolve, 0))
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
})
describe('getPostParams', () => {
it('should include viewport coordinates in params', () => {
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,
edges: [],
transform: [100, 200, 1.5],
})
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
})
it('should include environment variables in params', () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
environmentVariables: [{ key: 'API_KEY', value: 'secret' }],
syncWorkflowDraftHash: 'test-hash',
ragPipelineVariables: [],
setSyncWorkflowDraftHash: vi.fn(),
setDraftUpdatedAt: vi.fn(),
})
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }])
})
it('should include rag pipeline variables in params', () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
environmentVariables: [],
syncWorkflowDraftHash: 'test-hash',
ragPipelineVariables: [{ variable: 'input', type: 'text-input' }],
setSyncWorkflowDraftHash: vi.fn(),
setDraftUpdatedAt: vi.fn(),
})
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
})
it('should remove underscore-prefixed keys from edges', () => {
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { _hidden: true, visible: false } }],
transform: [0, 0, 1],
})
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
])
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
expect(sentData.graph.edges[0].data._hidden).toBeUndefined()
expect(sentData.graph.edges[0].data.visible).toBe(false)
})
})
})

View File

@ -0,0 +1,299 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Import after mocks
// ============================================================================
import { usePipelineConfig } from './use-pipeline-config'
// ============================================================================
// Mocks
// ============================================================================
// Mock workflow store
const mockUseStore = vi.fn()
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => mockUseStore(selector),
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Mock useWorkflowConfig
const mockUseWorkflowConfig = vi.fn()
vi.mock('@/service/use-workflow', () => ({
useWorkflowConfig: (url: string, callback: (data: unknown) => void) => mockUseWorkflowConfig(url, callback),
}))
// Mock useDataSourceList
const mockUseDataSourceList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: (enabled: boolean, callback: (data: unknown) => void) => mockUseDataSourceList(enabled, callback),
}))
// Mock basePath
vi.mock('@/utils/var', () => ({
basePath: '/base',
}))
// ============================================================================
// Tests
// ============================================================================
describe('usePipelineConfig', () => {
const mockSetNodesDefaultConfigs = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetDataSourceList = vi.fn()
const mockSetFileUploadConfig = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = { pipelineId: 'test-pipeline-id' }
return selector(state)
})
mockWorkflowStoreGetState.mockReturnValue({
setNodesDefaultConfigs: mockSetNodesDefaultConfigs,
setPublishedAt: mockSetPublishedAt,
setDataSourceList: mockSetDataSourceList,
setFileUploadConfig: mockSetFileUploadConfig,
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should render without crashing', () => {
expect(() => renderHook(() => usePipelineConfig())).not.toThrow()
})
it('should call useWorkflowConfig with correct URL for nodes default configs', () => {
renderHook(() => usePipelineConfig())
expect(mockUseWorkflowConfig).toHaveBeenCalledWith(
'/rag/pipelines/test-pipeline-id/workflows/default-workflow-block-configs',
expect.any(Function),
)
})
it('should call useWorkflowConfig with correct URL for published workflow', () => {
renderHook(() => usePipelineConfig())
expect(mockUseWorkflowConfig).toHaveBeenCalledWith(
'/rag/pipelines/test-pipeline-id/workflows/publish',
expect.any(Function),
)
})
it('should call useWorkflowConfig with correct URL for file upload config', () => {
renderHook(() => usePipelineConfig())
expect(mockUseWorkflowConfig).toHaveBeenCalledWith(
'/files/upload',
expect.any(Function),
)
})
it('should call useDataSourceList when pipelineId exists', () => {
renderHook(() => usePipelineConfig())
expect(mockUseDataSourceList).toHaveBeenCalledWith(true, expect.any(Function))
})
it('should call useDataSourceList with false when pipelineId is missing', () => {
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = { pipelineId: undefined }
return selector(state)
})
renderHook(() => usePipelineConfig())
expect(mockUseDataSourceList).toHaveBeenCalledWith(false, expect.any(Function))
})
it('should use empty URL when pipelineId is missing for nodes configs', () => {
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = { pipelineId: undefined }
return selector(state)
})
renderHook(() => usePipelineConfig())
expect(mockUseWorkflowConfig).toHaveBeenCalledWith('', expect.any(Function))
})
})
describe('handleUpdateNodesDefaultConfigs', () => {
it('should handle array format configs', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
if (url.includes('default-workflow-block-configs')) {
capturedCallback = callback
}
})
renderHook(() => usePipelineConfig())
const arrayConfigs = [
{ type: 'llm', config: { model: 'gpt-4' } },
{ type: 'code', config: { language: 'python' } },
]
capturedCallback?.(arrayConfigs)
expect(mockSetNodesDefaultConfigs).toHaveBeenCalledWith({
llm: { model: 'gpt-4' },
code: { language: 'python' },
})
})
it('should handle object format configs', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
if (url.includes('default-workflow-block-configs')) {
capturedCallback = callback
}
})
renderHook(() => usePipelineConfig())
const objectConfigs = {
llm: { model: 'gpt-4' },
code: { language: 'python' },
}
capturedCallback?.(objectConfigs)
expect(mockSetNodesDefaultConfigs).toHaveBeenCalledWith(objectConfigs)
})
})
describe('handleUpdatePublishedAt', () => {
it('should set published at from workflow response', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
if (url.includes('/publish')) {
capturedCallback = callback
}
})
renderHook(() => usePipelineConfig())
capturedCallback?.({ created_at: '2024-01-01T00:00:00Z' })
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
})
it('should handle undefined workflow response', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
if (url.includes('/publish')) {
capturedCallback = callback
}
})
renderHook(() => usePipelineConfig())
capturedCallback?.(undefined)
expect(mockSetPublishedAt).toHaveBeenCalledWith(undefined)
})
})
describe('handleUpdateDataSourceList', () => {
it('should set data source list', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
capturedCallback = callback
})
renderHook(() => usePipelineConfig())
const dataSourceList = [
{ declaration: { identity: { icon: '/icon.png' } } },
]
capturedCallback?.(dataSourceList)
expect(mockSetDataSourceList).toHaveBeenCalled()
})
it('should prepend basePath to icon if not included', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
capturedCallback = callback
})
renderHook(() => usePipelineConfig())
const dataSourceList = [
{ declaration: { identity: { icon: '/icon.png' } } },
]
capturedCallback?.(dataSourceList)
// The callback modifies the array in place
expect(dataSourceList[0].declaration.identity.icon).toBe('/base/icon.png')
})
it('should not modify icon if it already includes basePath', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
capturedCallback = callback
})
renderHook(() => usePipelineConfig())
const dataSourceList = [
{ declaration: { identity: { icon: '/base/icon.png' } } },
]
capturedCallback?.(dataSourceList)
expect(dataSourceList[0].declaration.identity.icon).toBe('/base/icon.png')
})
it('should handle non-string icon', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseDataSourceList.mockImplementation((_enabled: boolean, callback: (data: unknown) => void) => {
capturedCallback = callback
})
renderHook(() => usePipelineConfig())
const dataSourceList = [
{ declaration: { identity: { icon: { url: '/icon.png' } } } },
]
capturedCallback?.(dataSourceList)
// Should not modify object icon
expect(dataSourceList[0].declaration.identity.icon).toEqual({ url: '/icon.png' })
})
})
describe('handleUpdateWorkflowFileUploadConfig', () => {
it('should set file upload config', () => {
let capturedCallback: ((data: unknown) => void) | undefined
mockUseWorkflowConfig.mockImplementation((url: string, callback: (data: unknown) => void) => {
if (url === '/files/upload') {
capturedCallback = callback
}
})
renderHook(() => usePipelineConfig())
const config = { max_file_size: 10 * 1024 * 1024 }
capturedCallback?.(config)
expect(mockSetFileUploadConfig).toHaveBeenCalledWith(config)
})
})
})

View File

@ -0,0 +1,345 @@
import { renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Import after mocks
// ============================================================================
import { usePipelineInit } from './use-pipeline-init'
// ============================================================================
// Mocks
// ============================================================================
// Mock workflow store
const mockWorkflowStoreGetState = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
setState: mockWorkflowStoreSetState,
}),
}))
// Mock dataset detail context
const mockUseDatasetDetailContextWithSelector = vi.fn()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) =>
mockUseDatasetDetailContextWithSelector(selector),
}))
// Mock workflow service
const mockFetchWorkflowDraft = vi.fn()
const mockSyncWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params),
}))
// Mock usePipelineConfig
vi.mock('./use-pipeline-config', () => ({
usePipelineConfig: vi.fn(),
}))
// Mock usePipelineTemplate
vi.mock('./use-pipeline-template', () => ({
usePipelineTemplate: () => ({
nodes: [{ id: 'template-node' }],
edges: [],
}),
}))
// ============================================================================
// Tests
// ============================================================================
describe('usePipelineInit', () => {
const mockSetEnvSecrets = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetToolPublished = vi.fn()
const mockSetRagPipelineVariables = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreGetState.mockReturnValue({
setEnvSecrets: mockSetEnvSecrets,
setEnvironmentVariables: mockSetEnvironmentVariables,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setToolPublished: mockSetToolPublished,
setRagPipelineVariables: mockSetRagPipelineVariables,
})
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
dataset: {
pipeline_id: 'test-pipeline-id',
name: 'Test Knowledge',
icon_info: { icon: 'test-icon' },
},
}
return selector(state)
})
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [{ id: 'node-1' }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'test-hash',
updated_at: '2024-01-01T00:00:00Z',
tool_published: true,
environment_variables: [],
rag_pipeline_variables: [],
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should return data and isLoading', async () => {
const { result } = renderHook(() => usePipelineInit())
expect(result.current.isLoading).toBe(true)
expect(result.current.data).toBeUndefined()
})
it('should set pipelineId in workflow store on mount', () => {
renderHook(() => usePipelineInit())
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
pipelineId: 'test-pipeline-id',
knowledgeName: 'Test Knowledge',
knowledgeIcon: { icon: 'test-icon' },
})
})
})
describe('data fetching', () => {
it('should fetch workflow draft on mount', async () => {
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
})
})
it('should set data after successful fetch', async () => {
const { result } = renderHook(() => usePipelineInit())
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
})
it('should set isLoading to false after fetch', async () => {
const { result } = renderHook(() => usePipelineInit())
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
})
it('should set draft updated at', async () => {
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
})
})
it('should set tool published status', async () => {
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetToolPublished).toHaveBeenCalledWith(true)
})
})
it('should set sync hash', async () => {
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('test-hash')
})
})
})
describe('environment variables handling', () => {
it('should extract secret environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
hash: 'test-hash',
updated_at: '2024-01-01T00:00:00Z',
tool_published: false,
environment_variables: [
{ id: 'env-1', value_type: 'secret', value: 'secret-value' },
{ id: 'env-2', value_type: 'string', value: 'plain-value' },
],
rag_pipeline_variables: [],
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({ 'env-1': 'secret-value' })
})
})
it('should mask secret values in environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
hash: 'test-hash',
updated_at: '2024-01-01T00:00:00Z',
tool_published: false,
environment_variables: [
{ id: 'env-1', value_type: 'secret', value: 'secret-value' },
{ id: 'env-2', value_type: 'string', value: 'plain-value' },
],
rag_pipeline_variables: [],
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-1', value_type: 'secret', value: '[__HIDDEN__]' },
{ id: 'env-2', value_type: 'string', value: 'plain-value' },
])
})
})
it('should handle empty environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
hash: 'test-hash',
updated_at: '2024-01-01T00:00:00Z',
tool_published: false,
environment_variables: [],
rag_pipeline_variables: [],
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
})
})
})
describe('rag pipeline variables handling', () => {
it('should set rag pipeline variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
hash: 'test-hash',
updated_at: '2024-01-01T00:00:00Z',
tool_published: false,
environment_variables: [],
rag_pipeline_variables: [
{ variable: 'query', type: 'text-input' },
],
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([
{ variable: 'query', type: 'text-input' },
])
})
})
it('should handle undefined rag pipeline variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: { nodes: [], edges: [], viewport: {} },
hash: 'test-hash',
updated_at: '2024-01-01T00:00:00Z',
tool_published: false,
environment_variables: [],
rag_pipeline_variables: undefined,
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([])
})
})
})
describe('draft not exist error handling', () => {
it('should create initial workflow when draft does not exist', async () => {
const mockJsonError = {
json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
bodyUsed: false,
}
mockFetchWorkflowDraft.mockRejectedValueOnce(mockJsonError)
mockSyncWorkflowDraft.mockResolvedValue({ updated_at: '2024-01-02T00:00:00Z' })
// Second fetch succeeds
mockFetchWorkflowDraft.mockResolvedValueOnce({
graph: { nodes: [], edges: [], viewport: {} },
hash: 'new-hash',
updated_at: '2024-01-02T00:00:00Z',
tool_published: false,
environment_variables: [],
rag_pipeline_variables: [],
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
notInitialWorkflow: true,
shouldAutoOpenStartNodeSelector: true,
})
})
})
it('should sync initial workflow with template nodes', async () => {
const mockJsonError = {
json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
bodyUsed: false,
}
mockFetchWorkflowDraft.mockRejectedValueOnce(mockJsonError)
mockSyncWorkflowDraft.mockResolvedValue({ updated_at: '2024-01-02T00:00:00Z' })
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: '/rag/pipelines/test-pipeline-id/workflows/draft',
params: {
graph: {
nodes: [{ id: 'template-node' }],
edges: [],
},
environment_variables: [],
},
})
})
})
})
describe('missing datasetId', () => {
it('should not fetch when datasetId is missing', async () => {
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = { dataset: undefined }
return selector(state)
})
renderHook(() => usePipelineInit())
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,246 @@
import { renderHook, waitFor } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Import after mocks
// ============================================================================
import { usePipelineRefreshDraft } from './use-pipeline-refresh-draft'
// ============================================================================
// Mocks
// ============================================================================
// Mock workflow store
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Mock useWorkflowUpdate
const mockHandleUpdateWorkflowCanvas = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
}),
}))
// Mock workflow service
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
}))
// Mock utils
vi.mock('../utils', () => ({
processNodesWithoutDataSource: (nodes: unknown[], viewport: unknown) => ({
nodes,
viewport,
}),
}))
// ============================================================================
// Tests
// ============================================================================
describe('usePipelineRefreshDraft', () => {
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
})
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [{ id: 'node-1' }],
edges: [{ id: 'edge-1' }],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: [],
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should return handleRefreshWorkflowDraft function', () => {
const { result } = renderHook(() => usePipelineRefreshDraft())
expect(result.current.handleRefreshWorkflowDraft).toBeDefined()
expect(typeof result.current.handleRefreshWorkflowDraft).toBe('function')
})
})
describe('handleRefreshWorkflowDraft', () => {
it('should set syncing state to true at start', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should fetch workflow draft with correct URL', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
})
it('should update workflow canvas with response data', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
})
})
it('should update sync hash after fetch', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash')
})
})
it('should set syncing state to false after completion', async () => {
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
})
})
it('should handle secret environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: [
{ id: 'env-1', value_type: 'secret', value: 'secret-value' },
{ id: 'env-2', value_type: 'string', value: 'plain-value' },
],
})
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({ 'env-1': 'secret-value' })
})
})
it('should mask secret values in environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: [
{ id: 'env-1', value_type: 'secret', value: 'secret-value' },
{ id: 'env-2', value_type: 'string', value: 'plain-value' },
],
})
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-1', value_type: 'secret', value: '[__HIDDEN__]' },
{ id: 'env-2', value_type: 'string', value: 'plain-value' },
])
})
})
it('should handle empty environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: [],
})
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
})
})
it('should handle undefined environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
hash: 'new-hash',
environment_variables: undefined,
})
const { result } = renderHook(() => usePipelineRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
})
})
})
})

View File

@ -0,0 +1,825 @@
/* eslint-disable ts/no-explicit-any */
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
// ============================================================================
// Import after mocks
// ============================================================================
import { usePipelineRun } from './use-pipeline-run'
// ============================================================================
// Mocks
// ============================================================================
// Mock reactflow
const mockStoreGetState = vi.fn()
const mockGetViewport = vi.fn()
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: mockStoreGetState,
}),
useReactFlow: () => ({
getViewport: mockGetViewport,
}),
}))
// Mock workflow store
const mockUseStore = vi.fn()
const mockWorkflowStoreGetState = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => mockUseStore(selector),
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
setState: mockWorkflowStoreSetState,
}),
}))
// Mock useNodesSyncDraft
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('./use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
// Mock workflow hooks
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: vi.fn(),
}),
}))
const mockHandleUpdateWorkflowCanvas = vi.fn()
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
useWorkflowRunEvent: () => ({
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
}),
}))
// Mock service
const mockSsePost = vi.fn()
vi.mock('@/service/base', () => ({
ssePost: (url: string, ...args: unknown[]) => mockSsePost(url, ...args),
}))
const mockStopWorkflowRun = vi.fn()
vi.mock('@/service/workflow', () => ({
stopWorkflowRun: (url: string) => mockStopWorkflowRun(url),
}))
const mockInvalidAllLastRun = vi.fn()
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
}))
// Mock FlowType
vi.mock('@/types/common', () => ({
FlowType: {
ragPipeline: 'rag-pipeline',
},
}))
// ============================================================================
// Tests
// ============================================================================
describe('usePipelineRun', () => {
const mockSetNodes = vi.fn()
const mockGetNodes = vi.fn()
const mockSetBackupDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetRagPipelineVariables = vi.fn()
const mockSetWorkflowRunningData = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Mock DOM element
const mockWorkflowContainer = document.createElement('div')
mockWorkflowContainer.id = 'workflow-container'
Object.defineProperty(mockWorkflowContainer, 'clientWidth', { value: 1000 })
Object.defineProperty(mockWorkflowContainer, 'clientHeight', { value: 800 })
document.body.appendChild(mockWorkflowContainer)
mockStoreGetState.mockReturnValue({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
edges: [],
})
mockGetNodes.mockReturnValue([
{ id: 'node-1', data: { type: 'start', selected: true, _runningStatus: WorkflowRunningStatus.Running } },
])
mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 })
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
backupDraft: undefined,
environmentVariables: [],
setBackupDraft: mockSetBackupDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setRagPipelineVariables: mockSetRagPipelineVariables,
setWorkflowRunningData: mockSetWorkflowRunningData,
})
mockUseStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
return selector({ pipelineId: 'test-pipeline-id' })
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
})
afterEach(() => {
const container = document.getElementById('workflow-container')
if (container) {
document.body.removeChild(container)
}
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should return handleBackupDraft function', () => {
const { result } = renderHook(() => usePipelineRun())
expect(result.current.handleBackupDraft).toBeDefined()
expect(typeof result.current.handleBackupDraft).toBe('function')
})
it('should return handleLoadBackupDraft function', () => {
const { result } = renderHook(() => usePipelineRun())
expect(result.current.handleLoadBackupDraft).toBeDefined()
expect(typeof result.current.handleLoadBackupDraft).toBe('function')
})
it('should return handleRun function', () => {
const { result } = renderHook(() => usePipelineRun())
expect(result.current.handleRun).toBeDefined()
expect(typeof result.current.handleRun).toBe('function')
})
it('should return handleStopRun function', () => {
const { result } = renderHook(() => usePipelineRun())
expect(result.current.handleStopRun).toBeDefined()
expect(typeof result.current.handleStopRun).toBe('function')
})
it('should return handleRestoreFromPublishedWorkflow function', () => {
const { result } = renderHook(() => usePipelineRun())
expect(result.current.handleRestoreFromPublishedWorkflow).toBeDefined()
expect(typeof result.current.handleRestoreFromPublishedWorkflow).toBe('function')
})
})
describe('handleBackupDraft', () => {
it('should backup draft when no backup exists', () => {
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleBackupDraft()
})
expect(mockSetBackupDraft).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should not backup draft when backup already exists', () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
backupDraft: { nodes: [], edges: [], viewport: {}, environmentVariables: [] },
environmentVariables: [],
setBackupDraft: mockSetBackupDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setRagPipelineVariables: mockSetRagPipelineVariables,
setWorkflowRunningData: mockSetWorkflowRunningData,
})
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleBackupDraft()
})
expect(mockSetBackupDraft).not.toHaveBeenCalled()
})
})
describe('handleLoadBackupDraft', () => {
it('should load backup draft when exists', () => {
const backupDraft = {
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 100, y: 100, zoom: 1.5 },
environmentVariables: [{ key: 'ENV', value: 'test' }],
}
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
backupDraft,
environmentVariables: [],
setBackupDraft: mockSetBackupDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setRagPipelineVariables: mockSetRagPipelineVariables,
setWorkflowRunningData: mockSetWorkflowRunningData,
})
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleLoadBackupDraft()
})
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: backupDraft.nodes,
edges: backupDraft.edges,
viewport: backupDraft.viewport,
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith(backupDraft.environmentVariables)
expect(mockSetBackupDraft).toHaveBeenCalledWith(undefined)
})
it('should not load when no backup exists', () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
backupDraft: undefined,
environmentVariables: [],
setBackupDraft: mockSetBackupDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setRagPipelineVariables: mockSetRagPipelineVariables,
setWorkflowRunningData: mockSetWorkflowRunningData,
})
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleLoadBackupDraft()
})
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
})
describe('handleStopRun', () => {
it('should call stop workflow run service', () => {
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleStopRun('task-123')
})
expect(mockStopWorkflowRun).toHaveBeenCalledWith(
'/rag/pipelines/test-pipeline-id/workflow-runs/tasks/task-123/stop',
)
})
})
describe('handleRestoreFromPublishedWorkflow', () => {
it('should restore workflow from published version', () => {
const publishedWorkflow = {
graph: {
nodes: [{ id: 'pub-node', data: { type: 'start' } }],
edges: [{ id: 'pub-edge' }],
viewport: { x: 50, y: 50, zoom: 1 },
},
environment_variables: [{ key: 'PUB_ENV', value: 'pub' }],
rag_pipeline_variables: [{ variable: 'input', type: 'text-input' }],
}
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
})
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'pub-node', data: { type: 'start', selected: false }, selected: false }],
edges: publishedWorkflow.graph.edges,
viewport: publishedWorkflow.graph.viewport,
})
})
it('should set environment variables from published workflow', () => {
const publishedWorkflow = {
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
environment_variables: [{ key: 'ENV', value: 'value' }],
rag_pipeline_variables: [],
}
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ key: 'ENV', value: 'value' }])
})
it('should set rag pipeline variables from published workflow', () => {
const publishedWorkflow = {
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
environment_variables: [],
rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }],
}
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
})
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }])
})
it('should handle empty environment and rag pipeline variables', () => {
const publishedWorkflow = {
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
environment_variables: undefined,
rag_pipeline_variables: undefined,
}
const { result } = renderHook(() => usePipelineRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as any)
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([])
})
})
describe('handleRun', () => {
it('should sync workflow draft before running', async () => {
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should reset node selection and running status', async () => {
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
expect(mockSetNodes).toHaveBeenCalled()
})
it('should clear history workflow data', async () => {
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined })
})
it('should set initial running data', async () => {
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
expect(mockSetWorkflowRunningData).toHaveBeenCalledWith({
result: {
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
status: WorkflowRunningStatus.Running,
},
tracing: [],
resultText: '',
})
})
it('should call ssePost with correct URL', async () => {
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'test' } })
})
expect(mockSsePost).toHaveBeenCalledWith(
'/rag/pipelines/test-pipeline-id/workflows/draft/run',
expect.any(Object),
expect.any(Object),
)
})
it('should call onWorkflowStarted callback when provided', async () => {
const onWorkflowStarted = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onWorkflowStarted })
})
// Trigger the callback
await act(async () => {
capturedCallbacks.onWorkflowStarted?.({ task_id: 'task-1' })
})
expect(onWorkflowStarted).toHaveBeenCalledWith({ task_id: 'task-1' })
})
it('should call onWorkflowFinished callback when provided', async () => {
const onWorkflowFinished = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onWorkflowFinished })
})
await act(async () => {
capturedCallbacks.onWorkflowFinished?.({ status: 'succeeded' })
})
expect(onWorkflowFinished).toHaveBeenCalledWith({ status: 'succeeded' })
})
it('should call onError callback when provided', async () => {
const onError = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onError })
})
await act(async () => {
capturedCallbacks.onError?.({ message: 'error' })
})
expect(onError).toHaveBeenCalledWith({ message: 'error' })
})
it('should call onNodeStarted callback when provided', async () => {
const onNodeStarted = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onNodeStarted })
})
await act(async () => {
capturedCallbacks.onNodeStarted?.({ node_id: 'node-1' })
})
expect(onNodeStarted).toHaveBeenCalledWith({ node_id: 'node-1' })
})
it('should call onNodeFinished callback when provided', async () => {
const onNodeFinished = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onNodeFinished })
})
await act(async () => {
capturedCallbacks.onNodeFinished?.({ node_id: 'node-1' })
})
expect(onNodeFinished).toHaveBeenCalledWith({ node_id: 'node-1' })
})
it('should call onIterationStart callback when provided', async () => {
const onIterationStart = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onIterationStart })
})
await act(async () => {
capturedCallbacks.onIterationStart?.({ iteration_id: 'iter-1' })
})
expect(onIterationStart).toHaveBeenCalledWith({ iteration_id: 'iter-1' })
})
it('should call onIterationNext callback when provided', async () => {
const onIterationNext = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onIterationNext })
})
await act(async () => {
capturedCallbacks.onIterationNext?.({ index: 1 })
})
expect(onIterationNext).toHaveBeenCalledWith({ index: 1 })
})
it('should call onIterationFinish callback when provided', async () => {
const onIterationFinish = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onIterationFinish })
})
await act(async () => {
capturedCallbacks.onIterationFinish?.({ iteration_id: 'iter-1' })
})
expect(onIterationFinish).toHaveBeenCalledWith({ iteration_id: 'iter-1' })
})
it('should call onLoopStart callback when provided', async () => {
const onLoopStart = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onLoopStart })
})
await act(async () => {
capturedCallbacks.onLoopStart?.({ loop_id: 'loop-1' })
})
expect(onLoopStart).toHaveBeenCalledWith({ loop_id: 'loop-1' })
})
it('should call onLoopNext callback when provided', async () => {
const onLoopNext = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onLoopNext })
})
await act(async () => {
capturedCallbacks.onLoopNext?.({ index: 2 })
})
expect(onLoopNext).toHaveBeenCalledWith({ index: 2 })
})
it('should call onLoopFinish callback when provided', async () => {
const onLoopFinish = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onLoopFinish })
})
await act(async () => {
capturedCallbacks.onLoopFinish?.({ loop_id: 'loop-1' })
})
expect(onLoopFinish).toHaveBeenCalledWith({ loop_id: 'loop-1' })
})
it('should call onNodeRetry callback when provided', async () => {
const onNodeRetry = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onNodeRetry })
})
await act(async () => {
capturedCallbacks.onNodeRetry?.({ node_id: 'node-1', retry: 1 })
})
expect(onNodeRetry).toHaveBeenCalledWith({ node_id: 'node-1', retry: 1 })
})
it('should call onAgentLog callback when provided', async () => {
const onAgentLog = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onAgentLog })
})
await act(async () => {
capturedCallbacks.onAgentLog?.({ message: 'agent log' })
})
expect(onAgentLog).toHaveBeenCalledWith({ message: 'agent log' })
})
it('should handle onTextChunk callback', async () => {
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
await act(async () => {
capturedCallbacks.onTextChunk?.({ text: 'chunk' })
})
// Just verify it doesn't throw
expect(capturedCallbacks.onTextChunk).toBeDefined()
})
it('should handle onTextReplace callback', async () => {
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
await act(async () => {
capturedCallbacks.onTextReplace?.({ text: 'replaced' })
})
// Just verify it doesn't throw
expect(capturedCallbacks.onTextReplace).toBeDefined()
})
it('should pass rest callback to ssePost', async () => {
const customCallback = vi.fn()
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onData: customCallback } as any)
})
expect(capturedCallbacks.onData).toBeDefined()
})
it('should handle callbacks without optional handlers', async () => {
let capturedCallbacks: Record<string, (params: unknown) => void> = {}
mockSsePost.mockImplementation((_url, _body, callbacks) => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
// Run without any optional callbacks
await act(async () => {
await result.current.handleRun({ inputs: {} })
})
// Trigger all callbacks - they should not throw even without optional handlers
await act(async () => {
capturedCallbacks.onWorkflowStarted?.({ task_id: 'task-1' })
capturedCallbacks.onWorkflowFinished?.({ status: 'succeeded' })
capturedCallbacks.onError?.({ message: 'error' })
capturedCallbacks.onNodeStarted?.({ node_id: 'node-1' })
capturedCallbacks.onNodeFinished?.({ node_id: 'node-1' })
capturedCallbacks.onIterationStart?.({ iteration_id: 'iter-1' })
capturedCallbacks.onIterationNext?.({ index: 1 })
capturedCallbacks.onIterationFinish?.({ iteration_id: 'iter-1' })
capturedCallbacks.onLoopStart?.({ loop_id: 'loop-1' })
capturedCallbacks.onLoopNext?.({ index: 2 })
capturedCallbacks.onLoopFinish?.({ loop_id: 'loop-1' })
capturedCallbacks.onNodeRetry?.({ node_id: 'node-1', retry: 1 })
capturedCallbacks.onAgentLog?.({ message: 'agent log' })
capturedCallbacks.onTextChunk?.({ text: 'chunk' })
capturedCallbacks.onTextReplace?.({ text: 'replaced' })
})
// Verify ssePost was called
expect(mockSsePost).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,217 @@
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
// ============================================================================
// Import after mocks
// ============================================================================
import { usePipelineStartRun } from './use-pipeline-start-run'
// ============================================================================
// Mocks
// ============================================================================
// Mock workflow store
const mockWorkflowStoreGetState = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
setState: mockWorkflowStoreSetState,
}),
}))
// Mock workflow interactions
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
// Mock useNodesSyncDraft
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
useInputFieldPanel: () => ({
closeAllInputFieldPanels: vi.fn(),
}),
}))
// ============================================================================
// Tests
// ============================================================================
describe('usePipelineStartRun', () => {
const mockSetIsPreparingDataSource = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
isPreparingDataSource: false,
showDebugAndPreviewPanel: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('hook initialization', () => {
it('should return handleStartWorkflowRun function', () => {
const { result } = renderHook(() => usePipelineStartRun())
expect(result.current.handleStartWorkflowRun).toBeDefined()
expect(typeof result.current.handleStartWorkflowRun).toBe('function')
})
it('should return handleWorkflowStartRunInWorkflow function', () => {
const { result } = renderHook(() => usePipelineStartRun())
expect(result.current.handleWorkflowStartRunInWorkflow).toBeDefined()
expect(typeof result.current.handleWorkflowStartRunInWorkflow).toBe('function')
})
})
describe('handleWorkflowStartRunInWorkflow', () => {
it('should not proceed when workflow is already running', async () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: {
result: { status: WorkflowRunningStatus.Running },
},
isPreparingDataSource: false,
showDebugAndPreviewPanel: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).not.toHaveBeenCalled()
})
it('should set preparing data source when not preparing and has running data', async () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: {
result: { status: WorkflowRunningStatus.Succeeded },
},
isPreparingDataSource: false,
showDebugAndPreviewPanel: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
isPreparingDataSource: true,
workflowRunningData: undefined,
})
})
it('should cancel debug panel when already showing', async () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
isPreparingDataSource: false,
showDebugAndPreviewPanel: true,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
})
it('should sync draft and show debug panel when conditions are met', async () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
isPreparingDataSource: false,
showDebugAndPreviewPanel: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(true)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
})
it('should hide env panel at start', async () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
isPreparingDataSource: false,
showDebugAndPreviewPanel: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
})
})
describe('handleStartWorkflowRun', () => {
it('should call handleWorkflowStartRunInWorkflow', async () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
isPreparingDataSource: false,
showDebugAndPreviewPanel: false,
setIsPreparingDataSource: mockSetIsPreparingDataSource,
setShowEnvPanel: mockSetShowEnvPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
await act(async () => {
result.current.handleStartWorkflowRun()
})
// Should trigger the same workflow as handleWorkflowStartRunInWorkflow
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
})
})
})

View File

@ -0,0 +1,289 @@
/* eslint-disable ts/no-explicit-any */
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
import { describe, expect, it, vi } from 'vitest'
import { createRagPipelineSliceSlice } from './index'
// Mock the transformDataSourceToTool function
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (item: DataSourceItem) => ({
...item,
transformed: true,
}),
}))
describe('createRagPipelineSliceSlice', () => {
const mockSet = vi.fn()
describe('initial state', () => {
it('should have empty pipelineId', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.pipelineId).toBe('')
})
it('should have empty knowledgeName', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.knowledgeName).toBe('')
})
it('should have showInputFieldPanel as false', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.showInputFieldPanel).toBe(false)
})
it('should have showInputFieldPreviewPanel as false', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.showInputFieldPreviewPanel).toBe(false)
})
it('should have inputFieldEditPanelProps as null', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.inputFieldEditPanelProps).toBeNull()
})
it('should have empty nodesDefaultConfigs', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.nodesDefaultConfigs).toEqual({})
})
it('should have empty ragPipelineVariables', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.ragPipelineVariables).toEqual([])
})
it('should have empty dataSourceList', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.dataSourceList).toEqual([])
})
it('should have isPreparingDataSource as false', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
expect(slice.isPreparingDataSource).toBe(false)
})
})
describe('setShowInputFieldPanel', () => {
it('should call set with showInputFieldPanel true', () => {
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setShowInputFieldPanel(true)
expect(mockSet).toHaveBeenCalledWith(expect.any(Function))
// Get the setter function and execute it
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ showInputFieldPanel: true })
})
it('should call set with showInputFieldPanel false', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setShowInputFieldPanel(false)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ showInputFieldPanel: false })
})
})
describe('setShowInputFieldPreviewPanel', () => {
it('should call set with showInputFieldPreviewPanel true', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setShowInputFieldPreviewPanel(true)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ showInputFieldPreviewPanel: true })
})
it('should call set with showInputFieldPreviewPanel false', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setShowInputFieldPreviewPanel(false)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ showInputFieldPreviewPanel: false })
})
})
describe('setInputFieldEditPanelProps', () => {
it('should call set with inputFieldEditPanelProps object', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
const props = { type: 'create' as const }
slice.setInputFieldEditPanelProps(props as any)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ inputFieldEditPanelProps: props })
})
it('should call set with inputFieldEditPanelProps null', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setInputFieldEditPanelProps(null)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ inputFieldEditPanelProps: null })
})
})
describe('setNodesDefaultConfigs', () => {
it('should call set with nodesDefaultConfigs', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
const configs = { node1: { key: 'value' } }
slice.setNodesDefaultConfigs(configs)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ nodesDefaultConfigs: configs })
})
it('should call set with empty nodesDefaultConfigs', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setNodesDefaultConfigs({})
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ nodesDefaultConfigs: {} })
})
})
describe('setRagPipelineVariables', () => {
it('should call set with ragPipelineVariables', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
const variables = [
{ type: 'text-input', variable: 'var1', label: 'Var 1', required: true },
]
slice.setRagPipelineVariables(variables as any)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ ragPipelineVariables: variables })
})
it('should call set with empty ragPipelineVariables', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setRagPipelineVariables([])
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ ragPipelineVariables: [] })
})
})
describe('setDataSourceList', () => {
it('should transform and set dataSourceList', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
const dataSourceList: DataSourceItem[] = [
{ name: 'source1', key: 'key1' } as unknown as DataSourceItem,
{ name: 'source2', key: 'key2' } as unknown as DataSourceItem,
]
slice.setDataSourceList(dataSourceList)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result.dataSourceList).toHaveLength(2)
expect(result.dataSourceList[0]).toEqual({ name: 'source1', key: 'key1', transformed: true })
expect(result.dataSourceList[1]).toEqual({ name: 'source2', key: 'key2', transformed: true })
})
it('should set empty dataSourceList', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setDataSourceList([])
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result.dataSourceList).toEqual([])
})
})
describe('setIsPreparingDataSource', () => {
it('should call set with isPreparingDataSource true', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setIsPreparingDataSource(true)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ isPreparingDataSource: true })
})
it('should call set with isPreparingDataSource false', () => {
mockSet.mockClear()
const slice = createRagPipelineSliceSlice(mockSet, vi.fn() as any, vi.fn() as any)
slice.setIsPreparingDataSource(false)
const setterFn = mockSet.mock.calls[0][0]
const result = setterFn()
expect(result).toEqual({ isPreparingDataSource: false })
})
})
})
describe('RagPipelineSliceShape type', () => {
it('should define all required properties', () => {
const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any)
// Check all properties exist
expect(slice).toHaveProperty('pipelineId')
expect(slice).toHaveProperty('knowledgeName')
expect(slice).toHaveProperty('showInputFieldPanel')
expect(slice).toHaveProperty('setShowInputFieldPanel')
expect(slice).toHaveProperty('showInputFieldPreviewPanel')
expect(slice).toHaveProperty('setShowInputFieldPreviewPanel')
expect(slice).toHaveProperty('inputFieldEditPanelProps')
expect(slice).toHaveProperty('setInputFieldEditPanelProps')
expect(slice).toHaveProperty('nodesDefaultConfigs')
expect(slice).toHaveProperty('setNodesDefaultConfigs')
expect(slice).toHaveProperty('ragPipelineVariables')
expect(slice).toHaveProperty('setRagPipelineVariables')
expect(slice).toHaveProperty('dataSourceList')
expect(slice).toHaveProperty('setDataSourceList')
expect(slice).toHaveProperty('isPreparingDataSource')
expect(slice).toHaveProperty('setIsPreparingDataSource')
})
it('should have all setters as functions', () => {
const slice = createRagPipelineSliceSlice(vi.fn(), vi.fn() as any, vi.fn() as any)
expect(typeof slice.setShowInputFieldPanel).toBe('function')
expect(typeof slice.setShowInputFieldPreviewPanel).toBe('function')
expect(typeof slice.setInputFieldEditPanelProps).toBe('function')
expect(typeof slice.setNodesDefaultConfigs).toBe('function')
expect(typeof slice.setRagPipelineVariables).toBe('function')
expect(typeof slice.setDataSourceList).toBe('function')
expect(typeof slice.setIsPreparingDataSource).toBe('function')
})
})

View File

@ -0,0 +1,348 @@
import type { Viewport } from 'reactflow'
import type { Node } from '@/app/components/workflow/types'
import { describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { processNodesWithoutDataSource } from './nodes'
// Mock constants
vi.mock('@/app/components/workflow/constants', () => ({
CUSTOM_NODE: 'custom',
NODE_WIDTH_X_OFFSET: 400,
START_INITIAL_POSITION: { x: 100, y: 100 },
}))
vi.mock('@/app/components/workflow/nodes/data-source-empty/constants', () => ({
CUSTOM_DATA_SOURCE_EMPTY_NODE: 'data-source-empty',
}))
vi.mock('@/app/components/workflow/note-node/constants', () => ({
CUSTOM_NOTE_NODE: 'note',
}))
vi.mock('@/app/components/workflow/note-node/types', () => ({
NoteTheme: { blue: 'blue' },
}))
vi.mock('@/app/components/workflow/utils', () => ({
generateNewNode: ({ id, type, data, position }: { id: string, type?: string, data: object, position: { x: number, y: number } }) => ({
newNode: { id, type: type || 'custom', data, position },
}),
}))
describe('processNodesWithoutDataSource', () => {
describe('when nodes contain DataSource', () => {
it('should return original nodes and viewport unchanged', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.DataSource, title: 'Data Source' },
position: { x: 100, y: 100 },
} as Node,
{
id: 'node-2',
type: 'custom',
data: { type: BlockEnum.End, title: 'End' },
position: { x: 500, y: 100 },
} as Node,
]
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
const result = processNodesWithoutDataSource(nodes, viewport)
expect(result.nodes).toBe(nodes)
expect(result.viewport).toBe(viewport)
})
it('should check all nodes before returning early', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.Start, title: 'Start' },
position: { x: 0, y: 0 },
} as Node,
{
id: 'node-2',
type: 'custom',
data: { type: BlockEnum.DataSource, title: 'Data Source' },
position: { x: 100, y: 100 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes)
expect(result.nodes).toBe(nodes)
})
})
describe('when nodes do not contain DataSource', () => {
it('should add data source empty node and note node for single custom node', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'Knowledge Base' },
position: { x: 500, y: 200 },
} as Node,
]
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
const result = processNodesWithoutDataSource(nodes, viewport)
expect(result.nodes.length).toBe(3)
expect(result.nodes[0].id).toBe('data-source-empty')
expect(result.nodes[1].id).toBe('note')
expect(result.nodes[2]).toBe(nodes[0])
})
it('should use the leftmost custom node position for new nodes', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB 1' },
position: { x: 700, y: 100 },
} as Node,
{
id: 'node-2',
type: 'custom',
data: { type: BlockEnum.End, title: 'End' },
position: { x: 200, y: 100 }, // This is the leftmost
} as Node,
{
id: 'node-3',
type: 'custom',
data: { type: BlockEnum.Start, title: 'Start' },
position: { x: 500, y: 100 },
} as Node,
]
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
const result = processNodesWithoutDataSource(nodes, viewport)
// New nodes should be positioned based on the leftmost node (x: 200)
// startX = 200 - 400 = -200
expect(result.nodes[0].position.x).toBe(-200)
expect(result.nodes[0].position.y).toBe(100)
})
it('should adjust viewport based on new node position', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 300, y: 200 },
} as Node,
]
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
const result = processNodesWithoutDataSource(nodes, viewport)
// startX = 300 - 400 = -100
// startY = 200
// viewport.x = (100 - (-100)) * 1 = 200
// viewport.y = (100 - 200) * 1 = -100
expect(result.viewport).toEqual({
x: 200,
y: -100,
zoom: 1,
})
})
it('should apply zoom factor to viewport calculation', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 300, y: 200 },
} as Node,
]
const viewport: Viewport = { x: 0, y: 0, zoom: 2 }
const result = processNodesWithoutDataSource(nodes, viewport)
// startX = 300 - 400 = -100
// startY = 200
// viewport.x = (100 - (-100)) * 2 = 400
// viewport.y = (100 - 200) * 2 = -200
expect(result.viewport).toEqual({
x: 400,
y: -200,
zoom: 2,
})
})
it('should use default zoom 1 when viewport zoom is undefined', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 500, y: 100 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes, undefined)
expect(result.viewport?.zoom).toBe(1)
})
it('should add note node below data source empty node', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 500, y: 100 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes)
// Data source empty node position
const dataSourceEmptyNode = result.nodes[0]
const noteNode = result.nodes[1]
// Note node should be 100px below data source empty node
expect(noteNode.position.x).toBe(dataSourceEmptyNode.position.x)
expect(noteNode.position.y).toBe(dataSourceEmptyNode.position.y + 100)
})
it('should set correct data for data source empty node', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 500, y: 100 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes)
expect(result.nodes[0].data.type).toBe(BlockEnum.DataSourceEmpty)
expect(result.nodes[0].data._isTempNode).toBe(true)
expect(result.nodes[0].data.width).toBe(240)
})
it('should set correct data for note node', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 500, y: 100 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes)
const noteNode = result.nodes[1]
const noteData = noteNode.data as Record<string, unknown>
expect(noteData._isTempNode).toBe(true)
expect(noteData.theme).toBe('blue')
expect(noteData.width).toBe(240)
expect(noteData.height).toBe(300)
expect(noteData.showAuthor).toBe(true)
})
})
describe('when nodes array is empty', () => {
it('should return empty nodes array unchanged', () => {
const nodes: Node[] = []
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
const result = processNodesWithoutDataSource(nodes, viewport)
expect(result.nodes).toEqual([])
expect(result.viewport).toBe(viewport)
})
})
describe('when no custom nodes exist', () => {
it('should return original nodes when only non-custom nodes', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'special', // Not 'custom'
data: { type: BlockEnum.Start, title: 'Start' },
position: { x: 100, y: 100 },
} as Node,
]
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
const result = processNodesWithoutDataSource(nodes, viewport)
// No custom nodes to find leftmost, so no new nodes are added
expect(result.nodes).toBe(nodes)
expect(result.viewport).toBe(viewport)
})
})
describe('edge cases', () => {
it('should handle nodes with same x position', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB 1' },
position: { x: 300, y: 100 },
} as Node,
{
id: 'node-2',
type: 'custom',
data: { type: BlockEnum.End, title: 'End' },
position: { x: 300, y: 200 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes)
// First node should be used as leftNode
expect(result.nodes.length).toBe(4)
})
it('should handle negative positions', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: -100, y: -50 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes)
// startX = -100 - 400 = -500
expect(result.nodes[0].position.x).toBe(-500)
expect(result.nodes[0].position.y).toBe(-50)
})
it('should handle undefined viewport gracefully', () => {
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
data: { type: BlockEnum.KnowledgeBase, title: 'KB' },
position: { x: 500, y: 100 },
} as Node,
]
const result = processNodesWithoutDataSource(nodes, undefined)
expect(result.viewport).toBeDefined()
expect(result.viewport?.zoom).toBe(1)
})
})
})
describe('module exports', () => {
it('should export processNodesWithoutDataSource', () => {
expect(processNodesWithoutDataSource).toBeDefined()
expect(typeof processNodesWithoutDataSource).toBe('function')
})
})

View File

@ -0,0 +1,205 @@
import type { SiteInfo } from '@/models/share'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import InfoModal from './info-modal'
// Only mock react-i18next for translations
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('InfoModal', () => {
const mockOnClose = vi.fn()
const baseSiteInfo: SiteInfo = {
title: 'Test App',
icon: '🚀',
icon_type: 'emoji',
icon_background: '#ffffff',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should not render when isShow is false', () => {
render(
<InfoModal
isShow={false}
onClose={mockOnClose}
data={baseSiteInfo}
/>,
)
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
})
it('should render when isShow is true', () => {
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={baseSiteInfo}
/>,
)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should render app title', () => {
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={baseSiteInfo}
/>,
)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should render copyright when provided', () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Dify Inc.',
}
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={siteInfoWithCopyright}
/>,
)
expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
})
it('should render current year in copyright', () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Test Company',
}
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={siteInfoWithCopyright}
/>,
)
const currentYear = new Date().getFullYear().toString()
expect(screen.getByText(new RegExp(currentYear))).toBeInTheDocument()
})
it('should render custom disclaimer when provided', () => {
const siteInfoWithDisclaimer: SiteInfo = {
...baseSiteInfo,
custom_disclaimer: 'This is a custom disclaimer',
}
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={siteInfoWithDisclaimer}
/>,
)
expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument()
})
it('should not render copyright section when not provided', () => {
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={baseSiteInfo}
/>,
)
const year = new Date().getFullYear().toString()
expect(screen.queryByText(new RegExp(`©.*${year}`))).not.toBeInTheDocument()
})
it('should render with undefined data', () => {
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={undefined}
/>,
)
// Modal should still render but without content
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
})
it('should render with image icon type', () => {
const siteInfoWithImage: SiteInfo = {
...baseSiteInfo,
icon_type: 'image',
icon_url: 'https://example.com/icon.png',
}
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={siteInfoWithImage}
/>,
)
expect(screen.getByText(siteInfoWithImage.title!)).toBeInTheDocument()
})
})
describe('close functionality', () => {
it('should call onClose when close button is clicked', () => {
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={baseSiteInfo}
/>,
)
// Find the close icon (RiCloseLine) which has text-text-tertiary class
const closeIcon = document.querySelector('[class*="text-text-tertiary"]')
expect(closeIcon).toBeInTheDocument()
if (closeIcon) {
fireEvent.click(closeIcon)
expect(mockOnClose).toHaveBeenCalled()
}
})
})
describe('both copyright and disclaimer', () => {
it('should render both when both are provided', () => {
const siteInfoWithBoth: SiteInfo = {
...baseSiteInfo,
copyright: 'My Company',
custom_disclaimer: 'Disclaimer text here',
}
render(
<InfoModal
isShow={true}
onClose={mockOnClose}
data={siteInfoWithBoth}
/>,
)
expect(screen.getByText(/My Company/)).toBeInTheDocument()
expect(screen.getByText('Disclaimer text here')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,261 @@
import type { SiteInfo } from '@/models/share'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MenuDropdown from './menu-dropdown'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockReplace = vi.fn()
const mockPathname = '/test-path'
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
usePathname: () => mockPathname,
}))
// Mock web-app-context
const mockShareCode = 'test-share-code'
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
webAppAccessMode: 'code',
shareCode: mockShareCode,
}
return selector(state)
},
}))
// Mock webapp-auth service
const mockWebAppLogout = vi.fn().mockResolvedValue(undefined)
vi.mock('@/service/webapp-auth', () => ({
webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args),
}))
afterEach(() => {
cleanup()
})
describe('MenuDropdown', () => {
const baseSiteInfo: SiteInfo = {
title: 'Test App',
icon: '🚀',
icon_type: 'emoji',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the trigger button', () => {
render(<MenuDropdown data={baseSiteInfo} />)
// The trigger button contains a settings icon (RiEqualizer2Line)
const triggerButton = screen.getByRole('button')
expect(triggerButton).toBeInTheDocument()
})
it('should not show dropdown content initially', () => {
render(<MenuDropdown data={baseSiteInfo} />)
// Dropdown content should not be visible initially
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
})
it('should show dropdown content when clicked', async () => {
render(<MenuDropdown data={baseSiteInfo} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('theme.theme')).toBeInTheDocument()
})
})
it('should show About option in dropdown', async () => {
render(<MenuDropdown data={baseSiteInfo} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.about')).toBeInTheDocument()
})
})
})
describe('privacy policy link', () => {
it('should show privacy policy link when provided', async () => {
const siteInfoWithPrivacy: SiteInfo = {
...baseSiteInfo,
privacy_policy: 'https://example.com/privacy',
}
render(<MenuDropdown data={siteInfoWithPrivacy} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('chat.privacyPolicyMiddle')).toBeInTheDocument()
})
})
it('should not show privacy policy link when not provided', async () => {
render(<MenuDropdown data={baseSiteInfo} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('chat.privacyPolicyMiddle')).not.toBeInTheDocument()
})
})
it('should have correct href for privacy policy link', async () => {
const privacyUrl = 'https://example.com/privacy'
const siteInfoWithPrivacy: SiteInfo = {
...baseSiteInfo,
privacy_policy: privacyUrl,
}
render(<MenuDropdown data={siteInfoWithPrivacy} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
const link = screen.getByText('chat.privacyPolicyMiddle').closest('a')
expect(link).toHaveAttribute('href', privacyUrl)
expect(link).toHaveAttribute('target', '_blank')
})
})
})
describe('logout functionality', () => {
it('should show logout option when hideLogout is false', async () => {
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
})
})
it('should hide logout option when hideLogout is true', async () => {
render(<MenuDropdown data={baseSiteInfo} hideLogout={true} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('userProfile.logout')).not.toBeInTheDocument()
})
})
it('should call webAppLogout and redirect when logout is clicked', async () => {
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
})
const logoutButton = screen.getByText('userProfile.logout')
await act(async () => {
fireEvent.click(logoutButton)
})
await waitFor(() => {
expect(mockWebAppLogout).toHaveBeenCalledWith(mockShareCode)
expect(mockReplace).toHaveBeenCalledWith(`/webapp-signin?redirect_url=${mockPathname}`)
})
})
})
describe('about modal', () => {
it('should show InfoModal when About is clicked', async () => {
render(<MenuDropdown data={baseSiteInfo} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('userProfile.about')).toBeInTheDocument()
})
const aboutButton = screen.getByText('userProfile.about')
fireEvent.click(aboutButton)
await waitFor(() => {
expect(screen.getByText('Test App')).toBeInTheDocument()
})
})
})
describe('forceClose prop', () => {
it('should close dropdown when forceClose changes to true', async () => {
const { rerender } = render(<MenuDropdown data={baseSiteInfo} forceClose={false} />)
const triggerButton = screen.getByRole('button')
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('theme.theme')).toBeInTheDocument()
})
rerender(<MenuDropdown data={baseSiteInfo} forceClose={true} />)
await waitFor(() => {
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
})
})
})
describe('placement prop', () => {
it('should accept custom placement', () => {
render(<MenuDropdown data={baseSiteInfo} placement="top-start" />)
const triggerButton = screen.getByRole('button')
expect(triggerButton).toBeInTheDocument()
})
})
describe('toggle behavior', () => {
it('should close dropdown when clicking trigger again', async () => {
render(<MenuDropdown data={baseSiteInfo} />)
const triggerButton = screen.getByRole('button')
// Open
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('theme.theme')).toBeInTheDocument()
})
// Close
fireEvent.click(triggerButton)
await waitFor(() => {
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
})
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((MenuDropdown as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})

View File

@ -0,0 +1,133 @@
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Result from './content'
// Only mock react-i18next for translations
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock copy-to-clipboard for the Header component
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(() => true),
}))
// Mock the format function from service/base
vi.mock('@/service/base', () => ({
format: (content: string) => content.replace(/\n/g, '<br>'),
}))
afterEach(() => {
cleanup()
})
describe('Result (content)', () => {
const mockOnFeedback = vi.fn()
const defaultProps = {
content: 'Test content here',
showFeedback: true,
feedback: { rating: null } as FeedbackType,
onFeedback: mockOnFeedback,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the Header component', () => {
render(<Result {...defaultProps} />)
// Header renders the result title
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
})
it('should render content', () => {
render(<Result {...defaultProps} />)
expect(screen.getByText('Test content here')).toBeInTheDocument()
})
it('should render formatted content with line breaks', () => {
render(
<Result
{...defaultProps}
content={'Line 1\nLine 2'}
/>,
)
// The format function converts \n to <br>
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
expect(contentDiv?.innerHTML).toContain('Line 1<br>Line 2')
})
it('should have max height style', () => {
render(<Result {...defaultProps} />)
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
expect(contentDiv).toHaveStyle({ maxHeight: '70vh' })
})
it('should render with empty content', () => {
render(
<Result
{...defaultProps}
content=""
/>,
)
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
})
it('should render with HTML content safely', () => {
render(
<Result
{...defaultProps}
content="<script>alert('xss')</script>"
/>,
)
// Content is rendered via dangerouslySetInnerHTML
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
expect(contentDiv).toBeInTheDocument()
})
})
describe('feedback props', () => {
it('should pass showFeedback to Header', () => {
render(
<Result
{...defaultProps}
showFeedback={false}
/>,
)
// Feedback buttons should not be visible
const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
expect(feedbackArea).not.toBeInTheDocument()
})
it('should pass feedback to Header', () => {
render(
<Result
{...defaultProps}
feedback={{ rating: 'like' }}
/>,
)
// Like button should be highlighted
const likeButton = document.querySelector('[class*="primary"]')
expect(likeButton).toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})

View File

@ -0,0 +1,176 @@
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
// Only mock react-i18next for translations
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock copy-to-clipboard
const mockCopy = vi.fn((_text: string) => true)
vi.mock('copy-to-clipboard', () => ({
default: (text: string) => mockCopy(text),
}))
afterEach(() => {
cleanup()
})
describe('Header', () => {
const mockOnFeedback = vi.fn()
const defaultProps = {
result: 'Test result content',
showFeedback: true,
feedback: { rating: null } as FeedbackType,
onFeedback: mockOnFeedback,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the result title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
})
it('should render the copy button', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('generation.copy')).toBeInTheDocument()
})
})
describe('copy functionality', () => {
it('should copy result when copy button is clicked', () => {
render(<Header {...defaultProps} />)
const copyButton = screen.getByText('generation.copy').closest('button')
fireEvent.click(copyButton!)
expect(mockCopy).toHaveBeenCalledWith('Test result content')
})
})
describe('feedback buttons when showFeedback is true', () => {
it('should show feedback buttons when no rating is given', () => {
render(<Header {...defaultProps} />)
// Should show both thumbs up and down buttons
const buttons = document.querySelectorAll('[class*="cursor-pointer"]')
expect(buttons.length).toBeGreaterThan(0)
})
it('should show like button highlighted when rating is like', () => {
render(
<Header
{...defaultProps}
feedback={{ rating: 'like' }}
/>,
)
// Should show the undo button for like
const likeButton = document.querySelector('[class*="primary"]')
expect(likeButton).toBeInTheDocument()
})
it('should show dislike button highlighted when rating is dislike', () => {
render(
<Header
{...defaultProps}
feedback={{ rating: 'dislike' }}
/>,
)
// Should show the undo button for dislike
const dislikeButton = document.querySelector('[class*="red"]')
expect(dislikeButton).toBeInTheDocument()
})
it('should call onFeedback with like when thumbs up is clicked', () => {
render(<Header {...defaultProps} />)
// Find the thumbs up button (first one in the feedback area)
const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
const thumbsUp = Array.from(thumbButtons).find(btn =>
btn.className.includes('rounded-md') && !btn.className.includes('primary'),
)
if (thumbsUp) {
fireEvent.click(thumbsUp)
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'like' })
}
})
it('should call onFeedback with dislike when thumbs down is clicked', () => {
render(<Header {...defaultProps} />)
// Find the thumbs down button
const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
const thumbsDown = Array.from(thumbButtons).pop()
if (thumbsDown) {
fireEvent.click(thumbsDown)
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
}
})
it('should call onFeedback with null when undo like is clicked', () => {
render(
<Header
{...defaultProps}
feedback={{ rating: 'like' }}
/>,
)
// When liked, clicking the like button again should undo it (has bg-primary-100 class)
const likeButton = document.querySelector('[class*="bg-primary-100"]')
expect(likeButton).toBeInTheDocument()
fireEvent.click(likeButton!)
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
})
it('should call onFeedback with null when undo dislike is clicked', () => {
render(
<Header
{...defaultProps}
feedback={{ rating: 'dislike' }}
/>,
)
// When disliked, clicking the dislike button again should undo it (has bg-red-100 class)
const dislikeButton = document.querySelector('[class*="bg-red-100"]')
expect(dislikeButton).toBeInTheDocument()
fireEvent.click(dislikeButton!)
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
})
})
describe('feedback buttons when showFeedback is false', () => {
it('should not show feedback buttons', () => {
render(
<Header
{...defaultProps}
showFeedback={false}
/>,
)
// Should not show feedback area buttons (only copy button)
const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
expect(feedbackArea).not.toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Header as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
})

View File

@ -1,6 +1,7 @@
import type { InputValueTypes } from '../types'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import type { VisionFile, VisionSettings } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
@ -27,7 +28,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', (
}))
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => {
function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: any[]) => void }) {
function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: VisionFile[]) => void }) {
useEffect(() => {
onFilesChange([])
}, [onFilesChange])
@ -38,6 +39,20 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', (
}
})
// Mock FileUploaderInAttachmentWrapper as it requires context providers not available in tests
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => (
<div data-testid="file-uploader-mock">
<button onClick={() => onChange([{ id: 'test-file' }])}>Upload</button>
<span>
{value?.length || 0}
{' '}
files
</span>
</div>
),
}))
const createPromptVariable = (overrides: Partial<PromptVariable>): PromptVariable => ({
key: 'input',
name: 'Input',
@ -95,11 +110,11 @@ const setup = (overrides: {
const onInputsChange = vi.fn()
const onSend = vi.fn()
const onVisionFilesChange = vi.fn()
let inputsRefCapture: React.MutableRefObject<Record<string, any>> | null = null
let inputsRefCapture: React.MutableRefObject<Record<string, InputValueTypes>> | null = null
const Wrapper = () => {
const [inputs, setInputs] = useState<Record<string, any>>({})
const inputsRef = useRef<Record<string, any>>({})
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef<Record<string, InputValueTypes>>({})
inputsRefCapture = inputsRef
return (
<RunOnce
@ -237,6 +252,208 @@ describe('RunOnce', () => {
expect(stopButton).toBeDisabled()
})
describe('select input type', () => {
it('should render select input and handle selection', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'selectInput',
name: 'Select Input',
type: 'select',
options: ['Option A', 'Option B', 'Option C'],
default: 'Option A',
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalledWith({
selectInput: 'Option A',
})
})
// The Select component should be rendered
expect(screen.getByText('Select Input')).toBeInTheDocument()
})
})
describe('file input types', () => {
it('should render file uploader for single file input', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'fileInput',
name: 'File Input',
type: 'file',
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalledWith({
fileInput: undefined,
})
})
expect(screen.getByText('File Input')).toBeInTheDocument()
})
it('should render file uploader for file-list input', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'fileListInput',
name: 'File List Input',
type: 'file-list',
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalledWith({
fileListInput: [],
})
})
expect(screen.getByText('File List Input')).toBeInTheDocument()
})
})
describe('json_object input type', () => {
it('should render code editor for json_object input', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'jsonInput',
name: 'JSON Input',
type: 'json_object' as PromptVariable['type'],
json_schema: '{"type": "object"}',
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalledWith({
jsonInput: undefined,
})
})
expect(screen.getByText('JSON Input')).toBeInTheDocument()
expect(screen.getByTestId('code-editor-mock')).toBeInTheDocument()
})
it('should update json_object input when code editor changes', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'jsonInput',
name: 'JSON Input',
type: 'json_object' as PromptVariable['type'],
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
onInputsChange.mockClear()
const codeEditor = screen.getByTestId('code-editor-mock')
fireEvent.change(codeEditor, { target: { value: '{"key": "value"}' } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalledWith({
jsonInput: '{"key": "value"}',
})
})
})
})
describe('hidden and optional fields', () => {
it('should not render hidden variables', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'hiddenInput',
name: 'Hidden Input',
type: 'string',
hide: true,
}),
createPromptVariable({
key: 'visibleInput',
name: 'Visible Input',
type: 'string',
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
expect(screen.queryByText('Hidden Input')).not.toBeInTheDocument()
expect(screen.getByText('Visible Input')).toBeInTheDocument()
})
it('should show optional label for non-required fields', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'optionalInput',
name: 'Optional Input',
type: 'string',
required: false,
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
expect(screen.getByText('workflow.panel.optional')).toBeInTheDocument()
})
})
describe('vision uploader', () => {
it('should not render vision uploader when disabled', async () => {
const { onInputsChange } = setup({ visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
expect(screen.queryByText('common.imageUploader.imageUpload')).not.toBeInTheDocument()
})
})
describe('clear with different input types', () => {
it('should clear select input to undefined', async () => {
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'selectInput',
name: 'Select Input',
type: 'select',
options: ['Option A', 'Option B'],
default: 'Option A',
}),
],
}
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
onInputsChange.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onInputsChange).toHaveBeenCalledWith({
selectInput: undefined,
})
})
})
describe('maxLength behavior', () => {
it('should not have maxLength attribute when max_length is not set', async () => {
const promptConfig: PromptConfig = {

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { getInitialTokenV2, isTokenV1 } from './utils'
describe('utils', () => {
describe('isTokenV1', () => {
it('should return true when token has no version property', () => {
const token = { someKey: 'value' }
expect(isTokenV1(token)).toBe(true)
})
it('should return true when token.version is undefined', () => {
const token = { version: undefined }
expect(isTokenV1(token)).toBe(true)
})
it('should return true when token.version is null', () => {
const token = { version: null }
expect(isTokenV1(token)).toBe(true)
})
it('should return true when token.version is 0', () => {
const token = { version: 0 }
expect(isTokenV1(token)).toBe(true)
})
it('should return true when token.version is empty string', () => {
const token = { version: '' }
expect(isTokenV1(token)).toBe(true)
})
it('should return false when token has version 1', () => {
const token = { version: 1 }
expect(isTokenV1(token)).toBe(false)
})
it('should return false when token has version 2', () => {
const token = { version: 2 }
expect(isTokenV1(token)).toBe(false)
})
it('should return false when token has string version', () => {
const token = { version: '2' }
expect(isTokenV1(token)).toBe(false)
})
it('should handle empty object', () => {
const token = {}
expect(isTokenV1(token)).toBe(true)
})
})
describe('getInitialTokenV2', () => {
it('should return object with version 2', () => {
const token = getInitialTokenV2()
expect(token.version).toBe(2)
})
it('should return a new object each time', () => {
const token1 = getInitialTokenV2()
const token2 = getInitialTokenV2()
expect(token1).not.toBe(token2)
})
it('should return an object that can be modified without affecting future calls', () => {
const token1 = getInitialTokenV2()
token1.customField = 'test'
const token2 = getInitialTokenV2()
expect(token2.customField).toBeUndefined()
})
})
})

View File

@ -2584,11 +2584,6 @@
"count": 2
}
},
"app/components/share/text-generation/run-once/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/share/text-generation/run-once/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1