mirror of https://github.com/langgenius/dify.git
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:
parent
b48a10d7ec
commit
8f414af34e
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 />)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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">&</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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue