Compare commits

...

2 Commits

25 changed files with 6111 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import EconomicalRetrievalMethodConfig from './index'
// Mock dependencies
vi.mock('../../settings/option-card', () => ({
default: ({ children, title, description, disabled, id }: any) => (
<div data-testid="option-card" data-title={title} data-id={id} data-disabled={disabled}>
<div>{description}</div>
{children}
</div>
),
}))
vi.mock('../retrieval-param-config', () => ({
default: ({ value, onChange, type }: any) => (
<div data-testid="retrieval-param-config" data-type={type}>
<button onClick={() => onChange({ ...value, newProp: 'changed' })}>
Change Value
</button>
</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
VectorSearch: () => <svg data-testid="vector-search-icon" />,
}))
describe('EconomicalRetrievalMethodConfig', () => {
const mockOnChange = vi.fn()
const defaultProps = {
value: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 2,
score_threshold_enabled: false,
score_threshold: 0.5,
},
onChange: mockOnChange,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
expect(screen.getByTestId('option-card')).toBeInTheDocument()
expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument()
// Check if title and description are rendered (mocked i18n returns key)
expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument()
})
it('should pass correct props to OptionCard', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} disabled={true} />)
const card = screen.getByTestId('option-card')
expect(card).toHaveAttribute('data-disabled', 'true')
expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch)
})
it('should pass correct props to RetrievalParamConfig', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
const config = screen.getByTestId('retrieval-param-config')
expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch)
})
it('should handle onChange events', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
fireEvent.click(screen.getByText('Change Value'))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith({
...defaultProps.value,
newProp: 'changed',
})
})
it('should default disabled prop to false', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
const card = screen.getByTestId('option-card')
expect(card).toHaveAttribute('data-disabled', 'false')
})
})

View File

@@ -0,0 +1,147 @@
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../create/icons'
import RetrievalMethodInfo, { getIcon } from './index'
// Mock next/image
vi.mock('next/image', () => ({
default: ({ src, alt, className }: any) => (
<img src={src} alt={alt} className={className} data-testid="method-icon" />
),
}))
// Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: any) => (
<div data-testid="radio-card">
<div data-testid="card-title">{title}</div>
<div data-testid="card-description">{description}</div>
<div data-testid="card-icon">{icon}</div>
<div data-testid="chosen-config">{chosenConfig}</div>
</div>
),
}))
// Mock icons
vi.mock('../../create/icons', () => ({
retrievalIcon: {
vector: 'vector-icon.png',
fullText: 'fulltext-icon.png',
hybrid: 'hybrid-icon.png',
},
}))
describe('RetrievalMethodInfo', () => {
const defaultConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'test-provider',
reranking_model_name: 'test-model',
},
top_k: 5,
score_threshold_enabled: true,
score_threshold: 0.8,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly with full config', () => {
render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
// Check Title & Description (mocked i18n returns key prefixed with ns)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
// Check Icon
const icon = screen.getByTestId('method-icon')
expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
expect(screen.getByText('5')).toBeInTheDocument() // Top K
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
})
it('should not render reranking model if missing', () => {
const configWithoutRerank = {
...defaultConfig,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}
render(<RetrievalMethodInfo value={configWithoutRerank} />)
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
// Other fields should still be there
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should handle different retrieval methods', () => {
// Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
unmount()
// Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
})
describe('getIcon utility', () => {
it('should return correct icon for each type', () => {
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText)
expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid)
expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector)
expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector)
})
it('should return default vector icon for unknown type', () => {
// Test fallback branch when type is not in the mapping
const unknownType = 'unknown_method' as RETRIEVE_METHOD
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
})
})
it('should not render score threshold if disabled', () => {
const configWithoutScoreThreshold = {
...defaultConfig,
score_threshold_enabled: false,
score_threshold: undefined,
}
render(<RetrievalMethodInfo value={configWithoutScoreThreshold as any} />)
// score_threshold is still rendered but may be undefined
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
})
it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
})

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import EmbeddingSkeleton from './index'
// Mock Skeleton components
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: any) => <div data-testid="skeleton-container">{children}</div>,
SkeletonPoint: () => <div data-testid="skeleton-point" />,
SkeletonRectangle: () => <div data-testid="skeleton-rectangle" />,
SkeletonRow: ({ children }: any) => <div data-testid="skeleton-row">{children}</div>,
}))
// Mock Divider
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
describe('EmbeddingSkeleton', () => {
it('should render correct number of skeletons', () => {
render(<EmbeddingSkeleton />)
// It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers.
// Let's count the number of main wrapper divs (loop is 5)
// Each iteration renders a CardSkeleton and potentially a Divider.
// The component structure is:
// div.relative...
// div.absolute... (mask)
// map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?)
// Actually the code says `index !== 9`, but the loop is length 5.
// So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered.
expect(screen.getAllByTestId('divider')).toHaveLength(5)
// Just ensure it renders without crashing and contains skeleton elements
expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0)
})
it('should render the mask overlay', () => {
const { container } = render(<EmbeddingSkeleton />)
// Check for the absolute positioned mask
const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(mask).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
it('should render correctly', () => {
render(<DatasetFooter />)
// Check main title (mocked i18n returns ns:key or key)
// The code uses t('didYouKnow', { ns: 'dataset' })
// With default mock it likely returns 'dataset.didYouKnow'
expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
// Check paragraph content
expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
})
it('should have correct styling', () => {
const { container } = render(<DatasetFooter />)
const footer = container.querySelector('footer')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
const h3 = container.querySelector('h3')
expect(h3).toHaveClass('text-gradient')
})
})

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import NewDatasetCard from './index'
// Mock dependencies
vi.mock('./option', () => ({
default: ({ text, href }: any) => (
<a data-testid="option-link" href={href}>
{text}
</a>
),
}))
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <svg data-testid="icon-add" />,
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
ApiConnectionMod: () => <svg data-testid="icon-api" />,
}))
describe('NewDatasetCard', () => {
it('should render all options', () => {
render(<NewDatasetCard />)
const options = screen.getAllByTestId('option-link')
expect(options).toHaveLength(3)
// Check first option (Create Dataset)
const createDataset = options[0]
expect(createDataset).toHaveAttribute('href', '/datasets/create')
expect(createDataset).toHaveTextContent('dataset.createDataset')
// Check second option (Create from Pipeline)
const createFromPipeline = options[1]
expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
// Check third option (Connect Dataset)
const connectDataset = options[2]
expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
expect(connectDataset).toHaveTextContent('dataset.connectDataset')
})
})

View File

@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkStructure from './index'
// Mock dependencies
vi.mock('../option-card', () => ({
default: ({ id, title, isActive, disabled }: any) => (
<div
data-testid="option-card"
data-id={id}
data-active={isActive}
data-disabled={disabled}
>
{title}
</div>
),
}))
// Mock hook
vi.mock('./hooks', () => ({
useChunkStructure: () => ({
options: [
{
id: ChunkingMode.text,
title: 'General',
description: 'General description',
icon: <svg />,
effectColor: 'indigo',
iconActiveColor: 'indigo',
},
{
id: ChunkingMode.parentChild,
title: 'Parent-Child',
description: 'PC description',
icon: <svg />,
effectColor: 'blue',
iconActiveColor: 'blue',
},
],
}),
}))
describe('ChunkStructure', () => {
it('should render all options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
expect(options).toHaveLength(2)
expect(options[0]).toHaveTextContent('General')
expect(options[1]).toHaveTextContent('Parent-Child')
})
it('should set active state correctly', () => {
// Render with 'text' active
const { unmount } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
expect(options[0]).toHaveAttribute('data-active', 'true')
expect(options[1]).toHaveAttribute('data-active', 'false')
unmount()
// Render with 'parentChild' active
render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const newOptions = screen.getAllByTestId('option-card')
expect(newOptions[0]).toHaveAttribute('data-active', 'false')
expect(newOptions[1]).toHaveAttribute('data-active', 'true')
})
it('should be always disabled', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
options.forEach((option) => {
expect(option).toHaveAttribute('data-disabled', 'true')
})
})
})

View File

@@ -0,0 +1,130 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ActionList from './action-list'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.action || 'actions'}`
return key
},
}),
}))
const mockToolData = [
{ name: 'tool-1', label: { en_US: 'Tool 1' } },
{ name: 'tool-2', label: { en_US: 'Tool 2' } },
]
const mockProvider = {
name: 'test-plugin/test-tool',
type: 'builtin',
}
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({ data: [mockProvider] }),
useBuiltinTools: (key: string) => ({
data: key ? mockToolData : undefined,
}),
}))
vi.mock('@/app/components/tools/provider/tool-item', () => ({
default: ({ tool }: { tool: { name: string } }) => (
<div data-testid="tool-item">{tool.name}</div>
),
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
tool: {
identity: {
author: 'test-author',
name: 'test-tool',
description: { en_US: 'Test' },
icon: 'icon.png',
label: { en_US: 'Test Tool' },
tags: [],
},
credentials_schema: [],
},
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('ActionList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render tool items when data is available', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
expect(screen.getByText('2 actions')).toBeInTheDocument()
expect(screen.getAllByTestId('tool-item')).toHaveLength(2)
})
it('should render tool names', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
expect(screen.getByText('tool-1')).toBeInTheDocument()
expect(screen.getByText('tool-2')).toBeInTheDocument()
})
it('should return null when no tool declaration', () => {
const detail = createPluginDetail({
declaration: {} as PluginDetail['declaration'],
})
const { container } = render(<ActionList detail={detail} />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when providerKey is empty', () => {
const detail = createPluginDetail({
declaration: {
tool: {
identity: undefined,
},
} as unknown as PluginDetail['declaration'],
})
const { container } = render(<ActionList detail={detail} />)
expect(container).toBeEmptyDOMElement()
})
})
describe('Props', () => {
it('should use plugin_id in provider key construction', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
// The provider key is constructed from plugin_id and tool identity name
// When they match the mock, it renders
expect(screen.getByText('2 actions')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,131 @@
import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AgentStrategyList from './agent-strategy-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.strategy || 'strategies'}`
return key
},
}),
}))
const mockStrategies = [
{
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy 1' },
provider: 'provider-1',
},
parameters: [],
description: { en_US: 'Strategy 1 desc' },
output_schema: {},
features: [],
},
] as unknown as StrategyDetail[]
let mockStrategyProviderDetail: { declaration: { identity: unknown, strategies: StrategyDetail[] } } | undefined
vi.mock('@/service/use-strategy', () => ({
useStrategyProviderDetail: () => ({
data: mockStrategyProviderDetail,
}),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/strategy-item', () => ({
default: ({ detail }: { detail: StrategyDetail }) => (
<div data-testid="strategy-item">{detail.identity.name}</div>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
agent_strategy: {
identity: {
author: 'test-author',
name: 'test-strategy',
label: { en_US: 'Test Strategy' },
description: { en_US: 'Test' },
icon: 'icon.png',
tags: [],
},
},
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('AgentStrategyList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStrategyProviderDetail = {
declaration: {
identity: { author: 'test', name: 'test' },
strategies: mockStrategies,
},
}
})
describe('Rendering', () => {
it('should render strategy items when data is available', () => {
render(<AgentStrategyList detail={createPluginDetail()} />)
expect(screen.getByText('1 strategy')).toBeInTheDocument()
expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
})
it('should return null when no strategy provider detail', () => {
mockStrategyProviderDetail = undefined
const { container } = render(<AgentStrategyList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should render multiple strategies', () => {
mockStrategyProviderDetail = {
declaration: {
identity: { author: 'test', name: 'test' },
strategies: [
...mockStrategies,
{ ...mockStrategies[0], identity: { ...mockStrategies[0].identity, name: 'strategy-2' } },
],
},
}
render(<AgentStrategyList detail={createPluginDetail()} />)
expect(screen.getByText('2 strategies')).toBeInTheDocument()
expect(screen.getAllByTestId('strategy-item')).toHaveLength(2)
})
})
describe('Props', () => {
it('should pass tenant_id to provider detail', () => {
const detail = createPluginDetail()
detail.tenant_id = 'custom-tenant'
render(<AgentStrategyList detail={detail} />)
expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,104 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DatasourceActionList from './datasource-action-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.action || 'actions'}`
return key
},
}),
}))
const mockDataSourceList = [
{ plugin_id: 'test-plugin', name: 'Data Source 1' },
]
let mockDataSourceListData: typeof mockDataSourceList | undefined
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: () => ({ data: mockDataSourceListData }),
}))
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (ds: unknown) => ds,
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
datasource: {
identity: {
author: 'test-author',
name: 'test-datasource',
description: { en_US: 'Test' },
icon: 'icon.png',
label: { en_US: 'Test Datasource' },
tags: [],
},
credentials_schema: [],
},
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('DatasourceActionList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataSourceListData = mockDataSourceList
})
describe('Rendering', () => {
it('should render action count when data and provider exist', () => {
render(<DatasourceActionList detail={createPluginDetail()} />)
// The component always shows "0 action" because data is hardcoded as empty array
expect(screen.getByText('0 action')).toBeInTheDocument()
})
it('should return null when no provider found', () => {
mockDataSourceListData = []
const { container } = render(<DatasourceActionList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when dataSourceList is undefined', () => {
mockDataSourceListData = undefined
const { container } = render(<DatasourceActionList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
})
describe('Props', () => {
it('should use plugin_id to find matching datasource', () => {
const detail = createPluginDetail()
detail.plugin_id = 'different-plugin'
mockDataSourceListData = [{ plugin_id: 'different-plugin', name: 'Different DS' }]
render(<DatasourceActionList detail={detail} />)
expect(screen.getByText('0 action')).toBeInTheDocument()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
import type { EndpointListItem, PluginDetail } from '../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointCard from './endpoint-card'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
const mockHandleChange = vi.fn()
const mockEnableEndpoint = vi.fn()
const mockDisableEndpoint = vi.fn()
const mockDeleteEndpoint = vi.fn()
const mockUpdateEndpoint = vi.fn()
// Flags to control whether operations should fail
const failureFlags = {
enable: false,
disable: false,
delete: false,
update: false,
}
vi.mock('@/service/use-endpoints', () => ({
useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockEnableEndpoint(id)
if (failureFlags.enable)
onError()
else
onSuccess()
},
}),
useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockDisableEndpoint(id)
if (failureFlags.disable)
onError()
else
onSuccess()
},
}),
useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockDeleteEndpoint(id)
if (failureFlags.delete)
onError()
else
onSuccess()
},
}),
useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (data: unknown) => {
mockUpdateEndpoint(data)
if (failureFlags.update)
onError()
else
onSuccess()
},
}),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
addDefaultValue: (value: unknown) => value,
}))
vi.mock('./endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}>Save</button>
</div>
),
}))
const mockEndpointData: EndpointListItem = {
id: 'ep-1',
name: 'Test Endpoint',
url: 'https://api.example.com',
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-02',
settings: {},
tenant_id: 'tenant-1',
plugin_id: 'plugin-1',
expired_at: '',
hook_id: 'hook-1',
declaration: {
settings: [],
endpoints: [
{ path: '/api/test', method: 'GET' },
{ path: '/api/hidden', method: 'POST', hidden: true },
],
},
}
const mockPluginDetail: PluginDetail = {
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}
describe('EndpointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset failure flags
failureFlags.enable = false
failureFlags.disable = false
failureFlags.delete = false
failureFlags.update = false
// Mock Toast.notify to prevent toast elements from accumulating in DOM
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render endpoint name', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
it('should render visible endpoints only', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('GET')).toBeInTheDocument()
expect(screen.getByText('https://api.example.com/api/test')).toBeInTheDocument()
expect(screen.queryByText('POST')).not.toBeInTheDocument()
})
it('should show active status when enabled', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
})
it('should show disabled status when not enabled', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray')
})
})
describe('User Interactions', () => {
it('should show disable confirm when switching off', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
})
it('should call disableEndpoint when confirm disable', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
// Click confirm button in the Confirm dialog
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1')
})
it('should show delete confirm when delete clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find delete button by its destructive class
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
})
it('should call deleteEndpoint when confirm delete', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
})
it('should show edit modal when edit clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
it('should call updateEndpoint when save in modal', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
})
})
describe('Copy Functionality', () => {
it('should reset copy state after timeout', async () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find copy button by its class
const allButtons = screen.getAllByRole('button')
const copyButton = allButtons.find(btn => btn.classList.contains('ml-2'))
expect(copyButton).toBeDefined()
if (copyButton) {
fireEvent.click(copyButton)
act(() => {
vi.advanceTimersByTime(2000)
})
// After timeout, the component should still be rendered correctly
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty endpoints', () => {
const dataWithNoEndpoints = {
...mockEndpointData,
declaration: { settings: [], endpoints: [] },
}
render(<EndpointCard pluginDetail={mockPluginDetail} data={dataWithNoEndpoints} handleChange={mockHandleChange} />)
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
it('should call handleChange after enable', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockHandleChange).toHaveBeenCalled()
})
it('should hide disable confirm and revert state when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
// Confirm should be hidden
expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument()
})
it('should hide delete confirm when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument()
})
it('should hide edit modal when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
})
})
describe('Error Handling', () => {
it('should show error toast when enable fails', () => {
failureFlags.enable = true
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockEnableEndpoint).toHaveBeenCalled()
})
it('should show error toast when disable fails', () => {
failureFlags.disable = true
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDisableEndpoint).toHaveBeenCalled()
})
it('should show error toast when delete fails', () => {
failureFlags.delete = true
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalled()
})
it('should show error toast when update fails', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
expect(editButton).toBeDefined()
if (editButton)
fireEvent.click(editButton)
// Verify modal is open
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
// Set failure flag before save is clicked
failureFlags.update = true
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
// On error, handleChange is not called
expect(mockHandleChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,222 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EndpointList from './endpoint-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
const mockEndpoints = [
{ id: 'ep-1', name: 'Endpoint 1', url: 'https://api.example.com', declaration: { settings: [], endpoints: [] } },
]
let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined
const mockInvalidateEndpointList = vi.fn()
const mockCreateEndpoint = vi.fn()
vi.mock('@/service/use-endpoints', () => ({
useEndpointList: () => ({ data: mockEndpointListData }),
useInvalidateEndpointList: () => mockInvalidateEndpointList,
useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({
mutate: (data: unknown) => {
mockCreateEndpoint(data)
onSuccess()
},
}),
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
}))
vi.mock('./endpoint-card', () => ({
default: ({ data }: { data: { name: string } }) => (
<div data-testid="endpoint-card">{data.name}</div>
),
}))
vi.mock('./endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}>Save</button>
</div>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
endpoint: { settings: [], endpoints: [] },
tool: undefined,
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('EndpointList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEndpointListData = { endpoints: mockEndpoints }
})
describe('Rendering', () => {
it('should render endpoint list', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
})
it('should render endpoint cards', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByTestId('endpoint-card')).toBeInTheDocument()
expect(screen.getByText('Endpoint 1')).toBeInTheDocument()
})
it('should return null when no data', () => {
mockEndpointListData = undefined
const { container } = render(<EndpointList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should show empty message when no endpoints', () => {
mockEndpointListData = { endpoints: [] }
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument()
})
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('User Interactions', () => {
it('should show modal when add button clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
it('should hide modal when cancel clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
})
it('should call createEndpoint when save clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalled()
})
})
describe('Border Style', () => {
it('should render with border style based on tool existence', () => {
const detail = createPluginDetail()
detail.declaration.tool = {} as PluginDetail['declaration']['tool']
render(<EndpointList detail={detail} />)
// Verify the component renders correctly
expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
})
})
describe('Multiple Endpoints', () => {
it('should render multiple endpoint cards', () => {
mockEndpointListData = {
endpoints: [
{ id: 'ep-1', name: 'Endpoint 1', url: 'https://api1.example.com', declaration: { settings: [], endpoints: [] } },
{ id: 'ep-2', name: 'Endpoint 2', url: 'https://api2.example.com', declaration: { settings: [], endpoints: [] } },
],
}
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2)
})
})
describe('Tooltip', () => {
it('should render with tooltip content', () => {
render(<EndpointList detail={createPluginDetail()} />)
// Tooltip is rendered - the add button should be visible
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('Create Endpoint Flow', () => {
it('should invalidate endpoint list after successful create', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
})
it('should pass correct params to createEndpoint', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalledWith({
pluginUniqueID: 'test-uid',
state: { name: 'New Endpoint' },
})
})
})
})

View File

@@ -0,0 +1,519 @@
import type { FormSchema } from '../../base/form/types'
import type { PluginDetail } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointModal from './endpoint-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (opts?.field)
return `${key}: ${opts.field}`
return key
},
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string> | string) =>
typeof obj === 'string' ? obj : obj?.en_US || '',
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
default: ({ value, onChange, fieldMoreInfo }: {
value: Record<string, unknown>
onChange: (v: Record<string, unknown>) => void
fieldMoreInfo?: (item: { url?: string }) => React.ReactNode
}) => {
return (
<div data-testid="form">
<input
data-testid="form-input"
value={value.name as string || ''}
onChange={e => onChange({ ...value, name: e.target.value })}
/>
{/* Render fieldMoreInfo to test url link */}
{fieldMoreInfo && (
<div data-testid="field-more-info">
{fieldMoreInfo({ url: 'https://example.com' })}
{fieldMoreInfo({})}
</div>
)}
</div>
)
},
}))
vi.mock('../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
const mockFormSchemas = [
{ name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' },
{ name: 'apiKey', label: { en_US: 'API Key' }, type: 'secret-input', required: false, default: '' },
] as unknown as FormSchema[]
const mockPluginDetail: PluginDetail = {
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}
describe('EndpointModal', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
let mockToastNotify: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {
it('should render drawer', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render title and description', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument()
expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument()
})
it('should render form with fieldMoreInfo url link', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('field-more-info')).toBeInTheDocument()
// Should render the "howToGet" link when url exists
expect(screen.getByText('howToGet')).toBeInTheDocument()
})
it('should render readme entrance', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCancel when cancel clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
// Find the close button (ActionButton with RiCloseLine icon)
const allButtons = screen.getAllByRole('button')
const closeButton = allButtons.find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should update form value when input changes', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
const input = screen.getByTestId('form-input')
fireEvent.change(input, { target: { value: 'Test Name' } })
expect(input).toHaveValue('Test Name')
})
})
describe('Default Values', () => {
it('should use defaultValues when provided', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Default Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form-input')).toHaveValue('Default Name')
})
it('should extract default values from schemas when no defaultValues', () => {
const schemasWithDefaults = [
{ name: 'name', label: 'Name', type: 'text-input', required: true, default: 'Schema Default' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithDefaults}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form-input')).toHaveValue('Schema Default')
})
it('should handle schemas without default values', () => {
const schemasNoDefault = [
{ name: 'name', label: 'Name', type: 'text-input', required: false },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasNoDefault}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form')).toBeInTheDocument()
})
})
describe('Validation - handleSave', () => {
it('should show toast error when required field is empty', () => {
const schemasWithRequired = [
{ name: 'name', label: { en_US: 'Name Field' }, type: 'text-input', required: true, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithRequired}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('errorMsg.fieldRequired'),
})
expect(mockOnSaved).not.toHaveBeenCalled()
})
it('should show toast error with string label when required field is empty', () => {
const schemasWithStringLabel = [
{ name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithStringLabel}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('String Label'),
})
})
it('should call onSaved when all required fields are filled', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Valid Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' })
})
it('should not validate non-required empty fields', () => {
const schemasOptional = [
{ name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasOptional}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).not.toHaveBeenCalled()
expect(mockOnSaved).toHaveBeenCalled()
})
})
describe('Boolean Field Processing', () => {
it('should convert string "true" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'true' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "1" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: '1' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "True" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'True' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "false" to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'false' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should convert number 1 to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 1 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert number 0 to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 0 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should preserve boolean true value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: true }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should preserve boolean false value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: false }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should not process non-boolean fields', () => {
const schemasWithText = [
{ name: 'text', label: 'Text', type: 'text-input', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithText}
defaultValues={{ text: 'hello' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' })
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(EndpointModal).toBeDefined()
expect((EndpointModal as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ModelList from './model-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} models`
return key
},
}),
}))
const mockModels = [
{ model: 'gpt-4', provider: 'openai' },
{ model: 'gpt-3.5', provider: 'openai' },
]
let mockModelListResponse: { data: typeof mockModels } | undefined
vi.mock('@/service/use-models', () => ({
useModelProviderModelList: () => ({
data: mockModelListResponse,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => (
<span data-testid="model-icon">{modelName}</span>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => (
<span data-testid="model-name">{modelItem.model}</span>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
model: { provider: 'openai' },
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('ModelList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelListResponse = { data: mockModels }
})
describe('Rendering', () => {
it('should render model list when data is available', () => {
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getByText('2 models')).toBeInTheDocument()
})
it('should render model icons and names', () => {
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getAllByTestId('model-icon')).toHaveLength(2)
expect(screen.getAllByTestId('model-name')).toHaveLength(2)
// Both icon and name show the model name, so use getAllByText
expect(screen.getAllByText('gpt-4')).toHaveLength(2)
expect(screen.getAllByText('gpt-3.5')).toHaveLength(2)
})
it('should return null when no data', () => {
mockModelListResponse = undefined
const { container } = render(<ModelList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should handle empty model list', () => {
mockModelListResponse = { data: [] }
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getByText('0 models')).toBeInTheDocument()
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,215 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../types'
import OperationDropdown from './operation-dropdown'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T =>
selector({ systemFeatures: { enable_marketplace: true } }),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
<button data-testid="action-button" className={className} onClick={onClick}>
{children}
</button>
),
}))
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}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
describe('OperationDropdown', () => {
const mockOnInfo = vi.fn()
const mockOnCheckVersion = vi.fn()
const mockOnRemove = vi.fn()
const defaultProps = {
source: PluginSource.github,
detailUrl: 'https://github.com/test/repo',
onInfo: mockOnInfo,
onCheckVersion: mockOnCheckVersion,
onRemove: mockOnRemove,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render trigger button', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByTestId('action-button')).toBeInTheDocument()
})
it('should render dropdown content', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should render info option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument()
})
it('should render check update option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument()
})
it('should render view detail option for github source with marketplace enabled', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
})
it('should render view detail option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
})
it('should always render remove option', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
})
it('should not render info option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument()
})
it('should not render check update option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument()
})
it('should not render view detail for local source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.local} />)
expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
})
it('should not render view detail for debugging source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.debugging} />)
expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// The portal-elem should reflect the open state
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should call onInfo when info option is clicked', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
fireEvent.click(screen.getByText('detailPanel.operation.info'))
expect(mockOnInfo).toHaveBeenCalledTimes(1)
})
it('should call onCheckVersion when check update option is clicked', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate'))
expect(mockOnCheckVersion).toHaveBeenCalledTimes(1)
})
it('should call onRemove when remove option is clicked', () => {
render(<OperationDropdown {...defaultProps} />)
fireEvent.click(screen.getByText('detailPanel.operation.remove'))
expect(mockOnRemove).toHaveBeenCalledTimes(1)
})
it('should have correct href for view detail link', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
expect(link).toHaveAttribute('href', 'https://github.com/test/repo')
expect(link).toHaveAttribute('target', '_blank')
})
})
describe('Props Variations', () => {
it('should handle all plugin sources', () => {
const sources = [
PluginSource.github,
PluginSource.marketplace,
PluginSource.local,
PluginSource.debugging,
]
sources.forEach((source) => {
const { unmount } = render(
<OperationDropdown {...defaultProps} source={source} />,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
unmount()
})
})
it('should handle different detail URLs', () => {
const urls = [
'https://github.com/owner/repo',
'https://marketplace.example.com/plugin/123',
]
urls.forEach((url) => {
const { unmount } = render(
<OperationDropdown {...defaultProps} detailUrl={url} source={PluginSource.github} />,
)
const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
expect(link).toHaveAttribute('href', url)
unmount()
})
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Verify the component is exported as a memo component
expect(OperationDropdown).toBeDefined()
// React.memo wraps the component, so it should have $$typeof
expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})
})

View File

@@ -0,0 +1,461 @@
import type { SimpleDetail } from './store'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { usePluginStore } from './store'
// Factory function to create mock SimpleDetail
const createSimpleDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
plugin_id: 'test-plugin-id',
name: 'Test Plugin',
plugin_unique_identifier: 'test-plugin-uid',
id: 'test-id',
provider: 'test-provider',
declaration: {
category: 'tool' as SimpleDetail['declaration']['category'],
name: 'test-declaration',
},
...overrides,
})
describe('usePluginStore', () => {
beforeEach(() => {
// Reset store state before each test
const { result } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(undefined)
})
})
describe('Initial State', () => {
it('should have undefined detail initially', () => {
const { result } = renderHook(() => usePluginStore())
expect(result.current.detail).toBeUndefined()
})
it('should provide setDetail function', () => {
const { result } = renderHook(() => usePluginStore())
expect(typeof result.current.setDetail).toBe('function')
})
})
describe('setDetail', () => {
it('should set detail with valid SimpleDetail', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toEqual(detail)
})
it('should set detail to undefined', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
// First set a value
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toEqual(detail)
// Then clear it
act(() => {
result.current.setDetail(undefined)
})
expect(result.current.detail).toBeUndefined()
})
it('should update detail when called multiple times', () => {
const { result } = renderHook(() => usePluginStore())
const detail1 = createSimpleDetail({ plugin_id: 'plugin-1' })
const detail2 = createSimpleDetail({ plugin_id: 'plugin-2' })
act(() => {
result.current.setDetail(detail1)
})
expect(result.current.detail?.plugin_id).toBe('plugin-1')
act(() => {
result.current.setDetail(detail2)
})
expect(result.current.detail?.plugin_id).toBe('plugin-2')
})
it('should handle detail with trigger declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: null,
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger).toEqual({
subscription_schema: [],
subscription_constructor: null,
})
})
it('should handle detail with partial declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
name: 'partial-plugin',
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.name).toBe('partial-plugin')
})
})
describe('Store Sharing', () => {
it('should share state across multiple hook instances', () => {
const { result: result1 } = renderHook(() => usePluginStore())
const { result: result2 } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result1.current.setDetail(detail)
})
// Both hooks should see the same state
expect(result1.current.detail).toEqual(detail)
expect(result2.current.detail).toEqual(detail)
})
it('should update all hook instances when state changes', () => {
const { result: result1 } = renderHook(() => usePluginStore())
const { result: result2 } = renderHook(() => usePluginStore())
const detail1 = createSimpleDetail({ name: 'Plugin One' })
const detail2 = createSimpleDetail({ name: 'Plugin Two' })
act(() => {
result1.current.setDetail(detail1)
})
expect(result1.current.detail?.name).toBe('Plugin One')
expect(result2.current.detail?.name).toBe('Plugin One')
act(() => {
result2.current.setDetail(detail2)
})
expect(result1.current.detail?.name).toBe('Plugin Two')
expect(result2.current.detail?.name).toBe('Plugin Two')
})
})
describe('Selector Pattern', () => {
// Extract selectors to reduce nesting depth
const selectDetail = (state: ReturnType<typeof usePluginStore.getState>) => state.detail
const selectSetDetail = (state: ReturnType<typeof usePluginStore.getState>) => state.setDetail
it('should support selector to get specific field', () => {
const { result: setterResult } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({ plugin_id: 'selected-plugin' })
act(() => {
setterResult.current.setDetail(detail)
})
// Use selector to get only detail
const { result: selectorResult } = renderHook(() => usePluginStore(selectDetail))
expect(selectorResult.current?.plugin_id).toBe('selected-plugin')
})
it('should support selector to get setDetail function', () => {
const { result } = renderHook(() => usePluginStore(selectSetDetail))
expect(typeof result.current).toBe('function')
})
})
describe('Edge Cases', () => {
it('should handle empty string values in detail', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
plugin_id: '',
name: '',
plugin_unique_identifier: '',
provider: '',
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.plugin_id).toBe('')
expect(result.current.detail?.name).toBe('')
})
it('should handle detail with empty declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration).toEqual({})
})
it('should handle rapid state updates', () => {
const { result } = renderHook(() => usePluginStore())
act(() => {
for (let i = 0; i < 10; i++)
result.current.setDetail(createSimpleDetail({ plugin_id: `plugin-${i}` }))
})
expect(result.current.detail?.plugin_id).toBe('plugin-9')
})
it('should handle setDetail called without arguments', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toBeDefined()
act(() => {
result.current.setDetail()
})
expect(result.current.detail).toBeUndefined()
})
})
describe('Type Safety', () => {
it('should preserve all SimpleDetail fields correctly', () => {
const { result } = renderHook(() => usePluginStore())
const detail: SimpleDetail = {
plugin_id: 'type-test-id',
name: 'Type Test Plugin',
plugin_unique_identifier: 'type-test-uid',
id: 'type-id',
provider: 'type-provider',
declaration: {
category: 'model' as SimpleDetail['declaration']['category'],
name: 'type-declaration',
version: '2.0.0',
author: 'test-author',
},
}
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toStrictEqual(detail)
expect(result.current.detail?.plugin_id).toBe('type-test-id')
expect(result.current.detail?.name).toBe('Type Test Plugin')
expect(result.current.detail?.plugin_unique_identifier).toBe('type-test-uid')
expect(result.current.detail?.id).toBe('type-id')
expect(result.current.detail?.provider).toBe('type-provider')
})
it('should handle declaration with subscription_constructor', () => {
const { result } = renderHook(() => usePluginStore())
const mockConstructor = {
credentials_schema: [],
oauth_schema: {
client_schema: [],
credentials_schema: [],
},
parameters: [],
}
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: mockConstructor as unknown as NonNullable<SimpleDetail['declaration']['trigger']>['subscription_constructor'],
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger?.subscription_constructor).toBeDefined()
})
it('should handle declaration with subscription_schema', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: null,
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger?.subscription_schema).toEqual([])
})
})
describe('State Persistence', () => {
it('should maintain state after multiple renders', () => {
const detail = createSimpleDetail({ name: 'Persistent Plugin' })
const { result, rerender } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(detail)
})
// Rerender multiple times
rerender()
rerender()
rerender()
expect(result.current.detail?.name).toBe('Persistent Plugin')
})
it('should maintain reference equality for unchanged state', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
const firstDetailRef = result.current.detail
// Get state again without changing
const { result: result2 } = renderHook(() => usePluginStore())
expect(result2.current.detail).toBe(firstDetailRef)
})
})
describe('Concurrent Updates', () => {
it('should handle updates from multiple sources correctly', () => {
const { result: hook1 } = renderHook(() => usePluginStore())
const { result: hook2 } = renderHook(() => usePluginStore())
const { result: hook3 } = renderHook(() => usePluginStore())
act(() => {
hook1.current.setDetail(createSimpleDetail({ name: 'From Hook 1' }))
})
act(() => {
hook2.current.setDetail(createSimpleDetail({ name: 'From Hook 2' }))
})
act(() => {
hook3.current.setDetail(createSimpleDetail({ name: 'From Hook 3' }))
})
// All hooks should reflect the last update
expect(hook1.current.detail?.name).toBe('From Hook 3')
expect(hook2.current.detail?.name).toBe('From Hook 3')
expect(hook3.current.detail?.name).toBe('From Hook 3')
})
it('should handle interleaved read and write operations', () => {
const { result } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-1' }))
})
expect(result.current.detail?.plugin_id).toBe('step-1')
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-2' }))
})
expect(result.current.detail?.plugin_id).toBe('step-2')
act(() => {
result.current.setDetail(undefined)
})
expect(result.current.detail).toBeUndefined()
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-3' }))
})
expect(result.current.detail?.plugin_id).toBe('step-3')
})
})
describe('Declaration Variations', () => {
it('should handle declaration with all optional fields', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
category: 'extension' as SimpleDetail['declaration']['category'],
name: 'full-declaration',
version: '1.0.0',
author: 'full-author',
icon: 'icon.png',
verified: true,
tags: ['tag1', 'tag2'],
},
})
act(() => {
result.current.setDetail(detail)
})
const decl = result.current.detail?.declaration
expect(decl?.category).toBe('extension')
expect(decl?.name).toBe('full-declaration')
expect(decl?.version).toBe('1.0.0')
expect(decl?.author).toBe('full-author')
expect(decl?.icon).toBe('icon.png')
expect(decl?.verified).toBe(true)
expect(decl?.tags).toEqual(['tag1', 'tag2'])
})
it('should handle declaration with nested tool object', () => {
const { result } = renderHook(() => usePluginStore())
const mockTool = {
identity: {
author: 'tool-author',
name: 'tool-name',
icon: 'tool-icon.png',
tags: ['api', 'utility'],
},
credentials_schema: [],
}
const detail = createSimpleDetail({
declaration: {
tool: mockTool as unknown as SimpleDetail['declaration']['tool'],
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.tool?.identity.name).toBe('tool-name')
expect(result.current.detail?.declaration.tool?.identity.tags).toEqual(['api', 'utility'])
})
})
})

View File

@@ -0,0 +1,203 @@
import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyDetail from './strategy-detail'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
type ProviderType = Parameters<typeof StrategyDetail>[0]['provider']
const mockProvider = {
author: 'test-author',
name: 'test-provider',
description: { en_US: 'Provider desc' },
tenant_id: 'tenant-1',
icon: 'icon.png',
label: { en_US: 'Test Provider' },
tags: [],
} as unknown as ProviderType
const mockDetail = {
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy Label' },
provider: 'provider-1',
},
parameters: [
{
name: 'param1',
label: { en_US: 'Parameter 1' },
type: 'text-input',
required: true,
human_description: { en_US: 'A text parameter' },
},
],
description: { en_US: 'Strategy description' },
output_schema: {
properties: {
result: { type: 'string', description: 'Result output' },
items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
},
},
features: [],
} as unknown as StrategyDetailType
describe('StrategyDetail', () => {
const mockOnHide = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render drawer', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render provider label', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('Test Provider')).toBeInTheDocument()
})
it('should render strategy label', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
it('should render parameters section', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
})
it('should render output schema section', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
expect(screen.getByText('result')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should render BACK button', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('BACK')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHide when close button clicked', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
// Find the close button (ActionButton with action-btn class)
const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when BACK clicked', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
fireEvent.click(screen.getByText('BACK'))
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
describe('Parameter Types', () => {
it('should display correct type for number-input', () => {
const detailWithNumber = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'number-input' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
})
it('should display correct type for checkbox', () => {
const detailWithCheckbox = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'checkbox' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithCheckbox} onHide={mockOnHide} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display correct type for file', () => {
const detailWithFile = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'file' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithFile} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
})
it('should display correct type for array[tools]', () => {
const detailWithArrayTools = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'array[tools]' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithArrayTools} onHide={mockOnHide} />)
expect(screen.getByText('multiple-tool-select')).toBeInTheDocument()
})
it('should display original type for unknown types', () => {
const detailWithUnknown = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'custom-type' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithUnknown} onHide={mockOnHide} />)
expect(screen.getByText('custom-type')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty parameters', () => {
const detailEmpty = { ...mockDetail, parameters: [] }
render(<StrategyDetail provider={mockProvider} detail={detailEmpty} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
})
it('should handle no output schema', () => {
const detailNoOutput = { ...mockDetail, output_schema: undefined as unknown as Record<string, unknown> }
render(<StrategyDetail provider={mockProvider} detail={detailNoOutput} onHide={mockOnHide} />)
expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,102 @@
import type { StrategyDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyItem from './strategy-item'
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('./strategy-detail', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="strategy-detail-panel">
<button data-testid="hide-btn" onClick={onHide}>Hide</button>
</div>
),
}))
const mockProvider = {
author: 'test-author',
name: 'test-provider',
description: { en_US: 'Provider desc' } as Record<string, string>,
tenant_id: 'tenant-1',
icon: 'icon.png',
label: { en_US: 'Test Provider' } as Record<string, string>,
tags: [] as string[],
}
const mockDetail = {
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy Label' } as Record<string, string>,
provider: 'provider-1',
},
parameters: [],
description: { en_US: 'Strategy description' } as Record<string, string>,
output_schema: {},
features: [],
} as StrategyDetail
describe('StrategyItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render strategy label', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
it('should render strategy description', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.getByText('Strategy description')).toBeInTheDocument()
})
it('should not show detail panel initially', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show detail panel when clicked', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
fireEvent.click(screen.getByText('Strategy Label'))
expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
})
it('should hide detail panel when hide is called', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
fireEvent.click(screen.getByText('Strategy Label'))
expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-btn'))
expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle empty description', () => {
const detailWithEmptyDesc = {
...mockDetail,
description: { en_US: '' } as Record<string, string>,
} as StrategyDetail
render(<StrategyItem provider={mockProvider} detail={detailWithEmptyDesc} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
})
})

View File

@@ -1874,4 +1874,187 @@ describe('CommonCreateModal', () => {
expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true')
})
})
describe('normalizeFormType Additional Branches', () => {
it('should handle "text" type by returning textInput', () => {
const detailWithText = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [],
parameters: [
{ name: 'text_type_field', type: 'text' },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithText)
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />)
expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument()
})
it('should handle "secret" type by returning secretInput', () => {
const detailWithSecret = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [],
parameters: [
{ name: 'secret_type_field', type: 'secret' },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithSecret)
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />)
expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument()
})
})
describe('HandleManualPropertiesChange Provider Fallback', () => {
it('should not call updateBuilder when provider is empty', async () => {
const detailWithEmptyProvider = createMockPluginDetail({
provider: '',
declaration: {
trigger: {
subscription_schema: [
{ name: 'webhook_url', type: 'text', required: true },
],
subscription_constructor: {
credentials_schema: [],
parameters: [],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithEmptyProvider)
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
// updateBuilder should not be called when provider is empty
expect(mockUpdateBuilder).not.toHaveBeenCalled()
})
})
describe('Configuration Step Without Endpoint', () => {
it('should handle builder without endpoint', async () => {
const builderWithoutEndpoint = createMockSubscriptionBuilder({
endpoint: '',
})
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builderWithoutEndpoint} />)
// Component should render without errors
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('ApiKeyStep Flow Additional Coverage', () => {
it('should handle verify when no builder created yet', async () => {
const detailWithCredentials = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [
{ name: 'api_key', type: 'secret', required: true },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithCredentials)
// Make createBuilder slow
mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
render(<CommonCreateModal {...defaultProps} />)
// Click verify before builder is created
fireEvent.click(screen.getByTestId('modal-confirm'))
// Should still attempt to verify
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('Auto Parameters Not For APIKEY in Configuration', () => {
it('should include parameters for APIKEY in configuration step', async () => {
const detailWithParams = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [
{ name: 'api_key', type: 'secret', required: true },
],
parameters: [
{ name: 'extra_param', type: 'string', required: true },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithParams)
// First verify credentials
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} builder={builder} />)
// Click verify
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockVerifyCredentials).toHaveBeenCalled()
})
// Now in configuration step, should see extra_param
expect(screen.getByTestId('form-field-extra_param')).toBeInTheDocument()
})
})
describe('needCheckValidatedValues Option', () => {
it('should pass needCheckValidatedValues: false for manual properties', async () => {
const detailWithManualSchema = createMockPluginDetail({
declaration: {
trigger: {
subscription_schema: [
{ name: 'webhook_url', type: 'text', required: true },
],
subscription_constructor: {
credentials_schema: [],
parameters: [],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithManualSchema)
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'test' } })
await waitFor(() => {
expect(mockUpdateBuilder).toHaveBeenCalled()
})
})
})
})

View File

@@ -1475,4 +1475,213 @@ describe('CreateSubscriptionButton', () => {
})
})
})
// ==================== OAuth Callback Edge Cases ====================
describe('OAuth Callback - Falsy Data', () => {
it('should not open modal when OAuth callback returns falsy data', async () => {
// Arrange
const { openOAuthPopup } = await import('@/hooks/use-oauth')
vi.mocked(openOAuthPopup).mockImplementation((url: string, callback: (data?: unknown) => void) => {
callback(undefined) // falsy callback data
return null
})
const mockBuilder: TriggerSubscriptionBuilder = {
id: 'oauth-builder',
name: 'OAuth Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
properties: {},
workflows_in_use: 0,
}
mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onSuccess: (response: { authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }) => void }) => {
callbacks.onSuccess({
authorization_url: 'https://oauth.test.com/authorize',
subscription_builder: mockBuilder,
})
})
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
}),
oauthConfig: createOAuthConfig({ configured: true }),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// Click on OAuth option
const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
fireEvent.click(oauthOption)
// Assert - modal should NOT open because callback data was falsy
await waitFor(() => {
expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
})
})
})
// ==================== TriggerProps ClassName Branches ====================
describe('TriggerProps ClassName Branches', () => {
it('should apply pointer-events-none when non-default method with multiple supported methods', () => {
// Arrange - Single APIKEY method (methodType = APIKEY, not DEFAULT_METHOD)
// But we need multiple methods to test this branch
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL],
}),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// The methodType will be DEFAULT_METHOD since multiple methods
// This verifies the render doesn't crash with multiple methods
expect(screen.getByTestId('custom-select')).toHaveAttribute('data-value', 'default')
})
})
// ==================== Tooltip Disabled Branches ====================
describe('Tooltip Disabled Branches', () => {
it('should enable tooltip when single method and not at max count', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: [createSubscription()], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - tooltip should be enabled (disabled prop = false for single method)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
it('should disable tooltip when multiple methods and not at max count', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
}),
subscriptions: [createSubscription()], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - tooltip should be disabled (neither single method nor at max)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
// ==================== Tooltip PopupContent Branches ====================
describe('Tooltip PopupContent Branches', () => {
it('should show max count message when at max subscriptions', () => {
// Arrange
const maxSubscriptions = createMaxSubscriptions()
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: maxSubscriptions,
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - component renders with max subscriptions
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
it('should show method description when not at max', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: [], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - component renders without max subscriptions
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
// ==================== Provider Info Fallbacks ====================
describe('Provider Info Fallbacks', () => {
it('should handle undefined supported_creation_methods', () => {
// Arrange - providerInfo with undefined supported_creation_methods
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: {
...createProviderInfo(),
supported_creation_methods: undefined as unknown as SupportedCreationMethods[],
},
})
const props = createDefaultProps()
// Act
const { container } = render(<CreateSubscriptionButton {...props} />)
// Assert - should render null when supported methods fallback to empty
expect(container).toBeEmptyDOMElement()
})
it('should handle providerInfo with null supported_creation_methods', () => {
// Arrange
mockProviderInfo = { data: { ...createProviderInfo(), supported_creation_methods: null as unknown as SupportedCreationMethods[] } }
mockOAuthConfig = { data: undefined, refetch: vi.fn() }
mockStoreDetail = createStoreDetail()
const props = createDefaultProps()
// Act
const { container } = render(<CreateSubscriptionButton {...props} />)
// Assert - should render null
expect(container).toBeEmptyDOMElement()
})
})
// ==================== Method Type Logic ====================
describe('Method Type Logic', () => {
it('should use single method as methodType when only one supported', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.APIKEY],
}),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert
const customSelect = screen.getByTestId('custom-select')
expect(customSelect).toHaveAttribute('data-value', SupportedCreationMethods.APIKEY)
})
})
})

View File

@@ -1240,4 +1240,60 @@ describe('OAuthClientSettingsModal', () => {
vi.useRealTimers()
})
})
describe('OAuth Client Schema Params Fallback', () => {
it('should handle schema when params is truthy but schema name not in params', () => {
const configWithSchemaNotInParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: {
client_id: 'test-id',
client_secret: 'test-secret',
},
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
{ name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
{ name: 'extra_field', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithSchemaNotInParams} />)
// extra_field should be rendered but without default value
const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement
expect(extraInput.defaultValue).toBe('')
})
it('should handle oauth_client_schema with undefined params', () => {
const configWithUndefinedParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: undefined as unknown as TriggerOAuthConfig['params'],
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithUndefinedParams} />)
// Form should not render because params is undefined (schema condition fails)
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
it('should handle oauth_client_schema with null params', () => {
const configWithNullParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: null as unknown as TriggerOAuthConfig['params'],
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithNullParams} />)
// Form should not render because params is null
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,287 @@
import type { TriggerEvent } from '@/app/components/plugins/types'
import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventDetailDrawer } from './event-detail-drawer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName }: { orgName: string }) => <div data-testid="org-info">{orgName}</div>,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
triggerEventParametersToFormSchemas: (params: Array<Record<string, unknown>>) =>
params.map(p => ({
label: (p.label as Record<string, string>) || { en_US: p.name as string },
type: (p.type as string) || 'text-input',
required: (p.required as boolean) || false,
description: p.description as Record<string, string> | undefined,
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({
default: ({ name }: { name: string }) => <div data-testid="output-field">{name}</div>,
}))
const mockEventInfo = {
name: 'test-event',
identity: {
author: 'test-author',
name: 'test-event',
label: { en_US: 'Test Event' },
},
description: { en_US: 'Test event description' },
parameters: [
{
name: 'param1',
label: { en_US: 'Parameter 1' },
type: 'text-input',
auto_generate: null,
template: null,
scope: null,
required: true,
multiple: false,
default: null,
min: null,
max: null,
precision: null,
description: { en_US: 'A test parameter' },
},
],
output_schema: {
properties: {
result: { type: 'string', description: 'Result' },
},
required: ['result'],
},
} as unknown as TriggerEvent
const mockProviderInfo = {
provider: 'test-provider',
author: 'test-author',
name: 'test-provider/test-name',
icon: 'icon.png',
description: { en_US: 'Provider desc' },
supported_creation_methods: [],
} as unknown as TriggerProviderApiEntity
describe('EventDetailDrawer', () => {
const mockOnClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render drawer', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render event title', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('Test Event')).toBeInTheDocument()
})
it('should render event description', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByTestId('description')).toHaveTextContent('Test event description')
})
it('should render org info', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByTestId('org-info')).toBeInTheDocument()
})
it('should render parameters section', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
})
it('should render output section', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
expect(screen.getByTestId('output-field')).toHaveTextContent('result')
})
it('should render back button', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button clicked', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
// Find the close button (ActionButton with action-btn class)
const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when back clicked', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
fireEvent.click(screen.getByText('detailPanel.operation.back'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle no parameters', () => {
const eventWithNoParams = { ...mockEventInfo, parameters: [] }
render(<EventDetailDrawer eventInfo={eventWithNoParams} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.item.noParameters')).toBeInTheDocument()
})
it('should handle no output schema', () => {
const eventWithNoOutput = { ...mockEventInfo, output_schema: {} }
render(<EventDetailDrawer eventInfo={eventWithNoOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
expect(screen.queryByTestId('output-field')).not.toBeInTheDocument()
})
})
describe('Parameter Types', () => {
it('should display correct type for number-input', () => {
const eventWithNumber = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'number-input' }],
}
render(<EventDetailDrawer eventInfo={eventWithNumber} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
})
it('should display correct type for checkbox', () => {
const eventWithCheckbox = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'checkbox' }],
}
render(<EventDetailDrawer eventInfo={eventWithCheckbox} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display correct type for file', () => {
const eventWithFile = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'file' }],
}
render(<EventDetailDrawer eventInfo={eventWithFile} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
})
it('should display original type for unknown types', () => {
const eventWithUnknown = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'custom-type' }],
}
render(<EventDetailDrawer eventInfo={eventWithUnknown} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('custom-type')).toBeInTheDocument()
})
})
describe('Output Schema Conversion', () => {
it('should handle array type in output schema', () => {
const eventWithArrayOutput = {
...mockEventInfo,
output_schema: {
properties: {
items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithArrayOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle nested properties in output schema', () => {
const eventWithNestedOutput = {
...mockEventInfo,
output_schema: {
properties: {
nested: {
type: 'object',
properties: { inner: { type: 'string' } },
required: ['inner'],
},
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithNestedOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle enum in output schema', () => {
const eventWithEnumOutput = {
...mockEventInfo,
output_schema: {
properties: {
status: { type: 'string', enum: ['active', 'inactive'], description: 'Status' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithEnumOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle array type schema', () => {
const eventWithArrayType = {
...mockEventInfo,
output_schema: {
properties: {
multi: { type: ['string', 'null'], description: 'Multi type' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithArrayType} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,146 @@
import type { TriggerEvent } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerEventsList } from './event-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.event || 'events'}`
return key
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
const mockTriggerEvents = [
{
name: 'event-1',
identity: {
author: 'author-1',
name: 'event-1',
label: { en_US: 'Event One' },
},
description: { en_US: 'Event one description' },
parameters: [],
output_schema: {},
},
] as unknown as TriggerEvent[]
let mockDetail: { plugin_id: string, provider: string } | undefined
let mockProviderInfo: { events: TriggerEvent[] } | undefined
vi.mock('../store', () => ({
usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) =>
selector({ detail: mockDetail }),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerProviderInfo: () => ({ data: mockProviderInfo }),
}))
vi.mock('./event-detail-drawer', () => ({
EventDetailDrawer: ({ onClose }: { onClose: () => void }) => (
<div data-testid="event-detail-drawer">
<button data-testid="close-drawer" onClick={onClose}>Close</button>
</div>
),
}))
describe('TriggerEventsList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDetail = { plugin_id: 'test-plugin', provider: 'test-provider' }
mockProviderInfo = { events: mockTriggerEvents }
})
describe('Rendering', () => {
it('should render event count', () => {
render(<TriggerEventsList />)
expect(screen.getByText('1 events.event')).toBeInTheDocument()
})
it('should render event cards', () => {
render(<TriggerEventsList />)
expect(screen.getByText('Event One')).toBeInTheDocument()
expect(screen.getByText('Event one description')).toBeInTheDocument()
})
it('should return null when no provider info', () => {
mockProviderInfo = undefined
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when no events', () => {
mockProviderInfo = { events: [] }
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when no detail', () => {
mockDetail = undefined
mockProviderInfo = undefined
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
})
describe('User Interactions', () => {
it('should show detail drawer when event card clicked', () => {
render(<TriggerEventsList />)
fireEvent.click(screen.getByText('Event One'))
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
})
it('should hide detail drawer when close clicked', () => {
render(<TriggerEventsList />)
fireEvent.click(screen.getByText('Event One'))
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-drawer'))
expect(screen.queryByTestId('event-detail-drawer')).not.toBeInTheDocument()
})
})
describe('Multiple Events', () => {
it('should render multiple event cards', () => {
const secondEvent = {
name: 'event-2',
identity: {
author: 'author-2',
name: 'event-2',
label: { en_US: 'Event Two' },
},
description: { en_US: 'Event two description' },
parameters: [],
output_schema: {},
} as unknown as TriggerEvent
mockProviderInfo = {
events: [...mockTriggerEvents, secondEvent],
}
render(<TriggerEventsList />)
expect(screen.getByText('Event One')).toBeInTheDocument()
expect(screen.getByText('Event Two')).toBeInTheDocument()
expect(screen.getByText('2 events.events')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { NAME_FIELD } from './utils'
describe('utils', () => {
describe('NAME_FIELD', () => {
it('should have correct type', () => {
expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput)
})
it('should have correct name', () => {
expect(NAME_FIELD.name).toBe('name')
})
it('should have label translations', () => {
expect(NAME_FIELD.label).toBeDefined()
expect(NAME_FIELD.label.en_US).toBe('Endpoint Name')
expect(NAME_FIELD.label.zh_Hans).toBe('端点名称')
expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名')
expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final')
})
it('should have placeholder translations', () => {
expect(NAME_FIELD.placeholder).toBeDefined()
expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name')
expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称')
expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名')
expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final')
})
it('should be required', () => {
expect(NAME_FIELD.required).toBe(true)
})
it('should have empty default value', () => {
expect(NAME_FIELD.default).toBe('')
})
it('should have null help', () => {
expect(NAME_FIELD.help).toBeNull()
})
it('should have all required field properties', () => {
const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help']
requiredKeys.forEach((key) => {
expect(NAME_FIELD).toHaveProperty(key)
})
})
it('should match expected structure', () => {
expect(NAME_FIELD).toEqual({
type: FormTypeEnum.textInput,
name: 'name',
label: {
en_US: 'Endpoint Name',
zh_Hans: '端点名称',
ja_JP: 'エンドポイント名',
pt_BR: 'Nome do ponto final',
},
placeholder: {
en_US: 'Endpoint Name',
zh_Hans: '端点名称',
ja_JP: 'エンドポイント名',
pt_BR: 'Nome do ponto final',
},
required: true,
default: '',
help: null,
})
})
})
})