mirror of
https://github.com/langgenius/dify.git
synced 2026-01-15 11:20:01 +00:00
Compare commits
2 Commits
deploy/age
...
test/datas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7aef50c25 | ||
|
|
382110df86 |
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
1144
web/app/components/plugins/plugin-detail-panel/index.spec.tsx
Normal file
1144
web/app/components/plugins/plugin-detail-panel/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
461
web/app/components/plugins/plugin-detail-panel/store.spec.ts
Normal file
461
web/app/components/plugins/plugin-detail-panel/store.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
72
web/app/components/plugins/plugin-detail-panel/utils.spec.ts
Normal file
72
web/app/components/plugins/plugin-detail-panel/utils.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user