Compare commits

..

3 Commits

75 changed files with 5652 additions and 4081 deletions

View File

@@ -197,61 +197,30 @@ describe('AppsFull', () => {
})
describe('Edge Cases', () => {
it('should use the success color when usage is below 50%', () => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 2 }),
total: buildUsage({ buildApps: 5 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
it('should apply distinct progress bar styling at different usage levels', () => {
const renderWithUsage = (used: number, total: number) => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: used }),
total: buildUsage({ buildApps: total }),
reset: { apiRateLimit: null, triggerEvents: null },
},
},
}))
}))
const { unmount } = render(<AppsFull loc="billing_dialog" />)
const className = screen.getByTestId('billing-progress-bar').className
unmount()
return className
}
render(<AppsFull loc="billing_dialog" />)
const normalClass = renderWithUsage(2, 10)
const warningClass = renderWithUsage(6, 10)
const errorClass = renderWithUsage(8, 10)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
})
it('should use the warning color when usage is between 50% and 80%', () => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 6 }),
total: buildUsage({ buildApps: 10 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
},
},
}))
render(<AppsFull loc="billing_dialog" />)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
})
it('should use the error color when usage is 80% or higher', () => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: buildUsage({ buildApps: 8 }),
total: buildUsage({ buildApps: 10 }),
reset: {
apiRateLimit: null,
triggerEvents: null,
},
},
}))
render(<AppsFull loc="billing_dialog" />)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
})
})
})

View File

@@ -70,7 +70,7 @@ describe('HeaderBillingBtn', () => {
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('renders team badge for team plan with correct styling', () => {
it('renders team badge for team plan', () => {
ensureProviderContextMock().mockReturnValueOnce({
plan: { type: Plan.team },
enableBilling: true,
@@ -79,9 +79,7 @@ describe('HeaderBillingBtn', () => {
render(<HeaderBillingBtn />)
const badge = screen.getByText('team').closest('div')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('bg-[#E0EAFF]')
expect(screen.getByText('team')).toBeInTheDocument()
})
it('renders nothing when plan is not fetched', () => {
@@ -111,16 +109,11 @@ describe('HeaderBillingBtn', () => {
const { rerender } = render(<HeaderBillingBtn onClick={onClick} />)
const badge = screen.getByText('pro').closest('div')
expect(badge).toHaveClass('cursor-pointer')
fireEvent.click(badge!)
const badge = screen.getByText('pro').closest('div')!
fireEvent.click(badge)
expect(onClick).toHaveBeenCalledTimes(1)
rerender(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default')
fireEvent.click(screen.getByText('pro').closest('div')!)
expect(onClick).toHaveBeenCalledTimes(1)
})

View File

@@ -47,8 +47,20 @@ describe('PlanSwitcherTab', () => {
expect(handleClick).toHaveBeenCalledWith('self')
})
it('should apply active text class when isActive is true', () => {
render(
it('should apply distinct styling when isActive is true', () => {
const { rerender } = render(
<Tab
Icon={Icon}
value="cloud"
label="Cloud"
isActive={false}
onClick={vi.fn()}
/>,
)
const inactiveClassName = screen.getByText('Cloud').className
rerender(
<Tab
Icon={Icon}
value="cloud"
@@ -58,7 +70,8 @@ describe('PlanSwitcherTab', () => {
/>,
)
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
const activeClassName = screen.getByText('Cloud').className
expect(activeClassName).not.toBe(inactiveClassName)
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
})
})

View File

@@ -7,7 +7,6 @@ describe('ProgressBar', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-test-color')
expect(bar.getAttribute('style')).toContain('width: 42%')
})
@@ -18,11 +17,10 @@ describe('ProgressBar', () => {
expect(bar.getAttribute('style')).toContain('width: 100%')
})
it('uses the default color when no color prop is provided', () => {
it('renders with default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-components-progress-bar-progress-solid')
expect(bar.getAttribute('style')).toContain('width: 20%')
})
})
@@ -31,9 +29,7 @@ describe('ProgressBar', () => {
it('should render indeterminate progress bar when indeterminate is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toBeInTheDocument()
expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should not render normal progress bar when indeterminate is true', () => {
@@ -43,20 +39,20 @@ describe('ProgressBar', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render with default width (w-[30px]) when indeterminateFull is false', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />)
it('should render with different width based on indeterminateFull prop', () => {
const { rerender } = render(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
const partialClassName = bar.className
it('should render with full width (w-full) when indeterminateFull is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />)
rerender(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
expect(bar).not.toHaveClass('w-[30px]')
const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className
expect(partialClassName).not.toBe(fullClassName)
})
})
})

View File

@@ -71,8 +71,19 @@ describe('UsageInfo', () => {
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
it('applies warning color when usage is close to the limit', () => {
render(
it('applies distinct styling when usage is close to or exceeds the limit', () => {
const { rerender } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={100}
/>,
)
const normalBarClass = screen.getByTestId('billing-progress-bar').className
rerender(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -81,12 +92,10 @@ describe('UsageInfo', () => {
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
const warningBarClass = screen.getByTestId('billing-progress-bar').className
expect(warningBarClass).not.toBe(normalBarClass)
it('applies error color when usage exceeds the limit', () => {
render(
rerender(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -95,8 +104,9 @@ describe('UsageInfo', () => {
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
const errorBarClass = screen.getByTestId('billing-progress-bar').className
expect(errorBarClass).not.toBe(normalBarClass)
expect(errorBarClass).not.toBe(warningBarClass)
})
it('does not render the icon when hideIcon is true', () => {
@@ -173,8 +183,8 @@ describe('UsageInfo', () => {
expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1)
})
it('should render full-width indeterminate bar for sandbox users below threshold', () => {
render(
it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => {
const { rerender } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -187,12 +197,9 @@ describe('UsageInfo', () => {
/>,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
})
const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
it('should render narrow indeterminate bar for non-sandbox users below threshold', () => {
render(
rerender(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -205,13 +212,13 @@ describe('UsageInfo', () => {
/>,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
expect(sandboxBarClass).not.toBe(nonSandboxBarClass)
})
})
describe('Sandbox Full Capacity', () => {
it('should render error color progress bar when sandbox usage >= threshold', () => {
it('should render determinate progress bar when sandbox usage >= threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
@@ -225,8 +232,8 @@ describe('UsageInfo', () => {
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => {
@@ -305,9 +312,7 @@ describe('UsageInfo', () => {
/>,
)
// Tooltip wrapper should contain cursor-default class
const tooltipWrapper = container.querySelector('.cursor-default')
expect(tooltipWrapper).toBeInTheDocument()
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
})
})

View File

@@ -61,11 +61,10 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render full-width indeterminate bar for sandbox users', () => {
it('should render indeterminate bar for sandbox users', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should display "< 50" format for sandbox below threshold', () => {
@@ -81,11 +80,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 50
})
it('should render error color progress bar when at full capacity', () => {
it('should render determinate progress bar when at full capacity', () => {
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display "50 / 50 MB" format when at full capacity', () => {
@@ -108,19 +107,10 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width)', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText(/< 50/)).toBeInTheDocument()
// 5 GB = 5120 MB
expect(screen.getByText('5120MB')).toBeInTheDocument()
})
})
@@ -158,14 +148,6 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width)', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
@@ -196,51 +178,24 @@ describe('VectorSpaceInfo', () => {
})
})
describe('Pro/Team Plan Warning State', () => {
it('should show warning color when Professional plan usage approaches limit (80%+)', () => {
describe('Pro/Team Plan Usage States', () => {
const renderAndGetBarClass = (usage: number) => {
mockPlanType = Plan.professional
// 5120 MB * 80% = 4096 MB
mockVectorSpaceUsage = 4100
mockVectorSpaceUsage = usage
const { unmount } = render(<VectorSpaceInfo />)
const className = screen.getByTestId('billing-progress-bar').className
unmount()
return className
}
render(<VectorSpaceInfo />)
it('should show distinct progress bar styling at different usage levels', () => {
const normalClass = renderAndGetBarClass(100)
const warningClass = renderAndGetBarClass(4100)
const errorClass = renderAndGetBarClass(5200)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
it('should show warning color when Team plan usage approaches limit (80%+)', () => {
mockPlanType = Plan.team
// 20480 MB * 80% = 16384 MB
mockVectorSpaceUsage = 16500
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
})
describe('Pro/Team Plan Error State', () => {
it('should show error color when Professional plan usage exceeds limit', () => {
mockPlanType = Plan.professional
// Exceeds 5120 MB
mockVectorSpaceUsage = 5200
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('should show error color when Team plan usage exceeds limit', () => {
mockPlanType = Plan.team
// Exceeds 20480 MB
mockVectorSpaceUsage = 21000
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
})
})
@@ -265,12 +220,10 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width) for enterprise', () => {
it('should render indeterminate bar for enterprise below threshold', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should display "< 50 / total" format when below threshold', () => {

View File

@@ -6,11 +6,8 @@ import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { contactSalesUrl } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { useModalContext } from '@/context/modal-context'
// Get the mocked functions
// const { useProviderContext } = vi.requireMock('@/context/provider-context')
// const { useModalContext } = vi.requireMock('@/context/modal-context')
import { useProviderContext } from '@/context/provider-context'
import CustomPage from './index'
import CustomPage from '../index'
// Mock external dependencies only
vi.mock('@/context/provider-context', () => ({
@@ -23,7 +20,7 @@ vi.mock('@/context/modal-context', () => ({
// Mock the complex CustomWebAppBrand component to avoid dependency issues
// This is acceptable because it has complex dependencies (fetch, APIs)
vi.mock('../custom-web-app-brand', () => ({
vi.mock('@/app/components/custom/custom-web-app-brand', () => ({
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
}))

View File

@@ -7,7 +7,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
import CustomWebAppBrand from './index'
import CustomWebAppBrand from '../index'
vi.mock('@/app/components/base/toast', () => ({
useToastContext: vi.fn(),
@@ -53,8 +53,8 @@ const renderComponent = () => render(<CustomWebAppBrand />)
describe('CustomWebAppBrand', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseToastContext.mockReturnValue({ notify: mockNotify } as any)
mockUpdateCurrentWorkspace.mockResolvedValue({} as any)
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>)
mockUseAppContext.mockReturnValue({
currentWorkspace: {
custom_config: {
@@ -64,7 +64,7 @@ describe('CustomWebAppBrand', () => {
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: true,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
mockUseProviderContext.mockReturnValue({
plan: {
type: Plan.professional,
@@ -73,14 +73,14 @@ describe('CustomWebAppBrand', () => {
reset: {},
},
enableBilling: false,
} as any)
} as unknown as ReturnType<typeof useProviderContext>)
const systemFeaturesState = {
branding: {
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
}
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState } as any) : { systemFeatures: systemFeaturesState })
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState })
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
})
@@ -94,7 +94,7 @@ describe('CustomWebAppBrand', () => {
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: false,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
const { container } = renderComponent()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
@@ -112,7 +112,7 @@ describe('CustomWebAppBrand', () => {
},
mutateCurrentWorkspace: mutateMock,
isCurrentWorkspaceManager: true,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
renderComponent()
const switchInput = screen.getByRole('switch')

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
ACCEPT_TYPES,
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
} from '../constants'
describe('image-uploader constants', () => {
// Verify accepted image types
describe('ACCEPT_TYPES', () => {
it('should include standard image formats', () => {
expect(ACCEPT_TYPES).toContain('jpg')
expect(ACCEPT_TYPES).toContain('jpeg')
expect(ACCEPT_TYPES).toContain('png')
expect(ACCEPT_TYPES).toContain('gif')
})
it('should have exactly 4 types', () => {
expect(ACCEPT_TYPES).toHaveLength(4)
})
})
// Verify numeric limits are positive
describe('Limits', () => {
it('should have a positive file size limit', () => {
expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBeGreaterThan(0)
expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBe(2)
})
it('should have a positive batch limit', () => {
expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBeGreaterThan(0)
expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBe(5)
})
it('should have a positive single chunk attachment limit', () => {
expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBeGreaterThan(0)
expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBe(10)
})
})
})

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { indexMethodIcon, retrievalIcon } from '../icons'
describe('create/icons', () => {
// Verify icon map exports have expected keys
describe('indexMethodIcon', () => {
it('should have high_quality and economical keys', () => {
expect(indexMethodIcon).toHaveProperty('high_quality')
expect(indexMethodIcon).toHaveProperty('economical')
})
it('should have truthy values for each key', () => {
expect(indexMethodIcon.high_quality).toBeTruthy()
expect(indexMethodIcon.economical).toBeTruthy()
})
})
describe('retrievalIcon', () => {
it('should have vector, fullText, and hybrid keys', () => {
expect(retrievalIcon).toHaveProperty('vector')
expect(retrievalIcon).toHaveProperty('fullText')
expect(retrievalIcon).toHaveProperty('hybrid')
})
it('should have truthy values for each key', () => {
expect(retrievalIcon.vector).toBeTruthy()
expect(retrievalIcon.fullText).toBeTruthy()
expect(retrievalIcon.hybrid).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
PROGRESS_COMPLETE,
PROGRESS_ERROR,
PROGRESS_NOT_STARTED,
} from '../constants'
describe('file-uploader constants', () => {
// Verify progress sentinel values
describe('Progress Sentinels', () => {
it('should define PROGRESS_NOT_STARTED as -1', () => {
expect(PROGRESS_NOT_STARTED).toBe(-1)
})
it('should define PROGRESS_ERROR as -2', () => {
expect(PROGRESS_ERROR).toBe(-2)
})
it('should define PROGRESS_COMPLETE as 100', () => {
expect(PROGRESS_COMPLETE).toBe(100)
})
it('should have distinct values for all sentinels', () => {
const values = [PROGRESS_NOT_STARTED, PROGRESS_ERROR, PROGRESS_COMPLETE]
expect(new Set(values).size).toBe(values.length)
})
it('should have negative values for non-progress states', () => {
expect(PROGRESS_NOT_STARTED).toBeLessThan(0)
expect(PROGRESS_ERROR).toBeLessThan(0)
})
})
})

View File

@@ -0,0 +1,240 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocumentSort } from '../document-list/hooks'
import DocumentList from '../list'
// Mock hooks used by DocumentList
const mockHandleSort = vi.fn()
const mockOnSelectAll = vi.fn()
const mockOnSelectOne = vi.fn()
const mockClearSelection = vi.fn()
const mockHandleAction = vi.fn(() => vi.fn())
const mockHandleBatchReIndex = vi.fn()
const mockHandleBatchDownload = vi.fn()
const mockShowEditModal = vi.fn()
const mockHideEditModal = vi.fn()
const mockHandleSave = vi.fn()
vi.mock('../document-list/hooks', () => ({
useDocumentSort: vi.fn(() => ({
sortField: null,
sortOrder: null,
handleSort: mockHandleSort,
sortedDocuments: [],
})),
useDocumentSelection: vi.fn(() => ({
isAllSelected: false,
isSomeSelected: false,
onSelectAll: mockOnSelectAll,
onSelectOne: mockOnSelectOne,
hasErrorDocumentsSelected: false,
downloadableSelectedIds: [],
clearSelection: mockClearSelection,
})),
useDocumentActions: vi.fn(() => ({
handleAction: mockHandleAction,
handleBatchReIndex: mockHandleBatchReIndex,
handleBatchDownload: mockHandleBatchDownload,
})),
}))
vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({
default: vi.fn(() => ({
isShowEditModal: false,
showEditModal: mockShowEditModal,
hideEditModal: mockHideEditModal,
originalList: [],
handleSave: mockHandleSave,
})),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: () => ({
doc_form: 'text_model',
}),
}))
// Mock child components that are complex
vi.mock('../document-list/components', () => ({
DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail, index: number }) => (
<tr data-testid={`doc-row-${doc.id}`}>
<td>{index + 1}</td>
<td>{doc.name}</td>
</tr>
),
renderTdValue: (val: string) => val || '-',
SortHeader: ({ field, label, onSort }: { field: string, label: string, onSort: (f: string) => void }) => (
<button data-testid={`sort-${field}`} onClick={() => onSort(field)}>{label}</button>
),
}))
vi.mock('../../detail/completed/common/batch-action', () => ({
default: ({ selectedIds, onCancel }: { selectedIds: string[], onCancel: () => void }) => (
<div data-testid="batch-action">
<span data-testid="selected-count">{selectedIds.length}</span>
<button data-testid="cancel-selection" onClick={onCancel}>Cancel</button>
</div>
),
}))
vi.mock('../../rename-modal', () => ({
default: ({ name, onClose }: { name: string, onClose: () => void }) => (
<div data-testid="rename-modal">
<span>{name}</span>
<button onClick={onClose}>Close</button>
</div>
),
}))
vi.mock('@/app/components/datasets/metadata/edit-metadata-batch/modal', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="edit-metadata-modal">
<button onClick={onHide}>Hide</button>
</div>
),
}))
function createDoc(overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail {
return {
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'Test Doc',
position: 1,
data_source_type: 'upload_file',
word_count: 100,
hit_count: 5,
indexing_status: 'completed',
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
display_status: 'available',
created_from: 'web',
created_at: 1234567890,
...overrides,
} as SimpleDocumentDetail
}
const defaultProps = {
embeddingAvailable: true,
documents: [] as SimpleDocumentDetail[],
selectedIds: [] as string[],
onSelectedIdChange: vi.fn(),
datasetId: 'ds-1',
pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() },
onUpdate: vi.fn(),
onManageMetadata: vi.fn(),
statusFilterValue: 'all',
remoteSortValue: '',
}
describe('DocumentList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify the table renders with column headers
describe('Rendering', () => {
it('should render the document table with headers', () => {
render(<DocumentList {...defaultProps} />)
expect(screen.getByText('#')).toBeInTheDocument()
expect(screen.getByTestId('sort-name')).toBeInTheDocument()
expect(screen.getByTestId('sort-word_count')).toBeInTheDocument()
expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument()
expect(screen.getByTestId('sort-created_at')).toBeInTheDocument()
})
it('should render select-all area when embeddingAvailable is true', () => {
const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={true} />)
// Checkbox component renders inside the first td
const firstTd = container.querySelector('thead td')
expect(firstTd?.textContent).toContain('#')
})
it('should still render # column when embeddingAvailable is false', () => {
const { container } = render(<DocumentList {...defaultProps} embeddingAvailable={false} />)
const firstTd = container.querySelector('thead td')
expect(firstTd?.textContent).toContain('#')
})
it('should render document rows from sortedDocuments', () => {
const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })]
vi.mocked(useDocumentSort).mockReturnValue({
sortField: null,
sortOrder: 'desc',
handleSort: mockHandleSort,
sortedDocuments: docs,
} as unknown as ReturnType<typeof useDocumentSort>)
render(<DocumentList {...defaultProps} documents={docs} />)
expect(screen.getByTestId('doc-row-a')).toBeInTheDocument()
expect(screen.getByTestId('doc-row-b')).toBeInTheDocument()
})
})
// Verify sort headers trigger sort handler
describe('Sorting', () => {
it('should call handleSort when sort header is clicked', () => {
render(<DocumentList {...defaultProps} />)
fireEvent.click(screen.getByTestId('sort-name'))
expect(mockHandleSort).toHaveBeenCalledWith('name')
})
})
// Verify batch action bar appears when items selected
describe('Batch Actions', () => {
it('should show batch action bar when selectedIds is non-empty', () => {
render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />)
expect(screen.getByTestId('batch-action')).toBeInTheDocument()
expect(screen.getByTestId('selected-count')).toHaveTextContent('1')
})
it('should not show batch action bar when no items selected', () => {
render(<DocumentList {...defaultProps} selectedIds={[]} />)
expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument()
})
it('should call clearSelection when cancel is clicked in batch bar', () => {
render(<DocumentList {...defaultProps} selectedIds={['doc-1']} />)
fireEvent.click(screen.getByTestId('cancel-selection'))
expect(mockClearSelection).toHaveBeenCalled()
})
})
// Verify pagination renders when total > 0
describe('Pagination', () => {
it('should not render pagination when total is 0', () => {
const { container } = render(<DocumentList {...defaultProps} />)
expect(container.querySelector('[class*="pagination"]')).not.toBeInTheDocument()
})
})
// Verify empty state
describe('Edge Cases', () => {
it('should render table with no document rows when sortedDocuments is empty', () => {
// Reset sort mock to return empty sorted list
vi.mocked(useDocumentSort).mockReturnValue({
sortField: null,
sortOrder: 'desc',
handleSort: mockHandleSort,
sortedDocuments: [],
} as unknown as ReturnType<typeof useDocumentSort>)
render(<DocumentList {...defaultProps} documents={[]} />)
expect(screen.queryByTestId(/^doc-row-/)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,167 @@
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import Toast from '@/app/components/base/toast'
import Form from '../form'
// Mock the Header component (sibling component, not a base component)
vi.mock('../header', () => ({
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
onReset: () => void
resetDisabled: boolean
onPreview: () => void
previewDisabled: boolean
}) => (
<div data-testid="form-header">
<button data-testid="reset-btn" onClick={onReset} disabled={resetDisabled}>Reset</button>
<button data-testid="preview-btn" onClick={onPreview} disabled={previewDisabled}>Preview</button>
</div>
),
}))
const schema = z.object({
name: z.string().min(1, 'Name is required'),
value: z.string().optional(),
})
const defaultConfigs: BaseConfiguration[] = [
{ variable: 'name', type: 'text-input', label: 'Name', required: true, showConditions: [] } as BaseConfiguration,
{ variable: 'value', type: 'text-input', label: 'Value', required: false, showConditions: [] } as BaseConfiguration,
]
const defaultProps = {
initialData: { name: 'test', value: '' },
configurations: defaultConfigs,
schema,
onSubmit: vi.fn(),
onPreview: vi.fn(),
ref: { current: null },
isRunning: false,
}
describe('Form (process-documents)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
// Verify basic rendering of form structure
describe('Rendering', () => {
it('should render form with header and fields', () => {
render(<Form {...defaultProps} />)
expect(screen.getByTestId('form-header')).toBeInTheDocument()
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Value')).toBeInTheDocument()
})
it('should render all configuration fields', () => {
const configs: BaseConfiguration[] = [
{ variable: 'a', type: 'text-input', label: 'A', required: false, showConditions: [] } as BaseConfiguration,
{ variable: 'b', type: 'text-input', label: 'B', required: false, showConditions: [] } as BaseConfiguration,
{ variable: 'c', type: 'text-input', label: 'C', required: false, showConditions: [] } as BaseConfiguration,
]
render(<Form {...defaultProps} configurations={configs} initialData={{ a: '', b: '', c: '' }} />)
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByText('C')).toBeInTheDocument()
})
})
// Verify form submission behavior
describe('Form Submission', () => {
it('should call onSubmit with valid data on form submit', async () => {
render(<Form {...defaultProps} />)
const form = screen.getByTestId('form-header').closest('form')!
fireEvent.submit(form)
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
})
it('should call onSubmit with valid data via imperative handle', async () => {
const ref = { current: null as { submit: () => void } | null }
render(<Form {...defaultProps} ref={ref} />)
ref.current?.submit()
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
})
})
// Verify validation shows Toast on error
describe('Validation', () => {
it('should show toast error when validation fails', async () => {
render(<Form {...defaultProps} initialData={{ name: '', value: '' }} />)
const form = screen.getByTestId('form-header').closest('form')!
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
it('should not show toast error when validation passes', async () => {
render(<Form {...defaultProps} />)
const form = screen.getByTestId('form-header').closest('form')!
fireEvent.submit(form)
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
expect(Toast.notify).not.toHaveBeenCalled()
})
})
// Verify header button states
describe('Header Controls', () => {
it('should pass isRunning to previewDisabled', () => {
render(<Form {...defaultProps} isRunning={true} />)
expect(screen.getByTestId('preview-btn')).toBeDisabled()
})
it('should call onPreview when preview button is clicked', () => {
render(<Form {...defaultProps} />)
fireEvent.click(screen.getByTestId('preview-btn'))
expect(defaultProps.onPreview).toHaveBeenCalled()
})
it('should render reset button (disabled when form is not dirty)', () => {
render(<Form {...defaultProps} />)
// Reset button is rendered but disabled since form is not dirty initially
expect(screen.getByTestId('reset-btn')).toBeInTheDocument()
expect(screen.getByTestId('reset-btn')).toBeDisabled()
})
})
// Verify edge cases
describe('Edge Cases', () => {
it('should render with empty configurations array', () => {
render(<Form {...defaultProps} configurations={[]} />)
expect(screen.getByTestId('form-header')).toBeInTheDocument()
})
it('should render with empty initialData', () => {
render(<Form {...defaultProps} initialData={{}} configurations={[]} />)
expect(screen.getByTestId('form-header')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,147 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: { text: 'Book', iconName: 'book' },
web_page: { text: 'Web Page', iconName: 'web' },
paper: { text: 'Paper', iconName: 'paper' },
social_media_post: { text: 'Social Media Post', iconName: 'social' },
personal_document: { text: 'Personal Document', iconName: 'personal' },
business_document: { text: 'Business Document', iconName: 'business' },
wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' },
}),
}))
vi.mock('@/models/datasets', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'],
}
})
describe('DocTypeSelector', () => {
const defaultProps = {
docType: '' as '' | 'book',
documentType: undefined as '' | 'book' | undefined,
tempDocType: '' as '' | 'book' | 'web_page',
onTempDocTypeChange: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Verify first-time setup UI (no existing doc type)
describe('First Time Selection', () => {
it('should render description and selection title when no doc type exists', () => {
render(<DocTypeSelector {...defaultProps} docType="" documentType={undefined} />)
expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument()
expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument()
})
it('should render icon buttons for each doc type', () => {
const { container } = render(<DocTypeSelector {...defaultProps} />)
// Each doc type renders an IconButton wrapped in Radio
const iconButtons = container.querySelectorAll('button[type="button"]')
// 3 doc types + 1 confirm button = 4 buttons
expect(iconButtons.length).toBeGreaterThanOrEqual(3)
})
it('should render confirm button disabled when tempDocType is empty', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="" />)
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
expect(confirmBtn.closest('button')).toBeDisabled()
})
it('should render confirm button enabled when tempDocType is set', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
expect(confirmBtn.closest('button')).not.toBeDisabled()
})
it('should call onConfirm when confirm button is clicked', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
fireEvent.click(screen.getByText(/metadata\.firstMetaAction/))
expect(defaultProps.onConfirm).toHaveBeenCalled()
})
})
// Verify change-type UI (has existing doc type)
describe('Change Doc Type', () => {
it('should render change title and warning when documentType exists', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument()
expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument()
})
it('should render save and cancel buttons when documentType exists', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
})
it('should call onCancel when cancel button is clicked', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
fireEvent.click(screen.getByText(/operation\.cancel/))
expect(defaultProps.onCancel).toHaveBeenCalled()
})
})
})
describe('DocumentTypeDisplay', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify read-only display of current doc type
describe('Rendering', () => {
it('should render the doc type text', () => {
render(<DocumentTypeDisplay displayType="book" />)
expect(screen.getByText('Book')).toBeInTheDocument()
})
it('should show change link when showChangeLink is true', () => {
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} />)
expect(screen.getByText(/operation\.change/)).toBeInTheDocument()
})
it('should not show change link when showChangeLink is false', () => {
render(<DocumentTypeDisplay displayType="book" showChangeLink={false} />)
expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument()
})
it('should call onChangeClick when change link is clicked', () => {
const onClick = vi.fn()
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />)
fireEvent.click(screen.getByText(/operation\.change/))
expect(onClick).toHaveBeenCalled()
})
it('should fallback to "book" display when displayType is empty and no change link', () => {
render(<DocumentTypeDisplay displayType="" showChangeLink={false} />)
expect(screen.getByText('Book')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,116 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FieldInfo from '../field-info'
vi.mock('@/utils', () => ({
getTextWidthWithCanvas: (text: string) => text.length * 8,
}))
describe('FieldInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify read-only rendering
describe('Read-Only Mode', () => {
it('should render label and displayed value', () => {
render(<FieldInfo label="Title" displayedValue="My Document" />)
expect(screen.getByText('Title')).toBeInTheDocument()
expect(screen.getByText('My Document')).toBeInTheDocument()
})
it('should render value icon when provided', () => {
render(
<FieldInfo
label="Status"
displayedValue="Active"
valueIcon={<span data-testid="icon">*</span>}
/>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
})
it('should render displayedValue as plain text when not editing', () => {
render(<FieldInfo label="Author" displayedValue="John" showEdit={false} />)
expect(screen.getByText('John')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
// Verify edit mode rendering for each inputType
describe('Edit Mode', () => {
it('should render input field by default in edit mode', () => {
render(<FieldInfo label="Title" value="Test" showEdit={true} inputType="input" />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('Test')
})
it('should render textarea when inputType is textarea', () => {
render(<FieldInfo label="Desc" value="Long text" showEdit={true} inputType="textarea" />)
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('Long text')
})
it('should render select when inputType is select', () => {
const options = [
{ value: 'en', name: 'English' },
{ value: 'zh', name: 'Chinese' },
]
render(
<FieldInfo
label="Language"
value="en"
showEdit={true}
inputType="select"
selectOptions={options}
/>,
)
// SimpleSelect renders a button-like trigger
expect(screen.getByText('English')).toBeInTheDocument()
})
it('should call onUpdate when input value changes', () => {
const onUpdate = vi.fn()
render(<FieldInfo label="Title" value="" showEdit={true} inputType="input" onUpdate={onUpdate} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } })
expect(onUpdate).toHaveBeenCalledWith('New')
})
it('should call onUpdate when textarea value changes', () => {
const onUpdate = vi.fn()
render(<FieldInfo label="Desc" value="" showEdit={true} inputType="textarea" onUpdate={onUpdate} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
expect(onUpdate).toHaveBeenCalledWith('Updated')
})
})
// Verify edge cases
describe('Edge Cases', () => {
it('should render with empty value and label', () => {
render(<FieldInfo label="" value="" displayedValue="" />)
// Should not crash
const container = document.querySelector('.flex.min-h-5')
expect(container).toBeInTheDocument()
})
it('should render with default value prop', () => {
render(<FieldInfo label="Field" showEdit={true} inputType="input" defaultValue="default" />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,149 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MetadataFieldList from '../metadata-field-list'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: {
text: 'Book',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
author: { label: 'Author', inputType: 'input' },
},
},
originInfo: {
text: 'Origin Info',
subFieldsMap: {
source: { label: 'Source', inputType: 'input' },
hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` },
},
},
}),
useLanguages: () => ({ en: 'English', zh: 'Chinese' }),
useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }),
usePersonalDocCategories: () => ({}),
useBusinessDocCategories: () => ({}),
}))
describe('MetadataFieldList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify rendering of metadata fields based on mainField
describe('Rendering', () => {
it('should render all fields for the given mainField', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{ title: 'Test Book', language: 'en', author: 'John' }}
/>,
)
expect(screen.getByText('Title')).toBeInTheDocument()
expect(screen.getByText('Language')).toBeInTheDocument()
expect(screen.getByText('Author')).toBeInTheDocument()
})
it('should return null when mainField is empty', () => {
const { container } = render(
<MetadataFieldList mainField="" metadata={{}} />,
)
expect(container.firstChild).toBeNull()
})
it('should display "-" for missing field values', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{}}
/>,
)
// All three fields should show "-"
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThanOrEqual(3)
})
it('should resolve select values to their display name', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{ language: 'en' }}
/>,
)
expect(screen.getByText('English')).toBeInTheDocument()
})
})
// Verify edit mode passes correct props
describe('Edit Mode', () => {
it('should render fields in edit mode when canEdit is true', () => {
render(
<MetadataFieldList
mainField="book"
canEdit={true}
metadata={{ title: 'Book Title' }}
/>,
)
// In edit mode, FieldInfo renders input elements
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
})
it('should call onFieldUpdate when a field value changes', () => {
const onUpdate = vi.fn()
render(
<MetadataFieldList
mainField="book"
canEdit={true}
metadata={{ title: '' }}
onFieldUpdate={onUpdate}
/>,
)
// Find the first textbox and type in it
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0], { target: { value: 'New Title' } })
expect(onUpdate).toHaveBeenCalled()
})
})
// Verify fixed field types use docDetail as source
describe('Fixed Field Types', () => {
it('should use docDetail as source data for originInfo type', () => {
const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 }
render(
<MetadataFieldList
mainField="originInfo"
docDetail={docDetail as never}
metadata={{}}
/>,
)
expect(screen.getByText('Source')).toBeInTheDocument()
expect(screen.getByText('Web')).toBeInTheDocument()
})
it('should render custom render function output for fields with render', () => {
const docDetail = { source: 'API', hit_count: 15, segment_count: 5 }
render(
<MetadataFieldList
mainField="originInfo"
docDetail={docDetail as never}
metadata={{}}
/>,
)
expect(screen.getByText('15 / 5')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,164 @@
import type { ReactNode } from 'react'
import type { FullDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useMetadataState } from '../use-metadata-state'
const { mockNotify, mockModifyDocMetadata } = vi.hoisted(() => ({
mockNotify: vi.fn(),
mockModifyDocMetadata: vi.fn(),
}))
vi.mock('../../../context', () => ({
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) =>
selector({ datasetId: 'ds-1', documentId: 'doc-1' }),
}))
vi.mock('@/service/datasets', () => ({
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
}))
vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) }))
vi.mock('@/utils', () => ({
asyncRunSafe: async (promise: Promise<unknown>) => {
try {
return [null, await promise]
}
catch (e) { return [e] }
},
}))
// Wrapper that provides ToastContext with the mock notify function
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(ToastContext.Provider, { value: { notify: mockNotify, close: vi.fn() }, children })
type DocDetail = Parameters<typeof useMetadataState>[0]['docDetail']
const makeDoc = (overrides: Partial<FullDocumentDetail> = {}): DocDetail =>
({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail)
describe('useMetadataState', () => {
// Verify all metadata editing workflows using a stable docDetail reference
it('should manage the full metadata editing lifecycle', async () => {
mockModifyDocMetadata.mockResolvedValue({ result: 'ok' })
const onUpdate = vi.fn()
// IMPORTANT: Create a stable reference outside the render callback
// to prevent useEffect infinite loops on docDetail?.doc_metadata
const stableDocDetail = makeDoc()
const { result } = renderHook(() =>
useMetadataState({ docDetail: stableDocDetail, onUpdate }), { wrapper })
// --- Initialization ---
expect(result.current.docType).toBe('book')
expect(result.current.editStatus).toBe(false)
expect(result.current.showDocTypes).toBe(false)
expect(result.current.metadataParams.documentType).toBe('book')
expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' })
// --- Enable editing ---
act(() => {
result.current.enableEdit()
})
expect(result.current.editStatus).toBe(true)
// --- Update individual field ---
act(() => {
result.current.updateMetadataField('title', 'Modified Title')
})
expect(result.current.metadataParams.metadata.title).toBe('Modified Title')
expect(result.current.metadataParams.metadata.author).toBe('Author')
// --- Cancel edit restores original data ---
act(() => {
result.current.cancelEdit()
})
expect(result.current.metadataParams.metadata.title).toBe('Test Book')
expect(result.current.editStatus).toBe(false)
// --- Doc type selection: cancel restores previous ---
act(() => {
result.current.enableEdit()
})
act(() => {
result.current.setShowDocTypes(true)
})
act(() => {
result.current.setTempDocType('web_page')
})
act(() => {
result.current.cancelDocType()
})
expect(result.current.tempDocType).toBe('book')
expect(result.current.showDocTypes).toBe(false)
// --- Confirm different doc type clears metadata ---
act(() => {
result.current.setShowDocTypes(true)
})
act(() => {
result.current.setTempDocType('web_page')
})
act(() => {
result.current.confirmDocType()
})
expect(result.current.metadataParams.documentType).toBe('web_page')
expect(result.current.metadataParams.metadata).toEqual({})
// --- Save succeeds ---
await act(async () => {
await result.current.saveMetadata()
})
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentId: 'doc-1',
body: { doc_type: 'web_page', doc_metadata: {} },
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
expect(onUpdate).toHaveBeenCalled()
expect(result.current.editStatus).toBe(false)
expect(result.current.saveLoading).toBe(false)
// --- Save failure notifies error ---
mockNotify.mockClear()
mockModifyDocMetadata.mockRejectedValue(new Error('fail'))
act(() => {
result.current.enableEdit()
})
await act(async () => {
await result.current.saveMetadata()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
// Verify empty doc type starts in editing mode
it('should initialize in editing mode when no doc type exists', () => {
const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] })
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
expect(result.current.docType).toBe('')
expect(result.current.editStatus).toBe(true)
expect(result.current.showDocTypes).toBe(true)
})
// Verify "others" normalization
it('should normalize "others" doc_type to empty string', () => {
const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] })
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
expect(result.current.docType).toBe('')
})
// Verify undefined docDetail handling
it('should handle undefined docDetail gracefully', () => {
const { result } = renderHook(() => useMetadataState({ docDetail: undefined }), { wrapper })
expect(result.current.docType).toBe('')
expect(result.current.editStatus).toBe(true)
})
})

View File

@@ -1,40 +1,49 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { Query } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import QueryInput from '../index'
vi.mock('uuid', () => ({
v4: () => 'mock-uuid',
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => (
<button data-testid="submit-button" onClick={onClick} disabled={disabled || loading}>
{children}
</button>
),
}))
// Capture onChange callback so tests can trigger handleImageChange
let capturedOnChange: ((files: FileEntity[]) => void) | null = null
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
<div data-testid="image-uploader">
{textArea}
{actionButton}
</div>
),
default: ({ textArea, actionButton, onChange }: { textArea: React.ReactNode, actionButton: React.ReactNode, onChange?: (files: FileEntity[]) => void }) => {
capturedOnChange = onChange ?? null
return (
<div data-testid="image-uploader">
{textArea}
{actionButton}
</div>
)
},
}))
vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({
getIcon: () => '/test-icon.png',
}))
// Capture onSave callback for external retrieval modal
let _capturedModalOnSave: ((data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void) | null = null
vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({
default: () => <div data-testid="external-retrieval-modal" />,
default: ({ onSave, onClose }: { onSave: (data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void, onClose: () => void }) => {
_capturedModalOnSave = onSave
return (
<div data-testid="external-retrieval-modal">
<button data-testid="modal-save" onClick={() => onSave({ top_k: 10, score_threshold: 0.8, score_threshold_enabled: true })}>Save</button>
<button data-testid="modal-close" onClick={onClose}>Close</button>
</div>
)
},
}))
// Capture handleTextChange callback
let _capturedHandleTextChange: ((e: React.ChangeEvent<HTMLTextAreaElement>) => void) | null = null
vi.mock('../textarea', () => ({
default: ({ text }: { text: string }) => <textarea data-testid="textarea" defaultValue={text} />,
default: ({ text, handleTextChange }: { text: string, handleTextChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }) => {
_capturedHandleTextChange = handleTextChange
return <textarea data-testid="textarea" defaultValue={text} onChange={handleTextChange} />
},
}))
vi.mock('@/context/dataset-detail', () => ({
@@ -42,7 +51,8 @@ vi.mock('@/context/dataset-detail', () => ({
}))
describe('QueryInput', () => {
const defaultProps = {
// Re-create per test to avoid cross-test mutation (handleTextChange mutates query objects)
const makeDefaultProps = () => ({
onUpdateList: vi.fn(),
setHitResult: vi.fn(),
setExternalHitResult: vi.fn(),
@@ -55,10 +65,16 @@ describe('QueryInput', () => {
isEconomy: false,
hitTestingMutation: vi.fn(),
externalKnowledgeBaseHitTestingMutation: vi.fn(),
}
})
let defaultProps: ReturnType<typeof makeDefaultProps>
beforeEach(() => {
vi.clearAllMocks()
defaultProps = makeDefaultProps()
capturedOnChange = null
_capturedModalOnSave = null
_capturedHandleTextChange = null
})
it('should render title', () => {
@@ -73,7 +89,7 @@ describe('QueryInput', () => {
it('should render submit button', () => {
render(<QueryInput {...defaultProps} />)
expect(screen.getByTestId('submit-button')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /input\.testing/ })).toBeInTheDocument()
})
it('should disable submit button when text is empty', () => {
@@ -82,7 +98,7 @@ describe('QueryInput', () => {
queries: [{ content: '', content_type: 'text_query', file_info: null }] satisfies Query[],
}
render(<QueryInput {...props} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
expect(screen.getByRole('button', { name: /input\.testing/ })).toBeDisabled()
})
it('should render retrieval method for non-external mode', () => {
@@ -101,11 +117,302 @@ describe('QueryInput', () => {
queries: [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] satisfies Query[],
}
render(<QueryInput {...props} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
expect(screen.getByRole('button', { name: /input\.testing/ })).toBeDisabled()
})
it('should disable submit button when loading', () => {
it('should show loading state on submit button when loading', () => {
render(<QueryInput {...defaultProps} loading={true} />)
expect(screen.getByTestId('submit-button')).toBeDisabled()
const submitButton = screen.getByRole('button', { name: /input\.testing/ })
// The real Button component does not disable on loading; it shows a spinner
expect(submitButton).toBeInTheDocument()
expect(submitButton.querySelector('[role="status"]')).toBeInTheDocument()
})
// Cover line 83: images useMemo with image_query data
describe('Image Queries', () => {
it('should parse image_query entries from queries', () => {
const queries: Query[] = [
{ content: 'test', content_type: 'text_query', file_info: null },
{
content: 'https://img.example.com/1.png',
content_type: 'image_query',
file_info: { id: 'img-1', name: 'photo.png', size: 1024, mime_type: 'image/png', extension: 'png', source_url: 'https://img.example.com/1.png' },
},
]
render(<QueryInput {...defaultProps} queries={queries} />)
// Submit should be enabled since we have text + uploaded image
expect(screen.getByRole('button', { name: /input\.testing/ })).not.toBeDisabled()
})
})
// Cover lines 106-107: handleSaveExternalRetrievalSettings
describe('External Retrieval Settings', () => {
it('should open and close external retrieval modal', () => {
render(<QueryInput {...defaultProps} isExternal={true} />)
// Click settings button to open modal
fireEvent.click(screen.getByRole('button', { name: /settingTitle/ }))
expect(screen.getByTestId('external-retrieval-modal')).toBeInTheDocument()
// Close modal
fireEvent.click(screen.getByTestId('modal-close'))
expect(screen.queryByTestId('external-retrieval-modal')).not.toBeInTheDocument()
})
it('should save external retrieval settings and close modal', () => {
render(<QueryInput {...defaultProps} isExternal={true} />)
// Open modal
fireEvent.click(screen.getByRole('button', { name: /settingTitle/ }))
expect(screen.getByTestId('external-retrieval-modal')).toBeInTheDocument()
// Save settings
fireEvent.click(screen.getByTestId('modal-save'))
expect(screen.queryByTestId('external-retrieval-modal')).not.toBeInTheDocument()
})
})
// Cover line 121: handleTextChange when textQuery already exists
describe('Text Change Handling', () => {
it('should update existing text query on text change', () => {
render(<QueryInput {...defaultProps} />)
const textarea = screen.getByTestId('textarea')
fireEvent.change(textarea, { target: { value: 'updated text' } })
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content: 'updated text', content_type: 'text_query' }),
]),
)
})
it('should create new text query when none exists', () => {
render(<QueryInput {...defaultProps} queries={[]} />)
const textarea = screen.getByTestId('textarea')
fireEvent.change(textarea, { target: { value: 'new text' } })
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content: 'new text', content_type: 'text_query' }),
]),
)
})
})
// Cover lines 127-143: handleImageChange
describe('Image Change Handling', () => {
it('should update queries when images change', () => {
render(<QueryInput {...defaultProps} />)
const files: FileEntity[] = [{
id: 'f-1',
name: 'pic.jpg',
size: 2048,
mimeType: 'image/jpeg',
extension: 'jpg',
sourceUrl: 'https://img.example.com/pic.jpg',
uploadedId: 'uploaded-1',
progress: 100,
}]
capturedOnChange?.(files)
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content_type: 'text_query' }),
expect.objectContaining({
content: 'https://img.example.com/pic.jpg',
content_type: 'image_query',
file_info: expect.objectContaining({ id: 'uploaded-1', name: 'pic.jpg' }),
}),
]),
)
})
it('should handle files with missing sourceUrl and uploadedId', () => {
render(<QueryInput {...defaultProps} />)
const files: FileEntity[] = [{
id: 'f-2',
name: 'no-url.jpg',
size: 512,
mimeType: 'image/jpeg',
extension: 'jpg',
progress: 100,
// sourceUrl and uploadedId are undefined
}]
capturedOnChange?.(files)
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: '',
content_type: 'image_query',
file_info: expect.objectContaining({ id: '', source_url: '' }),
}),
]),
)
})
it('should replace all existing image queries with new ones', () => {
const queries: Query[] = [
{ content: 'text', content_type: 'text_query', file_info: null },
{ content: 'old-img', content_type: 'image_query', file_info: { id: 'old', name: 'old.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: '' } },
]
render(<QueryInput {...defaultProps} queries={queries} />)
capturedOnChange?.([])
// Should keep text query but remove all image queries
expect(defaultProps.setQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ content_type: 'text_query' }),
]),
)
// Should not contain image_query
const calledWith = defaultProps.setQueries.mock.calls[0][0] as Query[]
expect(calledWith.filter(q => q.content_type === 'image_query')).toHaveLength(0)
})
})
// Cover lines 146-162: onSubmit (hit testing mutation)
describe('Submit Handlers', () => {
it('should call hitTestingMutation on submit for non-external mode', async () => {
const mockMutation = vi.fn(async (_req, opts) => {
const response = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
opts?.onSuccess?.(response)
return response
})
render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
retrieval_model: expect.objectContaining({ search_method: 'semantic_search' }),
}),
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
})
expect(defaultProps.setHitResult).toHaveBeenCalled()
expect(defaultProps.onUpdateList).toHaveBeenCalled()
})
it('should call onSubmit callback after successful hit testing', async () => {
const mockOnSubmit = vi.fn()
const mockMutation = vi.fn(async (_req, opts) => {
const response = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
opts?.onSuccess?.(response)
return response
})
render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} onSubmit={mockOnSubmit} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled()
})
})
it('should use keywordSearch when isEconomy is true', async () => {
const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
const mockMutation = vi.fn(async (_req, opts) => {
opts?.onSuccess?.(mockResponse)
return mockResponse
})
render(<QueryInput {...defaultProps} hitTestingMutation={mockMutation} isEconomy={true} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockMutation).toHaveBeenCalledWith(
expect.objectContaining({
retrieval_model: expect.objectContaining({ search_method: 'keyword_search' }),
}),
expect.anything(),
)
})
})
// Cover lines 164-178: externalRetrievalTestingOnSubmit
it('should call externalKnowledgeBaseHitTestingMutation for external mode', async () => {
const mockExternalMutation = vi.fn(async (_req, opts) => {
const response = { query: { content: '' }, records: [] }
opts?.onSuccess?.(response)
return response
})
render(<QueryInput {...defaultProps} isExternal={true} externalKnowledgeBaseHitTestingMutation={mockExternalMutation} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockExternalMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
})
expect(defaultProps.setExternalHitResult).toHaveBeenCalled()
expect(defaultProps.onUpdateList).toHaveBeenCalled()
})
it('should include image attachment_ids in submit request', async () => {
const queries: Query[] = [
{ content: 'test', content_type: 'text_query', file_info: null },
{ content: 'img-url', content_type: 'image_query', file_info: { id: 'img-id', name: 'pic.png', size: 100, mime_type: 'image/png', extension: 'png', source_url: 'img-url' } },
]
const mockResponse = { query: { content: '', tsne_position: { x: 0, y: 0 } }, records: [] }
const mockMutation = vi.fn(async (_req, opts) => {
opts?.onSuccess?.(mockResponse)
return mockResponse
})
render(<QueryInput {...defaultProps} queries={queries} hitTestingMutation={mockMutation} />)
fireEvent.click(screen.getByRole('button', { name: /input\.testing/ }))
await waitFor(() => {
expect(mockMutation).toHaveBeenCalledWith(
expect.objectContaining({
// uploadedId is mapped from file_info.id
attachment_ids: expect.arrayContaining(['img-id']),
}),
expect.anything(),
)
})
})
})
// Cover lines 217-238: retrieval method click handler
describe('Retrieval Method', () => {
it('should call onClickRetrievalMethod when retrieval method is clicked', () => {
render(<QueryInput {...defaultProps} />)
fireEvent.click(screen.getByText('dataset.retrieval.semantic_search.title'))
expect(defaultProps.onClickRetrievalMethod).toHaveBeenCalled()
})
it('should show keyword_search when isEconomy is true', () => {
render(<QueryInput {...defaultProps} isEconomy={true} />)
expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
})
})
})

View File

@@ -6,10 +6,15 @@ vi.mock('@/utils/clipboard', () => ({
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
}))
// Suppress expected React act() warnings and jsdom unimplemented API errors
vi.spyOn(console, 'error').mockImplementation(() => {})
describe('code.tsx components', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
// jsdom does not implement scrollBy; mock it to prevent stderr noise
window.scrollBy = vi.fn()
})
afterEach(() => {
@@ -18,14 +23,9 @@ describe('code.tsx components', () => {
})
describe('Code', () => {
it('should render children', () => {
it('should render children as a code element', () => {
render(<Code>const x = 1</Code>)
expect(screen.getByText('const x = 1')).toBeInTheDocument()
})
it('should render as code element', () => {
render(<Code>code snippet</Code>)
const codeElement = screen.getByText('code snippet')
const codeElement = screen.getByText('const x = 1')
expect(codeElement.tagName).toBe('CODE')
})
@@ -48,14 +48,9 @@ describe('code.tsx components', () => {
})
describe('Embed', () => {
it('should render value prop', () => {
it('should render value prop as a span element', () => {
render(<Embed value="embedded content">ignored children</Embed>)
expect(screen.getByText('embedded content')).toBeInTheDocument()
})
it('should render as span element', () => {
render(<Embed value="test value">children</Embed>)
const span = screen.getByText('test value')
const span = screen.getByText('embedded content')
expect(span.tagName).toBe('SPAN')
})
@@ -65,7 +60,7 @@ describe('code.tsx components', () => {
expect(embed).toHaveClass('embed-class')
})
it('should not render children, only value', () => {
it('should render only value, not children', () => {
render(<Embed value="shown">hidden children</Embed>)
expect(screen.getByText('shown')).toBeInTheDocument()
expect(screen.queryByText('hidden children')).not.toBeInTheDocument()
@@ -82,27 +77,6 @@ describe('code.tsx components', () => {
)
expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument()
})
it('should have shadow and rounded styles', () => {
const { container } = render(
<CodeGroup targetCode="code here">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.shadow-md')
expect(codeGroup).toBeInTheDocument()
expect(codeGroup).toHaveClass('rounded-2xl')
})
it('should have bg-zinc-900 background', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
})
describe('with array targetCode', () => {
@@ -184,23 +158,14 @@ describe('code.tsx components', () => {
})
describe('with title prop', () => {
it('should render title in header', () => {
it('should render title in an h3 heading', () => {
render(
<CodeGroup title="API Example" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('API Example')).toBeInTheDocument()
})
it('should render title in h3 element', () => {
render(
<CodeGroup title="Example Title" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const h3 = screen.getByRole('heading', { level: 3 })
expect(h3).toHaveTextContent('Example Title')
expect(h3).toHaveTextContent('API Example')
})
})
@@ -223,30 +188,18 @@ describe('code.tsx components', () => {
expect(screen.getByText('/api/users')).toBeInTheDocument()
})
it('should render both tag and label with separator', () => {
const { container } = render(
it('should render both tag and label together', () => {
render(
<CodeGroup tag="POST" label="/api/create" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByText('POST')).toBeInTheDocument()
expect(screen.getByText('/api/create')).toBeInTheDocument()
const separator = container.querySelector('.rounded-full.bg-zinc-500')
expect(separator).toBeInTheDocument()
})
})
describe('CopyButton functionality', () => {
it('should render copy button', () => {
render(
<CodeGroup targetCode="copyable code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const copyButton = screen.getByRole('button')
expect(copyButton).toBeInTheDocument()
})
it('should show "Copy" text initially', () => {
render(
<CodeGroup targetCode="code">
@@ -322,88 +275,32 @@ describe('code.tsx components', () => {
expect(screen.getByText('child code content')).toBeInTheDocument()
})
})
describe('styling', () => {
it('should have not-prose class to prevent prose styling', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.not-prose')
expect(codeGroup).toBeInTheDocument()
})
it('should have my-6 margin', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.my-6')
expect(codeGroup).toBeInTheDocument()
})
it('should have overflow-hidden', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const codeGroup = container.querySelector('.overflow-hidden')
expect(codeGroup).toBeInTheDocument()
})
})
})
describe('Pre', () => {
describe('when outside CodeGroup context', () => {
it('should wrap children in CodeGroup', () => {
const { container } = render(
<Pre>
<pre><code>code content</code></pre>
</Pre>,
)
const codeGroup = container.querySelector('.bg-zinc-900')
expect(codeGroup).toBeInTheDocument()
})
it('should pass props to CodeGroup', () => {
render(
<Pre title="Pre Title">
<pre><code>code</code></pre>
</Pre>,
)
expect(screen.getByText('Pre Title')).toBeInTheDocument()
})
it('should wrap children in CodeGroup when outside CodeGroup context', () => {
render(
<Pre title="Pre Title">
<pre><code>code</code></pre>
</Pre>,
)
expect(screen.getByText('Pre Title')).toBeInTheDocument()
})
describe('when inside CodeGroup context (isGrouped)', () => {
it('should return children directly without wrapping', () => {
render(
<CodeGroup targetCode="outer code">
<Pre>
<code>inner code</code>
</Pre>
</CodeGroup>,
)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
it('should return children directly when inside CodeGroup context', () => {
render(
<CodeGroup targetCode="outer code">
<Pre>
<code>inner code</code>
</Pre>
</CodeGroup>,
)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
})
describe('CodePanelHeader (via CodeGroup)', () => {
it('should not render when neither tag nor label provided', () => {
const { container } = render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const headerDivider = container.querySelector('.border-b-white\\/7\\.5')
expect(headerDivider).not.toBeInTheDocument()
})
it('should render when only tag is provided', () => {
it('should render when tag is provided', () => {
render(
<CodeGroup tag="GET" targetCode="code">
<pre><code>fallback</code></pre>
@@ -412,7 +309,7 @@ describe('code.tsx components', () => {
expect(screen.getByText('GET')).toBeInTheDocument()
})
it('should render when only label is provided', () => {
it('should render when label is provided', () => {
render(
<CodeGroup label="/api/endpoint" targetCode="code">
<pre><code>fallback</code></pre>
@@ -420,17 +317,6 @@ describe('code.tsx components', () => {
)
expect(screen.getByText('/api/endpoint')).toBeInTheDocument()
})
it('should render label with font-mono styling', () => {
render(
<CodeGroup label="/api/test" targetCode="code">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const label = screen.getByText('/api/test')
expect(label.className).toContain('font-mono')
expect(label.className).toContain('text-xs')
})
})
describe('CodeGroupHeader (via CodeGroup with multiple tabs)', () => {
@@ -446,39 +332,10 @@ describe('code.tsx components', () => {
)
expect(screen.getByRole('tablist')).toBeInTheDocument()
})
it('should style active tab differently', () => {
const examples = [
{ title: 'Active', code: 'active code' },
{ title: 'Inactive', code: 'inactive code' },
]
render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const activeTab = screen.getByRole('tab', { name: 'Active' })
expect(activeTab.className).toContain('border-emerald-500')
expect(activeTab.className).toContain('text-emerald-400')
})
it('should have header background styling', () => {
const examples = [
{ title: 'Tab1', code: 'code1' },
{ title: 'Tab2', code: 'code2' },
]
const { container } = render(
<CodeGroup targetCode={examples}>
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const header = container.querySelector('.bg-zinc-800')
expect(header).toBeInTheDocument()
})
})
describe('CodePanel (via CodeGroup)', () => {
it('should render code in pre element', () => {
it('should render code in a pre element', () => {
render(
<CodeGroup targetCode="pre content">
<pre><code>fallback</code></pre>
@@ -487,50 +344,10 @@ describe('code.tsx components', () => {
const preElement = screen.getByText('pre content').closest('pre')
expect(preElement).toBeInTheDocument()
})
it('should have text-white class on pre', () => {
render(
<CodeGroup targetCode="white text">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('white text').closest('pre')
expect(preElement?.className).toContain('text-white')
})
it('should have text-xs class on pre', () => {
render(
<CodeGroup targetCode="small text">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('small text').closest('pre')
expect(preElement?.className).toContain('text-xs')
})
it('should have overflow-x-auto on pre', () => {
render(
<CodeGroup targetCode="scrollable">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('scrollable').closest('pre')
expect(preElement?.className).toContain('overflow-x-auto')
})
it('should have p-4 padding on pre', () => {
render(
<CodeGroup targetCode="padded">
<pre><code>fallback</code></pre>
</CodeGroup>,
)
const preElement = screen.getByText('padded').closest('pre')
expect(preElement?.className).toContain('p-4')
})
})
describe('ClipboardIcon (via CopyButton in CodeGroup)', () => {
it('should render clipboard icon in copy button', () => {
describe('ClipboardIcon (via CopyButton)', () => {
it('should render clipboard SVG icon in copy button', () => {
render(
<CodeGroup targetCode="code">
<pre><code>fallback</code></pre>
@@ -543,7 +360,7 @@ describe('code.tsx components', () => {
})
})
describe('edge cases', () => {
describe('Edge Cases', () => {
it('should handle empty string targetCode', () => {
render(
<CodeGroup targetCode="">

View File

@@ -1,11 +1,9 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import InputCopy from '../input-copy'
vi.mock('copy-to-clipboard', () => ({
default: vi.fn().mockReturnValue(true),
}))
// Suppress expected React act() warnings from CopyFeedback timer-based state updates
vi.spyOn(console, 'error').mockImplementation(() => {})
async function renderAndFlush(ui: React.ReactElement) {
const result = render(ui)
@@ -15,10 +13,14 @@ async function renderAndFlush(ui: React.ReactElement) {
return result
}
const execCommandMock = vi.fn().mockReturnValue(true)
describe('InputCopy', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
execCommandMock.mockReturnValue(true)
document.execCommand = execCommandMock
})
afterEach(() => {
@@ -107,7 +109,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('copy-this-value')
expect(execCommandMock).toHaveBeenCalledWith('copy')
})
it('should update copied state after clicking', async () => {
@@ -119,7 +121,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('test-value')
expect(execCommandMock).toHaveBeenCalledWith('copy')
})
it('should reset copied state after timeout', async () => {
@@ -131,7 +133,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledWith('test-value')
expect(execCommandMock).toHaveBeenCalledWith('copy')
await act(async () => {
vi.advanceTimersByTime(1500)
@@ -306,7 +308,7 @@ describe('InputCopy', () => {
await user.click(copyableArea)
})
expect(copy).toHaveBeenCalledTimes(3)
expect(execCommandMock).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -3,6 +3,9 @@ import userEvent from '@testing-library/user-event'
import { afterEach } from 'vitest'
import SecretKeyModal from '../secret-key-modal'
// Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates
vi.spyOn(console, 'error').mockImplementation(() => {})
async function renderModal(ui: React.ReactElement) {
const result = render(ui)
await act(async () => {

View File

@@ -60,5 +60,11 @@ describe('Category', () => {
const allCategoriesItem = screen.getByText('explore.apps.allCategories')
expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
})
it('should render raw category name when i18n key does not exist', () => {
renderComponent({ list: ['CustomCategory', 'Recommended'] as AppCategory[] })
expect(screen.getByText('CustomCategory')).toBeInTheDocument()
})
})
})

View File

@@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import type { CurrentTryAppParams } from '@/context/explore-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
@@ -55,9 +56,21 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const ContextReader = () => {
const { hasEditPermission } = useContext(ExploreContext)
return <div>{hasEditPermission ? 'edit-yes' : 'edit-no'}</div>
const ContextReader = ({ triggerTryPanel }: { triggerTryPanel?: boolean }) => {
const { hasEditPermission, setShowTryAppPanel, isShowTryAppPanel, currentApp } = useContext(ExploreContext)
return (
<div>
{hasEditPermission ? 'edit-yes' : 'edit-no'}
{isShowTryAppPanel && <span data-testid="try-panel-open">open</span>}
{currentApp && <span data-testid="current-app">{currentApp.appId}</span>}
{triggerTryPanel && (
<>
<button data-testid="show-try" onClick={() => setShowTryAppPanel(true, { appId: 'test-app' } as CurrentTryAppParams)}>show</button>
<button data-testid="hide-try" onClick={() => setShowTryAppPanel(false)}>hide</button>
</>
)}
</div>
)
}
describe('Explore', () => {
@@ -123,5 +136,69 @@ describe('Explore', () => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
it('should skip permission check when membersData has no accounts', () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: undefined })
render((
<Explore>
<ContextReader />
</Explore>
))
expect(screen.getByText('edit-no')).toBeInTheDocument()
})
})
describe('Context: setShowTryAppPanel', () => {
it('should set currentApp params when showing try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
expect(screen.getByTestId('current-app')).toHaveTextContent('test-app')
})
})
it('should clear currentApp params when hiding try panel', async () => {
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
render((
<Explore>
<ContextReader triggerTryPanel />
</Explore>
))
fireEvent.click(screen.getByTestId('show-try'))
await waitFor(() => {
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('hide-try'))
await waitFor(() => {
expect(screen.queryByTestId('try-panel-open')).not.toBeInTheDocument()
expect(screen.queryByTestId('current-app')).not.toBeInTheDocument()
})
})
})
})

View File

@@ -2,6 +2,7 @@ import type { AppCardProps } from '../index'
import type { App } from '@/models/explore'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ExploreContext from '@/context/explore-context'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
@@ -136,5 +137,32 @@ describe('AppCard', () => {
expect(screen.getByText('Sample App')).toBeInTheDocument()
})
it('should call setShowTryAppPanel when try button is clicked', () => {
const mockSetShowTryAppPanel = vi.fn()
const app = createApp()
render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: false,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: mockSetShowTryAppPanel,
}}
>
<AppCard app={app} canCreate={true} onCreate={vi.fn()} isExplore={true} />
</ExploreContext.Provider>,
)
fireEvent.click(screen.getByText('explore.appCard.try'))
expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { appId: 'app-id', app })
})
})
})

View File

@@ -1,44 +1,21 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import ExploreContext from '@/context/explore-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import AppList from '../index'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn()
let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] }
let mockIsLoading = false
let mockIsError = false
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockTabValue, mockSetTab],
}
})
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: () => setTimeout(() => fnRef.current(), 0),
}
},
}
})
vi.mock('@/service/use-explore', () => ({
useExploreAppList: () => ({
data: mockExploreData,
@@ -85,6 +62,19 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({
},
}))
vi.mock('../../try-app', () => ({
default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => (
<div data-testid="try-app-panel">
<button data-testid="try-app-create" onClick={onCreate}>create</button>
<button data-testid="try-app-close" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('../../banner/banner', () => ({
default: () => <div data-testid="explore-banner">banner</div>,
}))
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
<div data-testid="dsl-confirm-modal">
@@ -121,35 +111,41 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => {
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
return render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>,
<NuqsTestingAdapter searchParams={searchParams}>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
}
describe('AppList', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockTabValue = allCategoriesEn
mockExploreData = { categories: [], allList: [] }
mockIsLoading = false
mockIsError = false
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render loading when the query is loading', () => {
mockExploreData = undefined
@@ -175,13 +171,12 @@ describe('AppList', () => {
describe('Props', () => {
it('should filter apps by selected category', () => {
mockTabValue = 'Writing'
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
renderWithContext()
renderWithContext(false, undefined, { category: 'Writing' })
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
@@ -199,13 +194,16 @@ describe('AppList', () => {
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('should handle create flow and confirm DSL when pending', async () => {
vi.useRealTimers()
const onSuccess = vi.fn()
mockExploreData = {
categories: ['Writing'],
@@ -247,16 +245,241 @@ describe('AppList', () => {
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('input-clear'))
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('should render nothing when isError is true', () => {
mockIsError = true
mockExploreData = undefined
const { container } = renderWithContext()
expect(container.innerHTML).toBe('')
})
it('should render nothing when data is undefined', () => {
mockExploreData = undefined
const { container } = renderWithContext()
expect(container.innerHTML).toBe('')
})
it('should reset filter when reset button is clicked', async () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('explore.apps.resetFilter'))
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('should close create modal via hide button', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
renderWithContext(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-create'))
await waitFor(() => {
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
it('should close create modal on successful DSL import', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
options.onSuccess?.()
})
renderWithContext(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
it('should cancel DSL confirm modal', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
renderWithContext(true)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
await waitFor(() => {
expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dsl-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('dsl-confirm-modal')).not.toBeInTheDocument()
})
})
})
describe('TryApp Panel', () => {
it('should open create modal from try app panel', async () => {
vi.useRealTimers()
const mockSetShowTryAppPanel = vi.fn()
const app = createApp()
mockExploreData = {
categories: ['Writing'],
allList: [app],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: mockSetShowTryAppPanel,
currentApp: { appId: 'app-1', app },
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
const createBtn = screen.getByTestId('try-app-create')
fireEvent.click(createBtn)
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should open create modal with null currApp when appParams has no app', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
currentApp: { appId: 'app-1' } as CurrentTryAppParams,
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
fireEvent.click(screen.getByTestId('try-app-create'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should render try app panel with empty appId when currentApp is undefined', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
render(
<NuqsTestingAdapter>
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: true,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList />
</ExploreContext.Provider>
</NuqsTestingAdapter>,
)
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
})
})
describe('Banner', () => {
it('should render banner when enable_explore_banner is true', () => {
useGlobalPublicStore.setState({
systemFeatures: {
...useGlobalPublicStore.getState().systemFeatures,
enable_explore_banner: true,
},
})
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
renderWithContext()
expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
})
})
})

View File

@@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import TryApp from '../index'
import { TypeEnum } from '../tab'
// Suppress expected React act() warnings from internal async state updates
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal() as object
return {

View File

@@ -13,10 +13,6 @@ vi.mock('../../../app/type-selector', () => ({
AppTypeIcon: () => null,
}))
vi.mock('../../../base/app-icon', () => ({
default: () => null,
}))
describe('appAction', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -62,10 +58,13 @@ describe('appAction', () => {
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
const results = await appAction.search('@app fail', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('App search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@@ -146,6 +146,7 @@ describe('searchAnything', () => {
})
it('handles action search failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const action: ActionItem = {
key: '@app',
shortcut: '@app',
@@ -156,6 +157,11 @@ describe('searchAnything', () => {
const results = await searchAnything('en', '@app test', action)
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Search failed for @app'),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('runs global search across all non-slash actions for plain queries', async () => {
@@ -183,6 +189,7 @@ describe('searchAnything', () => {
})
it('handles partial search failures in global search gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const dynamicActions: Record<string, ActionItem> = {
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) },
knowledge: {
@@ -200,6 +207,8 @@ describe('searchAnything', () => {
expect(results).toHaveLength(1)
expect(results[0].id).toBe('k1')
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
})

View File

@@ -9,10 +9,6 @@ vi.mock('@/utils/classnames', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}))
vi.mock('../../../base/icons/src/vender/solid/files', () => ({
Folder: () => null,
}))
describe('knowledgeAction', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -84,10 +80,13 @@ describe('knowledgeAction', () => {
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Knowledge search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@@ -55,18 +55,27 @@ describe('pluginAction', () => {
})
it('returns empty array when response has unexpected structure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
const results = await pluginAction.search('@plugin', 'test', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Plugin search: Unexpected response structure',
expect.anything(),
)
warnSpy.mockRestore()
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
const results = await pluginAction.search('@plugin', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@@ -12,9 +12,10 @@ import { forumCommand } from '../forum'
vi.mock('../command-bus')
const mockT = vi.fn((key: string) => key)
vi.mock('react-i18next', () => ({
getI18n: () => ({
t: (key: string) => key,
t: (key: string) => mockT(key),
language: 'en',
}),
}))
@@ -62,11 +63,32 @@ describe('docsCommand', () => {
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) =>
key.includes('docDesc') ? '' : key,
)
const results = await docsCommand.search('', 'en')
expect(results[0].description).toBe('Open help documentation')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.doc command', () => {
docsCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
})
it('registered handler opens doc URL with correct locale', async () => {
docsCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.doc']()
expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('unregisters navigation.doc command', () => {
docsCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
@@ -154,11 +176,42 @@ describe('communityCommand', () => {
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) =>
key.includes('communityDesc') ? '' : key,
)
const results = await communityCommand.search('', 'en')
expect(results[0].description).toBe('Open Discord community')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.community command', () => {
communityCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) })
})
it('registered handler opens URL from args', async () => {
communityCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.community']({ url: 'https://custom-url.com' })
expect(openSpy).toHaveBeenCalledWith('https://custom-url.com', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('registered handler falls back to default URL when no args', async () => {
communityCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.community']()
expect(openSpy).toHaveBeenCalledWith('https://discord.gg/5AEfbxcd9k', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('unregisters navigation.community command', () => {
communityCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community'])
@@ -200,11 +253,42 @@ describe('forumCommand', () => {
})
})
it('search uses fallback description when i18n returns empty', async () => {
mockT.mockImplementation((key: string) =>
key.includes('feedbackDesc') ? '' : key,
)
const results = await forumCommand.search('', 'en')
expect(results[0].description).toBe('Open community feedback discussions')
mockT.mockImplementation((key: string) => key)
})
it('registers navigation.forum command', () => {
forumCommand.register?.({} as Record<string, never>)
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) })
})
it('registered handler opens URL from args', async () => {
forumCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.forum']({ url: 'https://custom-forum.com' })
expect(openSpy).toHaveBeenCalledWith('https://custom-forum.com', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('registered handler falls back to default URL when no args', async () => {
forumCommand.register?.({} as Record<string, never>)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
await handlers['navigation.forum']()
expect(openSpy).toHaveBeenCalledWith('https://forum.dify.ai', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('unregisters navigation.forum command', () => {
forumCommand.unregister?.()
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum'])

View File

@@ -214,6 +214,7 @@ describe('SlashCommandRegistry', () => {
})
it('returns empty when handler.search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const handler = createHandler({
name: 'broken',
search: vi.fn().mockRejectedValue(new Error('fail')),
@@ -222,6 +223,11 @@ describe('SlashCommandRegistry', () => {
const results = await registry.search('/broken')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Command search failed'),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('excludes unavailable commands from root listing', async () => {

View File

@@ -7,142 +7,55 @@ describe('useTags', () => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return tags array', () => {
const { result } = renderHook(() => useTags())
it('should return non-empty tags array with name and label properties', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tags).toBeDefined()
expect(Array.isArray(result.current.tags)).toBe(true)
expect(result.current.tags.length).toBeGreaterThan(0)
})
it('should return tags with translated labels', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(tag.label).toBe(`pluginTags.tags.${tag.name}`)
})
})
it('should return tags with name and label properties', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(tag).toHaveProperty('name')
expect(tag).toHaveProperty('label')
expect(typeof tag.name).toBe('string')
expect(typeof tag.label).toBe('string')
})
})
it('should return tagsMap object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap).toBeDefined()
expect(typeof result.current.tagsMap).toBe('object')
expect(result.current.tags.length).toBeGreaterThan(0)
result.current.tags.forEach((tag) => {
expect(typeof tag.name).toBe('string')
expect(tag.label).toBe(`pluginTags.tags.${tag.name}`)
})
})
describe('tagsMap', () => {
it('should map tag name to tag object', () => {
const { result } = renderHook(() => useTags())
it('should build a tagsMap that maps every tag name to its object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap.agent).toBeDefined()
expect(result.current.tagsMap.agent.name).toBe('agent')
expect(result.current.tagsMap.agent.label).toBe('pluginTags.tags.agent')
})
it('should contain all tags from tags array', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(result.current.tagsMap[tag.name]).toBeDefined()
expect(result.current.tagsMap[tag.name]).toEqual(tag)
})
result.current.tags.forEach((tag) => {
expect(result.current.tagsMap[tag.name]).toEqual(tag)
})
})
describe('getTagLabel', () => {
it('should return label for existing tag', () => {
it('should return translated label for existing tags', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('agent')).toBe('pluginTags.tags.agent')
expect(result.current.getTagLabel('search')).toBe('pluginTags.tags.search')
expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag')
})
it('should return name for non-existing tag', () => {
it('should return the name itself for non-existing tags', () => {
const { result } = renderHook(() => useTags())
// Test non-existing tags - this covers the branch where !tagsMap[name]
expect(result.current.getTagLabel('non-existing')).toBe('non-existing')
expect(result.current.getTagLabel('custom-tag')).toBe('custom-tag')
})
it('should cover both branches of getTagLabel conditional', () => {
it('should handle edge cases: empty string and special characters', () => {
const { result } = renderHook(() => useTags())
const existingTagResult = result.current.getTagLabel('rag')
expect(existingTagResult).toBe('pluginTags.tags.rag')
const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz')
expect(nonExistingTagResult).toBe('unknown-tag-xyz')
})
it('should be a function', () => {
const { result } = renderHook(() => useTags())
expect(typeof result.current.getTagLabel).toBe('function')
})
it('should return correct labels for all predefined tags', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('rag')).toBe('pluginTags.tags.rag')
expect(result.current.getTagLabel('image')).toBe('pluginTags.tags.image')
expect(result.current.getTagLabel('videos')).toBe('pluginTags.tags.videos')
expect(result.current.getTagLabel('weather')).toBe('pluginTags.tags.weather')
expect(result.current.getTagLabel('finance')).toBe('pluginTags.tags.finance')
expect(result.current.getTagLabel('design')).toBe('pluginTags.tags.design')
expect(result.current.getTagLabel('travel')).toBe('pluginTags.tags.travel')
expect(result.current.getTagLabel('social')).toBe('pluginTags.tags.social')
expect(result.current.getTagLabel('news')).toBe('pluginTags.tags.news')
expect(result.current.getTagLabel('medical')).toBe('pluginTags.tags.medical')
expect(result.current.getTagLabel('productivity')).toBe('pluginTags.tags.productivity')
expect(result.current.getTagLabel('education')).toBe('pluginTags.tags.education')
expect(result.current.getTagLabel('business')).toBe('pluginTags.tags.business')
expect(result.current.getTagLabel('entertainment')).toBe('pluginTags.tags.entertainment')
expect(result.current.getTagLabel('utilities')).toBe('pluginTags.tags.utilities')
expect(result.current.getTagLabel('other')).toBe('pluginTags.tags.other')
})
it('should handle empty string tag name', () => {
const { result } = renderHook(() => useTags())
// Empty string tag doesn't exist, so should return the empty string
expect(result.current.getTagLabel('')).toBe('')
})
it('should handle special characters in tag name', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('tag-with-dashes')).toBe('tag-with-dashes')
expect(result.current.getTagLabel('tag_with_underscores')).toBe('tag_with_underscores')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useTags())
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useTags())
const firstTagsLength = result.current.tags.length
const firstTagNames = result.current.tags.map(t => t.name)
rerender()
// Structure should remain consistent
expect(result.current.tags.length).toBe(firstTagsLength)
expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames)
})
const firstTagNames = result.current.tags.map(t => t.name)
rerender()
expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames)
})
})
@@ -151,93 +64,46 @@ describe('useCategories', () => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return categories array', () => {
const { result } = renderHook(() => useCategories())
it('should return non-empty categories array with name and label properties', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categories).toBeDefined()
expect(Array.isArray(result.current.categories)).toBe(true)
expect(result.current.categories.length).toBeGreaterThan(0)
})
it('should return categories with name and label properties', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(category).toHaveProperty('name')
expect(category).toHaveProperty('label')
expect(typeof category.name).toBe('string')
expect(typeof category.label).toBe('string')
})
})
it('should return categoriesMap object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap).toBeDefined()
expect(typeof result.current.categoriesMap).toBe('object')
expect(result.current.categories.length).toBeGreaterThan(0)
result.current.categories.forEach((category) => {
expect(typeof category.name).toBe('string')
expect(typeof category.label).toBe('string')
})
})
describe('categoriesMap', () => {
it('should map category name to category object', () => {
const { result } = renderHook(() => useCategories())
it('should build a categoriesMap that maps every category name to its object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool).toBeDefined()
expect(result.current.categoriesMap.tool.name).toBe('tool')
})
it('should contain all categories from categories array', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(result.current.categoriesMap[category.name]).toBeDefined()
expect(result.current.categoriesMap[category.name]).toEqual(category)
})
result.current.categories.forEach((category) => {
expect(result.current.categoriesMap[category.name]).toEqual(category)
})
})
describe('isSingle parameter', () => {
it('should use plural labels when isSingle is false', () => {
const { result } = renderHook(() => useCategories(false))
expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools')
})
it('should use plural labels when isSingle is undefined', () => {
it('should use plural labels by default', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool.label).toBe('plugin.category.tools')
expect(result.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents')
})
it('should use singular labels when isSingle is true', () => {
const { result } = renderHook(() => useCategories(true))
expect(result.current.categoriesMap.tool.label).toBe('plugin.categorySingle.tool')
})
it('should handle agent category specially', () => {
const { result: resultPlural } = renderHook(() => useCategories(false))
const { result: resultSingle } = renderHook(() => useCategories(true))
expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('plugin.category.agents')
expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent')
expect(result.current.categoriesMap['agent-strategy'].label).toBe('plugin.categorySingle.agent')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useCategories())
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useCategories())
const firstCategoriesLength = result.current.categories.length
const firstCategoryNames = result.current.categories.map(c => c.name)
rerender()
// Structure should remain consistent
expect(result.current.categories.length).toBe(firstCategoriesLength)
expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames)
})
const firstCategoryNames = result.current.categories.map(c => c.name)
rerender()
expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames)
})
})
@@ -246,103 +112,26 @@ describe('usePluginPageTabs', () => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return tabs array', () => {
const { result } = renderHook(() => usePluginPageTabs())
it('should return two tabs: plugins first, marketplace second', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current).toBeDefined()
expect(Array.isArray(result.current)).toBe(true)
})
it('should return two tabs', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current.length).toBe(2)
})
it('should return tabs with value and text properties', () => {
const { result } = renderHook(() => usePluginPageTabs())
result.current.forEach((tab) => {
expect(tab).toHaveProperty('value')
expect(tab).toHaveProperty('text')
expect(typeof tab.value).toBe('string')
expect(typeof tab.text).toBe('string')
})
})
it('should return tabs with translated texts', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[0].text).toBe('common.menus.plugins')
expect(result.current[1].text).toBe('common.menus.exploreMarketplace')
})
expect(result.current).toHaveLength(2)
expect(result.current[0]).toEqual({ value: 'plugins', text: 'common.menus.plugins' })
expect(result.current[1]).toEqual({ value: 'discover', text: 'common.menus.exploreMarketplace' })
})
describe('Tab Values', () => {
it('should have plugins tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
it('should have consistent structure across re-renders', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins)
expect(pluginsTab).toBeDefined()
expect(pluginsTab?.value).toBe('plugins')
expect(pluginsTab?.text).toBe('common.menus.plugins')
})
it('should have marketplace tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace)
expect(marketplaceTab).toBeDefined()
expect(marketplaceTab?.value).toBe('discover')
expect(marketplaceTab?.text).toBe('common.menus.exploreMarketplace')
})
})
describe('Tab Order', () => {
it('should return plugins tab as first tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[0].value).toBe('plugins')
expect(result.current[0].text).toBe('common.menus.plugins')
})
it('should return marketplace tab as second tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[1].value).toBe('discover')
expect(result.current[1].text).toBe('common.menus.exploreMarketplace')
})
})
describe('Tab Structure', () => {
it('should have consistent structure across re-renders', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = [...result.current]
rerender()
expect(result.current).toEqual(firstTabs)
})
it('should return new array reference on each call', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = result.current
rerender()
// Each call creates a new array (not memoized)
expect(result.current).not.toBe(firstTabs)
})
const firstTabs = [...result.current]
rerender()
expect(result.current).toEqual(firstTabs)
})
})
describe('PLUGIN_PAGE_TABS_MAP', () => {
it('should have plugins key with correct value', () => {
it('should have correct key-value mappings', () => {
expect(PLUGIN_PAGE_TABS_MAP.plugins).toBe('plugins')
})
it('should have marketplace key with correct value', () => {
expect(PLUGIN_PAGE_TABS_MAP.marketplace).toBe('discover')
})
})

View File

@@ -0,0 +1,171 @@
import type { Mock } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('useFoldAnimInto', () => {
let mockOnClose: Mock<() => void>
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
mockOnClose = vi.fn<() => void>()
})
afterEach(() => {
vi.useRealTimers()
document.querySelectorAll('.install-modal, #plugin-task-trigger, .plugins-nav-button')
.forEach(el => el.remove())
})
it('should return modalClassName and functions', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
expect(result.current.modalClassName).toBe('install-modal')
expect(typeof result.current.foldIntoAnim).toBe('function')
expect(typeof result.current.clearCountDown).toBe('function')
expect(typeof result.current.countDownFoldIntoAnim).toBe('function')
})
describe('foldIntoAnim', () => {
it('should call onClose immediately when modal element is not found', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
await act(async () => {
await result.current.foldIntoAnim()
})
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when modal exists but trigger element is not found', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
const modal = document.createElement('div')
modal.className = 'install-modal'
document.body.appendChild(modal)
await act(async () => {
await result.current.foldIntoAnim()
})
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should animate and call onClose when both elements exist', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
const modal = document.createElement('div')
modal.className = 'install-modal'
Object.defineProperty(modal, 'getBoundingClientRect', {
value: () => ({ left: 100, top: 100, width: 400, height: 300 }),
})
document.body.appendChild(modal)
// Set up trigger element with id
const trigger = document.createElement('div')
trigger.id = 'plugin-task-trigger'
Object.defineProperty(trigger, 'getBoundingClientRect', {
value: () => ({ left: 50, top: 50, width: 40, height: 40 }),
})
document.body.appendChild(trigger)
await act(async () => {
await result.current.foldIntoAnim()
})
// Should apply animation styles
expect(modal.style.transition).toContain('750ms')
expect(modal.style.transform).toContain('translate')
expect(modal.style.transform).toContain('scale')
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should use plugins-nav-button as fallback trigger element', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
const modal = document.createElement('div')
modal.className = 'install-modal'
Object.defineProperty(modal, 'getBoundingClientRect', {
value: () => ({ left: 200, top: 200, width: 500, height: 400 }),
})
document.body.appendChild(modal)
// No #plugin-task-trigger, use .plugins-nav-button fallback
const navButton = document.createElement('div')
navButton.className = 'plugins-nav-button'
Object.defineProperty(navButton, 'getBoundingClientRect', {
value: () => ({ left: 10, top: 10, width: 30, height: 30 }),
})
document.body.appendChild(navButton)
await act(async () => {
await result.current.foldIntoAnim()
})
expect(modal.style.transform).toContain('translate')
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
describe('clearCountDown', () => {
it('should clear the countdown timer', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
// Start countdown then clear it
await act(async () => {
result.current.countDownFoldIntoAnim()
})
result.current.clearCountDown()
// Advance past the countdown time — onClose should NOT be called
await act(async () => {
vi.advanceTimersByTime(20000)
})
// onClose might still be called because foldIntoAnim's inner logic
// could fire, but the setTimeout itself should be cleared
})
})
describe('countDownFoldIntoAnim', () => {
it('should trigger foldIntoAnim after 15 seconds', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
await act(async () => {
result.current.countDownFoldIntoAnim()
})
// Advance by 15 seconds
await act(async () => {
vi.advanceTimersByTime(15000)
})
// foldIntoAnim would be called, but no modal in DOM so onClose is called directly
expect(mockOnClose).toHaveBeenCalled()
})
it('should not trigger before 15 seconds', async () => {
const useFoldAnimInto = (await import('../use-fold-anim-into')).default
const { result } = renderHook(() => useFoldAnimInto(mockOnClose))
await act(async () => {
result.current.countDownFoldIntoAnim()
})
// Advance only 10 seconds
await act(async () => {
vi.advanceTimersByTime(10000)
})
expect(mockOnClose).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,268 @@
import type { Dependency, InstallStatus, Plugin } from '../../../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InstallStep } from '../../../types'
import ReadyToInstall from '../ready-to-install'
// Track the onInstalled callback from the Install component
let capturedOnInstalled: ((plugins: Plugin[], installStatus: InstallStatus[]) => void) | null = null
vi.mock('../steps/install', () => ({
default: ({
allPlugins,
onCancel,
onStartToInstall,
onInstalled,
isFromMarketPlace,
}: {
allPlugins: Dependency[]
onCancel: () => void
onStartToInstall: () => void
onInstalled: (plugins: Plugin[], installStatus: InstallStatus[]) => void
isFromMarketPlace?: boolean
}) => {
capturedOnInstalled = onInstalled
return (
<div data-testid="install-step">
<span data-testid="install-plugins-count">{allPlugins?.length}</span>
<span data-testid="install-from-marketplace">{String(!!isFromMarketPlace)}</span>
<button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button>
<button data-testid="install-start-btn" onClick={onStartToInstall}>Start</button>
<button
data-testid="install-complete-btn"
onClick={() => onInstalled(
[{ plugin_id: 'p1', name: 'Plugin 1' } as Plugin],
[{ success: true, isFromMarketPlace: true }],
)}
>
Complete
</button>
</div>
)
},
}))
vi.mock('../steps/installed', () => ({
default: ({
list,
installStatus,
onCancel,
}: {
list: Plugin[]
installStatus: InstallStatus[]
onCancel: () => void
}) => (
<div data-testid="installed-step">
<span data-testid="installed-count">{list.length}</span>
<span data-testid="installed-status-count">{installStatus.length}</span>
<button data-testid="installed-close-btn" onClick={onCancel}>Close</button>
</div>
),
}))
const createMockDependencies = (): Dependency[] => [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'plugin-1-uid',
},
} as Dependency,
{
type: 'github',
value: {
repo: 'test/plugin2',
version: 'v1.0.0',
package: 'plugin2.zip',
},
} as Dependency,
]
describe('ReadyToInstall', () => {
const mockOnStepChange = vi.fn()
const mockOnStartToInstall = vi.fn()
const mockSetIsInstalling = vi.fn()
const mockOnClose = vi.fn()
const defaultProps = {
step: InstallStep.readyToInstall,
onStepChange: mockOnStepChange,
onStartToInstall: mockOnStartToInstall,
setIsInstalling: mockSetIsInstalling,
allPlugins: createMockDependencies(),
onClose: mockOnClose,
}
beforeEach(() => {
vi.clearAllMocks()
capturedOnInstalled = null
})
describe('readyToInstall step', () => {
it('should render Install component when step is readyToInstall', () => {
render(<ReadyToInstall {...defaultProps} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
})
it('should pass allPlugins count to Install component', () => {
render(<ReadyToInstall {...defaultProps} />)
expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('2')
})
it('should pass isFromMarketPlace to Install component', () => {
render(<ReadyToInstall {...defaultProps} isFromMarketPlace />)
expect(screen.getByTestId('install-from-marketplace')).toHaveTextContent('true')
})
it('should pass onClose as onCancel to Install', () => {
render(<ReadyToInstall {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-cancel-btn'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should pass onStartToInstall to Install', () => {
render(<ReadyToInstall {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-start-btn'))
expect(mockOnStartToInstall).toHaveBeenCalledTimes(1)
})
})
describe('handleInstalled callback', () => {
it('should transition to installed step when Install completes', () => {
render(<ReadyToInstall {...defaultProps} />)
// Trigger the onInstalled callback via the mock button
fireEvent.click(screen.getByTestId('install-complete-btn'))
// Should update step to installed
expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed)
// Should set isInstalling to false
expect(mockSetIsInstalling).toHaveBeenCalledWith(false)
})
it('should store installed plugins and status for the Installed step', () => {
const { rerender } = render(<ReadyToInstall {...defaultProps} />)
// Trigger install completion
fireEvent.click(screen.getByTestId('install-complete-btn'))
// Re-render with step=installed to show Installed component
rerender(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('installed-count')).toHaveTextContent('1')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('1')
})
it('should pass custom plugins and status via capturedOnInstalled', () => {
const { rerender } = render(<ReadyToInstall {...defaultProps} />)
// Use the captured callback directly with custom data
expect(capturedOnInstalled).toBeTruthy()
act(() => {
capturedOnInstalled!(
[
{ plugin_id: 'p1', name: 'P1' } as Plugin,
{ plugin_id: 'p2', name: 'P2' } as Plugin,
],
[
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: false },
],
)
})
expect(mockOnStepChange).toHaveBeenCalledWith(InstallStep.installed)
expect(mockSetIsInstalling).toHaveBeenCalledWith(false)
// Re-render at installed step
rerender(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.getByTestId('installed-count')).toHaveTextContent('2')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('2')
})
})
describe('installed step', () => {
it('should render Installed component when step is installed', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
it('should pass onClose to Installed component', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
fireEvent.click(screen.getByTestId('installed-close-btn'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should render empty installed list initially', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installed}
/>,
)
expect(screen.getByTestId('installed-count')).toHaveTextContent('0')
expect(screen.getByTestId('installed-status-count')).toHaveTextContent('0')
})
})
describe('edge cases', () => {
it('should render nothing when step is neither readyToInstall nor installed', () => {
const { container } = render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.uploading}
/>,
)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
// Only the empty fragment wrapper
expect(container.innerHTML).toBe('')
})
it('should handle empty allPlugins array', () => {
render(
<ReadyToInstall
{...defaultProps}
allPlugins={[]}
/>,
)
expect(screen.getByTestId('install-plugins-count')).toHaveTextContent('0')
})
})
})

View File

@@ -0,0 +1,246 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_SORT } from '../constants'
const createWrapper = (searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
return { wrapper, onUrlUpdate }
}
describe('Marketplace sort atoms', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return default sort value from useMarketplaceSort', async () => {
const { useMarketplaceSort } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
expect(result.current[0]).toEqual(DEFAULT_SORT)
expect(typeof result.current[1]).toBe('function')
})
it('should return default sort value from useMarketplaceSortValue', async () => {
const { useMarketplaceSortValue } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper })
expect(result.current).toEqual(DEFAULT_SORT)
})
it('should return setter from useSetMarketplaceSort', async () => {
const { useSetMarketplaceSort } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSetMarketplaceSort(), { wrapper })
expect(typeof result.current).toBe('function')
})
it('should update sort value via useMarketplaceSort setter', async () => {
const { useMarketplaceSort } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
act(() => {
result.current[1]({ sortBy: 'created_at', sortOrder: 'ASC' })
})
expect(result.current[0]).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
})
describe('useSearchPluginText', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty string as default', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(result.current[0]).toBe('')
expect(typeof result.current[1]).toBe('function')
})
it('should parse q from search params', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper('?q=hello')
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(result.current[0]).toBe('hello')
})
it('should expose a setter function for search text', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(typeof result.current[1]).toBe('function')
// Calling the setter should not throw
await act(async () => {
result.current[1]('search term')
})
})
})
describe('useActivePluginType', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return "all" as default category', async () => {
const { useActivePluginType } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useActivePluginType(), { wrapper })
expect(result.current[0]).toBe('all')
})
it('should parse category from search params', async () => {
const { useActivePluginType } = await import('../atoms')
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useActivePluginType(), { wrapper })
expect(result.current[0]).toBe('tool')
})
})
describe('useFilterPluginTags', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty array as default', async () => {
const { useFilterPluginTags } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
expect(result.current[0]).toEqual([])
})
it('should parse tags from search params', async () => {
const { useFilterPluginTags } = await import('../atoms')
const { wrapper } = createWrapper('?tags=search')
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
expect(result.current[0]).toEqual(['search'])
})
})
describe('useMarketplaceSearchMode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return false when no search text, no tags, and category has collections (all)', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
// "all" is in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode should be false
expect(result.current).toBe(false)
})
it('should return true when search text is present', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?q=test&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
})
it('should return true when tags are present', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?tags=search&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
})
it('should return true when category does not have collections (e.g. model)', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?category=model')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
// "model" is NOT in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode = true
expect(result.current).toBe(true)
})
it('should return false when category has collections (tool) and no search/tags', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(false)
})
})
describe('useMarketplaceMoreClick', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return a callback function', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
expect(typeof result.current).toBe('function')
})
it('should do nothing when called with no params', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
// Should not throw when called with undefined
act(() => {
result.current(undefined)
})
})
it('should update search state when called with search params', async () => {
const { useMarketplaceMoreClick, useMarketplaceSortValue } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
}), { wrapper })
act(() => {
result.current.handleMoreClick({
query: 'collection search',
sort_by: 'created_at',
sort_order: 'ASC',
})
})
// Sort should be updated via the jotai atom
expect(result.current.sort).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
it('should use defaults when search params fields are missing', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
act(() => {
result.current({})
})
})
})

View File

@@ -0,0 +1,369 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Integration tests for hooks.ts using real @tanstack/react-query
* instead of mocking it, to get proper V8 coverage of queryFn closures.
*/
let mockPostMarketplaceShouldFail = false
const mockPostMarketplaceResponse = {
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
total: 1,
},
}
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(async () => {
if (mockPostMarketplaceShouldFail)
throw new Error('Mock API error')
return mockPostMarketplaceResponse
}),
}))
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
},
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplaceCollectionsAndPlugins (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollections.mockResolvedValue({
data: {
collections: [
{ name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
],
},
})
mockCollectionPlugins.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
},
})
})
it('should fetch collections with real QueryClient when query is triggered', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
// Trigger query
result.current.queryMarketplaceCollectionsAndPlugins({ condition: 'category=tool' })
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
})
it('should handle query with empty params (truthy)', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
result.current.queryMarketplaceCollectionsAndPlugins({})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
it('should handle query without arguments (falsy branch)', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
// Call without arguments → query is undefined → falsy branch
result.current.queryMarketplaceCollectionsAndPlugins()
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
})
describe('useMarketplacePluginsByCollectionId (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollectionPlugins.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
},
})
})
it('should return empty when collectionId is undefined', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId(undefined),
{ wrapper: Wrapper },
)
expect(result.current.plugins).toEqual([])
})
it('should fetch plugins when collectionId is provided', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('collection-1'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.plugins.length).toBeGreaterThan(0)
})
})
describe('useMarketplacePlugins (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPostMarketplaceShouldFail = false
})
it('should return initial state without query', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.plugins).toBeUndefined()
expect(result.current.total).toBeUndefined()
expect(result.current.page).toBe(0)
expect(result.current.isLoading).toBe(false)
})
it('should show isLoading during initial fetch', async () => {
// Delay the response so we can observe the loading state
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockImplementationOnce(() => new Promise((resolve) => {
setTimeout(() => resolve({
data: { plugins: [], total: 0 },
}), 200)
}))
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'loading-test' })
// The isLoading should be true while fetching with no data
// (isPending || (isFetching && !data))
await waitFor(() => {
expect(result.current.isLoading).toBe(true)
})
// Eventually completes
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
})
it('should fetch plugins when queryPlugins is called', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
category: 'tool',
sort_by: 'install_count',
sort_order: 'DESC',
page_size: 40,
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins!.length).toBeGreaterThan(0)
expect(result.current.total).toBe(1)
expect(result.current.page).toBe(1)
})
it('should handle bundle type query', async () => {
mockPostMarketplaceShouldFail = false
const bundleResponse = {
data: {
plugins: [],
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }],
total: 1,
},
}
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValueOnce(bundleResponse)
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
type: 'bundle',
page_size: 40,
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should handle API error gracefully', async () => {
mockPostMarketplaceShouldFail = true
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'failing',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins).toEqual([])
expect(result.current.total).toBe(0)
})
it('should reset plugins state', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'test' })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
result.current.resetPlugins()
await waitFor(() => {
expect(result.current.plugins).toBeUndefined()
})
})
it('should use default page_size of 40 when not provided', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
category: 'all',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should handle queryPluginsWithDebounced', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPluginsWithDebounced({
query: 'debounced',
})
// Real useDebounceFn has 500ms wait, so increase timeout
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
}, { timeout: 3000 })
})
it('should handle response with bundles field (bundles || plugins fallback)', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValueOnce({
data: {
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }],
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
total: 2,
},
})
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test-bundles-fallback',
type: 'bundle',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
// Should use bundles (truthy first in || chain)
expect(result.current.plugins!.length).toBeGreaterThan(0)
})
it('should handle response with no bundles and no plugins (empty fallback)', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValueOnce({
data: {
total: 0,
},
})
const { useMarketplacePlugins } = await import('../hooks')
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test-empty-fallback',
})
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins).toEqual([])
})
})

View File

@@ -1,10 +1,8 @@
import { render, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ================================
// Mock External Dependencies
// ================================
vi.mock('@/i18n-config/i18next-config', () => ({
default: {
getFixedT: () => (key: string) => key,
@@ -26,62 +24,19 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
const mockFetchNextPage = vi.fn()
const mockHasNextPage = false
let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined
let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => {
capturedQueryFn = queryFn
if (queryFn) {
const controller = new AbortController()
queryFn({ signal: controller.signal }).catch(() => {})
}
return {
data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined,
isFetching: false,
isPending: false,
isSuccess: enabled,
}
}),
useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: {
queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>
getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined
enabled: boolean
}) => {
capturedInfiniteQueryFn = queryFn
capturedGetNextPageParam = getNextPageParam
if (queryFn) {
const controller = new AbortController()
queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {})
}
if (getNextPageParam) {
getNextPageParam({ page: 1, page_size: 40, total: 100 })
getNextPageParam({ page: 3, page_size: 40, total: 100 })
}
return {
data: mockInfiniteQueryData,
isPending: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: mockHasNextPage,
fetchNextPage: mockFetchNextPage,
}
}),
useQueryClient: vi.fn(() => ({
removeQueries: vi.fn(),
})),
}))
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: fn,
cancel: vi.fn(),
}),
}))
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return { Wrapper, queryClient }
}
let mockPostMarketplaceShouldFail = false
const mockPostMarketplaceResponse = {
@@ -150,59 +105,26 @@ vi.mock('@/service/client', () => ({
},
}))
// ================================
// useMarketplaceCollectionsAndPlugins Tests
// ================================
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return initial state correctly', async () => {
it('should return initial state with all required properties', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
expect(result.current.setMarketplaceCollections).toBeDefined()
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
})
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
})
it('should provide setMarketplaceCollections function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollections).toBe('function')
})
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
})
it('should return marketplaceCollections from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollections).toBeUndefined()
})
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
})
})
// ================================
// useMarketplacePluginsByCollectionId Tests
// ================================
describe('useMarketplacePluginsByCollectionId', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -210,7 +132,11 @@ describe('useMarketplacePluginsByCollectionId', () => {
it('should return initial state when collectionId is undefined', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined))
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId(undefined),
{ wrapper: Wrapper },
)
expect(result.current.plugins).toEqual([])
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
@@ -218,39 +144,54 @@ describe('useMarketplacePluginsByCollectionId', () => {
it('should return isLoading false when collectionId is provided and query completes', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'))
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('test-collection'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.isLoading).toBe(false)
})
it('should accept query parameter', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() =>
useMarketplacePluginsByCollectionId('test-collection', {
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('test-collection', {
category: 'tool',
type: 'plugin',
}))
expect(result.current.plugins).toBeDefined()
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should return plugins property from hook', async () => {
const { useMarketplacePluginsByCollectionId } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'))
expect(result.current.plugins).toBeDefined()
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePluginsByCollectionId('collection-1'),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
})
// ================================
// useMarketplacePlugins Tests
// ================================
describe('useMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
})
it('should return initial state correctly', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.plugins).toBeUndefined()
expect(result.current.total).toBeUndefined()
expect(result.current.isLoading).toBe(false)
@@ -259,39 +200,21 @@ describe('useMarketplacePlugins', () => {
expect(result.current.page).toBe(0)
})
it('should provide queryPlugins function', async () => {
it('should expose all required functions', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(typeof result.current.queryPlugins).toBe('function')
})
it('should provide queryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.queryPluginsWithDebounced).toBe('function')
})
it('should provide cancelQueryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function')
})
it('should provide resetPlugins function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.resetPlugins).toBe('function')
})
it('should provide fetchNextPage function', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.fetchNextPage).toBe('function')
})
it('should handle queryPlugins call without errors', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.queryPlugins({
query: 'test',
@@ -305,7 +228,8 @@ describe('useMarketplacePlugins', () => {
it('should handle queryPlugins with bundle type', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.queryPlugins({
query: 'test',
@@ -317,7 +241,8 @@ describe('useMarketplacePlugins', () => {
it('should handle resetPlugins call', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.resetPlugins()
}).not.toThrow()
@@ -325,18 +250,28 @@ describe('useMarketplacePlugins', () => {
it('should handle queryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
vi.useFakeTimers()
expect(() => {
result.current.queryPluginsWithDebounced({
query: 'debounced search',
category: 'all',
})
}).not.toThrow()
act(() => {
vi.advanceTimersByTime(500)
})
vi.useRealTimers()
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should handle cancelQueryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.cancelQueryPluginsWithDebounced()
}).not.toThrow()
@@ -344,13 +279,15 @@ describe('useMarketplacePlugins', () => {
it('should return correct page number', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.page).toBe(0)
})
it('should handle queryPlugins with tags', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(() => {
result.current.queryPlugins({
query: 'test',
@@ -361,60 +298,76 @@ describe('useMarketplacePlugins', () => {
})
})
// ================================
// Hooks queryFn Coverage Tests
// ================================
describe('Hooks queryFn Coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
mockPostMarketplaceShouldFail = false
capturedInfiniteQueryFn = null
capturedQueryFn = null
})
it('should cover queryFn with pages data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 },
],
}
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'test',
category: 'tool',
})
expect(result.current).toBeDefined()
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should expose page and total from infinite query data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 },
{ plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 },
],
}
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace)
.mockResolvedValueOnce({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
],
total: 100,
},
})
.mockResolvedValueOnce({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'plugin3', tags: [] }],
total: 100,
},
})
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'search' })
expect(result.current.page).toBe(2)
result.current.queryPlugins({ query: 'search', page_size: 40 })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
expect(result.current.page).toBe(1)
expect(result.current.hasNextPage).toBe(true)
})
await act(async () => {
await result.current.fetchNextPage()
})
await waitFor(() => {
expect(result.current.page).toBe(2)
})
})
it('should return undefined total when no query is set', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
expect(result.current.total).toBeUndefined()
})
it('should directly test queryFn execution', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
query: 'direct test',
@@ -424,82 +377,98 @@ describe('Hooks queryFn Coverage', () => {
page_size: 40,
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
}
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should test queryFn with bundle type', async () => {
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({
type: 'bundle',
query: 'bundle test',
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal })
expect(response).toBeDefined()
}
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
})
it('should test queryFn error handling', async () => {
mockPostMarketplaceShouldFail = true
const { useMarketplacePlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
result.current.queryPlugins({ query: 'test that will fail' })
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
expect(response).toHaveProperty('plugins')
}
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
expect(result.current.plugins).toEqual([])
expect(result.current.total).toBe(0)
mockPostMarketplaceShouldFail = false
})
it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('../hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins(), { wrapper: Wrapper })
result.current.queryMarketplaceCollectionsAndPlugins({
condition: 'category=tool',
})
if (capturedQueryFn) {
const controller = new AbortController()
const response = await capturedQueryFn({ signal: controller.signal })
expect(response).toBeDefined()
}
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
})
it('should test getNextPageParam directly', async () => {
it('should test getNextPageParam via fetchNextPage behavior', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace)
.mockResolvedValueOnce({
data: { plugins: [], total: 100 },
})
.mockResolvedValueOnce({
data: { plugins: [], total: 100 },
})
.mockResolvedValueOnce({
data: { plugins: [], total: 100 },
})
const { useMarketplacePlugins } = await import('../hooks')
renderHook(() => useMarketplacePlugins())
const { Wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplacePlugins(), { wrapper: Wrapper })
if (capturedGetNextPageParam) {
const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 })
expect(nextPage).toBe(2)
result.current.queryPlugins({ query: 'test', page_size: 40 })
const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 })
expect(noMorePages).toBeUndefined()
await waitFor(() => {
expect(result.current.hasNextPage).toBe(true)
expect(result.current.page).toBe(1)
})
const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 })
expect(atBoundary).toBeUndefined()
}
result.current.fetchNextPage()
await waitFor(() => {
expect(result.current.hasNextPage).toBe(true)
expect(result.current.page).toBe(2)
})
result.current.fetchNextPage()
await waitFor(() => {
expect(result.current.hasNextPage).toBe(false)
expect(result.current.page).toBe(3)
})
})
})
// ================================
// useMarketplaceContainerScroll Tests
// ================================
describe('useMarketplaceContainerScroll', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -0,0 +1,122 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
},
}))
let serverQueryClient: QueryClient
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => serverQueryClient,
}))
describe('HydrateQueryClient', () => {
beforeEach(() => {
vi.clearAllMocks()
serverQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
mockCollections.mockResolvedValue({
data: { collections: [] },
})
mockCollectionPlugins.mockResolvedValue({
data: { plugins: [] },
})
})
it('should render children within HydrationBoundary', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
const element = await HydrateQueryClient({
searchParams: undefined,
children: <div data-testid="child">Child Content</div>,
})
const renderClient = new QueryClient()
const { getByText } = render(
<QueryClientProvider client={renderClient}>
{element as React.ReactElement}
</QueryClientProvider>,
)
expect(getByText('Child Content')).toBeInTheDocument()
})
it('should not prefetch when searchParams is undefined', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: undefined,
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
})
it('should prefetch when category has collections (all)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'all' }),
children: <div>Child</div>,
})
expect(mockCollections).toHaveBeenCalled()
})
it('should prefetch when category has collections (tool)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'tool' }),
children: <div>Child</div>,
})
expect(mockCollections).toHaveBeenCalled()
})
it('should not prefetch when category does not have collections (model)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'model' }),
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
})
it('should not prefetch when category does not have collections (bundle)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'bundle' }),
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
})
})

View File

@@ -1,15 +1,95 @@
import { describe, it } from 'vitest'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The Marketplace index component is an async Server Component
// that cannot be unit tested in jsdom. It is covered by integration tests.
//
// All sub-module tests have been moved to dedicated spec files:
// - constants.spec.ts (DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD, PLUGIN_TYPE_SEARCH_MAP)
// - utils.spec.ts (getPluginIconInMarketplace, getFormattedPlugin, getPluginLinkInMarketplace, etc.)
// - hooks.spec.tsx (useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceContainerScroll)
vi.mock('@/context/query-client', () => ({
TanstackQueryInitializer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tanstack-initializer">{children}</div>
),
}))
describe('Marketplace index', () => {
it('should be covered by dedicated sub-module specs', () => {
// Placeholder to document the split
vi.mock('../hydration-server', () => ({
HydrateQueryClient: ({ children }: { children: React.ReactNode }) => (
<div data-testid="hydration-client">{children}</div>
),
}))
vi.mock('../description', () => ({
default: () => <div data-testid="description">Description</div>,
}))
vi.mock('../list/list-wrapper', () => ({
default: ({ showInstallButton }: { showInstallButton: boolean }) => (
<div data-testid="list-wrapper" data-show-install={showInstallButton}>ListWrapper</div>
),
}))
vi.mock('../sticky-search-and-switch-wrapper', () => ({
default: ({ pluginTypeSwitchClassName }: { pluginTypeSwitchClassName?: string }) => (
<div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}>StickyWrapper</div>
),
}))
describe('Marketplace', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should export a default async component', async () => {
const mod = await import('../index')
expect(mod.default).toBeDefined()
expect(typeof mod.default).toBe('function')
})
it('should render all child components with default props', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
expect(getByTestId('tanstack-initializer')).toBeInTheDocument()
expect(getByTestId('hydration-client')).toBeInTheDocument()
expect(getByTestId('description')).toBeInTheDocument()
expect(getByTestId('sticky-wrapper')).toBeInTheDocument()
expect(getByTestId('list-wrapper')).toBeInTheDocument()
})
it('should pass showInstallButton=true by default to ListWrapper', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
const listWrapper = getByTestId('list-wrapper')
expect(listWrapper.getAttribute('data-show-install')).toBe('true')
})
it('should pass showInstallButton=false when specified', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({ showInstallButton: false })
const { getByTestId } = render(element as React.ReactElement)
const listWrapper = getByTestId('list-wrapper')
expect(listWrapper.getAttribute('data-show-install')).toBe('false')
})
it('should pass pluginTypeSwitchClassName to StickySearchAndSwitchWrapper', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({ pluginTypeSwitchClassName: 'top-14' })
const { getByTestId } = render(element as React.ReactElement)
const stickyWrapper = getByTestId('sticky-wrapper')
expect(stickyWrapper.getAttribute('data-classname')).toBe('top-14')
})
it('should render without pluginTypeSwitchClassName', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
const stickyWrapper = getByTestId('sticky-wrapper')
expect(stickyWrapper.getAttribute('data-classname')).toBeNull()
})
})

View File

@@ -0,0 +1,124 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginTypeSwitch from '../plugin-type-switch'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'category.all': 'All',
'category.models': 'Models',
'category.tools': 'Tools',
'category.datasources': 'Data Sources',
'category.triggers': 'Triggers',
'category.agents': 'Agents',
'category.extensions': 'Extensions',
'category.bundles': 'Bundles',
}
return map[key] || key
},
}),
}))
const createWrapper = (searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
return { Wrapper, onUrlUpdate }
}
describe('PluginTypeSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all category options', () => {
const { Wrapper } = createWrapper()
render(<PluginTypeSwitch />, { wrapper: Wrapper })
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Data Sources')).toBeInTheDocument()
expect(screen.getByText('Triggers')).toBeInTheDocument()
expect(screen.getByText('Agents')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should apply active styling to current category', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
const allButton = screen.getByText('All').closest('div')
expect(allButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
it('should apply custom className', () => {
const { Wrapper } = createWrapper()
const { container } = render(<PluginTypeSwitch className="custom-class" />, { wrapper: Wrapper })
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('custom-class')
})
it('should update category when option is clicked', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on Models option — should not throw
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
})
it('should handle clicking on category with collections (Tools)', () => {
const { Wrapper } = createWrapper('?category=model')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on "Tools" which has collections → setSearchMode(null)
expect(() => fireEvent.click(screen.getByText('Tools'))).not.toThrow()
})
it('should handle clicking on category without collections (Models)', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on "Models" which does NOT have collections → no setSearchMode call
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
})
it('should handle clicking on bundles', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
expect(() => fireEvent.click(screen.getByText('Bundles'))).not.toThrow()
})
it('should handle clicking on each category', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles']
categories.forEach((category) => {
expect(() => fireEvent.click(screen.getByText(category))).not.toThrow()
})
})
it('should render icons for categories that have them', () => {
const { Wrapper } = createWrapper()
const { container } = render(<PluginTypeSwitch />, { wrapper: Wrapper })
// "All" has no icon (icon: null), others should have SVG icons
const svgs = container.querySelectorAll('svg')
// 7 categories with icons (all categories except "All")
expect(svgs.length).toBeGreaterThanOrEqual(7)
})
})

View File

@@ -0,0 +1,220 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
},
},
}))
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should fetch collections and plugins data', async () => {
const mockCollectionData = [
{ name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
]
const mockPluginData = [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
]
mockCollections.mockResolvedValue({ data: { collections: mockCollectionData } })
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
const { useMarketplaceCollectionsAndPlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin' }),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
expect(result.current.data?.marketplaceCollections).toBeDefined()
expect(result.current.data?.marketplaceCollectionPluginsMap).toBeDefined()
})
it('should handle empty collections params', async () => {
mockCollections.mockResolvedValue({ data: { collections: [] } })
const { useMarketplaceCollectionsAndPlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplaceCollectionsAndPlugins({}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
})
})
describe('useMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should not fetch when queryParams is undefined', async () => {
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins(undefined),
{ wrapper: Wrapper },
)
// enabled is false, so should not fetch
expect(result.current.data).toBeUndefined()
expect(mockSearchAdvanced).not.toHaveBeenCalled()
})
it('should fetch plugins when queryParams is provided', async () => {
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
total: 1,
},
})
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'test',
sort_by: 'install_count',
sort_order: 'DESC',
category: 'tool',
tags: [],
type: 'plugin',
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
expect(result.current.data?.pages).toHaveLength(1)
expect(result.current.data?.pages[0].plugins).toHaveLength(1)
})
it('should handle bundle type in query params', async () => {
mockSearchAdvanced.mockResolvedValue({
data: {
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [] }],
total: 1,
},
})
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'bundle',
type: 'bundle',
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
})
it('should handle API error gracefully', async () => {
mockSearchAdvanced.mockRejectedValue(new Error('Network error'))
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'fail',
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
expect(result.current.data?.pages[0].plugins).toEqual([])
expect(result.current.data?.pages[0].total).toBe(0)
})
it('should determine next page correctly via getNextPageParam', async () => {
// Return enough data that there would be a next page
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, i) => ({
type: 'plugin',
org: 'test',
name: `p${i}`,
tags: [],
})),
total: 100,
},
})
const { useMarketplacePlugins } = await import('../query')
const { Wrapper } = createWrapper()
const { result } = renderHook(
() => useMarketplacePlugins({
query: 'paginated',
page_size: 40,
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.hasNextPage).toBe(true)
})
})
})

View File

@@ -0,0 +1,267 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
},
},
}))
const createWrapper = (searchParams = '') => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
</JotaiProvider>
)
return { Wrapper, queryClient }
}
describe('useMarketplaceData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollections.mockResolvedValue({
data: {
collections: [
{ name: 'col-1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
],
},
})
mockCollectionPlugins.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
},
})
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p2', tags: [] }],
total: 1,
},
})
})
it('should return initial state with loading and collections data', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=all')
// Create a mock container for scroll
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
expect(result.current.page).toBeDefined()
expect(result.current.isFetchingNextPage).toBe(false)
document.body.removeChild(container)
})
it('should return search mode data when search text is present', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=all&q=test')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.plugins).toBeDefined()
expect(result.current.pluginsTotal).toBeDefined()
document.body.removeChild(container)
})
it('should return plugins undefined in collection mode (not search mode)', async () => {
const { useMarketplaceData } = await import('../state')
// "all" category with no search → collection mode
const { Wrapper } = createWrapper('?category=all')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// In non-search mode, plugins should be undefined since useMarketplacePlugins is disabled
expect(result.current.plugins).toBeUndefined()
document.body.removeChild(container)
})
it('should enable search for category without collections (e.g. model)', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// "model" triggers search mode automatically
expect(result.current.plugins).toBeDefined()
document.body.removeChild(container)
})
it('should trigger scroll pagination via handlePageChange callback', async () => {
// Return enough data to indicate hasNextPage (40 of 200 total)
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, i) => ({
type: 'plugin',
org: 'test',
name: `p${i}`,
tags: [],
})),
total: 200,
},
})
const { useMarketplaceData } = await import('../state')
// Use "model" to force search mode
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true })
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
// Wait for data to fully load (isFetching becomes false, plugins become available)
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
expect(result.current.plugins!.length).toBeGreaterThan(0)
})
// Trigger scroll event to invoke handlePageChange
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: container })
container.dispatchEvent(scrollEvent)
document.body.removeChild(container)
})
it('should handle tags filter in search mode', async () => {
const { useMarketplaceData } = await import('../state')
// tags in URL triggers search mode
const { Wrapper } = createWrapper('?category=all&tags=search')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// Tags triggers search mode even with "all" category
expect(result.current.plugins).toBeDefined()
document.body.removeChild(container)
})
it('should not fetch next page when scroll fires but no more data', async () => {
// Return only 2 items with total=2 → no more pages
mockSearchAdvanced.mockResolvedValue({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'p1', tags: [] },
{ type: 'plugin', org: 'test', name: 'p2', tags: [] },
],
total: 2,
},
})
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
Object.defineProperty(container, 'scrollTop', { value: 900, writable: true, configurable: true })
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()
})
// Scroll fires but hasNextPage is false → handlePageChange does nothing
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: container })
container.dispatchEvent(scrollEvent)
// isFetchingNextPage should remain false
expect(result.current.isFetchingNextPage).toBe(false)
document.body.removeChild(container)
})
})

View File

@@ -0,0 +1,79 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock child components to isolate wrapper logic
vi.mock('../plugin-type-switch', () => ({
default: () => <div data-testid="plugin-type-switch">PluginTypeSwitch</div>,
}))
vi.mock('../search-box/search-box-wrapper', () => ({
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
}))
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsTestingAdapter>
{children}
</NuqsTestingAdapter>
</JotaiProvider>
)
describe('StickySearchAndSwitchWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render SearchBoxWrapper and PluginTypeSwitch', () => {
const { getByTestId } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
)
expect(getByTestId('search-box-wrapper')).toBeInTheDocument()
expect(getByTestId('plugin-type-switch')).toBeInTheDocument()
})
it('should not apply sticky class when no pluginTypeSwitchClassName', () => {
const { container } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('mt-4')
expect(outerDiv.className).not.toContain('sticky')
})
it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => {
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('sticky')
expect(outerDiv.className).toContain('z-10')
expect(outerDiv.className).toContain('top-10')
})
it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => {
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).not.toContain('sticky')
expect(outerDiv.className).toContain('custom-class')
})
})

View File

@@ -315,3 +315,165 @@ describe('getCollectionsParams', () => {
})
})
})
describe('getMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty result when queryParams is undefined', async () => {
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins(undefined, 1)
expect(result).toEqual({
plugins: [],
total: 0,
page: 1,
page_size: 40,
})
expect(mockSearchAdvanced).not.toHaveBeenCalled()
})
it('should fetch plugins with valid query params', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: {
plugins: [{ type: 'plugin', org: 'test', name: 'p1', tags: [] }],
total: 1,
},
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({
query: 'test',
sort_by: 'install_count',
sort_order: 'DESC',
category: 'tool',
tags: ['search'],
type: 'plugin',
page_size: 20,
}, 1)
expect(result.plugins).toHaveLength(1)
expect(result.total).toBe(1)
expect(result.page).toBe(1)
expect(result.page_size).toBe(20)
})
it('should use bundles endpoint when type is bundle', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: {
bundles: [{ type: 'bundle', org: 'test', name: 'b1', tags: [], description: 'desc', labels: {} }],
total: 1,
},
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({
query: 'bundle',
type: 'bundle',
}, 1)
expect(result.plugins).toHaveLength(1)
const call = mockSearchAdvanced.mock.calls[0]
expect(call[0].params.kind).toBe('bundles')
})
it('should use empty category when category is all', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: { plugins: [], total: 0 },
})
const { getMarketplacePlugins } = await import('../utils')
await getMarketplacePlugins({
query: 'test',
category: 'all',
}, 1)
const call = mockSearchAdvanced.mock.calls[0]
expect(call[0].body.category).toBe('')
})
it('should handle API error and return empty result', async () => {
mockSearchAdvanced.mockRejectedValueOnce(new Error('API error'))
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({
query: 'fail',
}, 2)
expect(result).toEqual({
plugins: [],
total: 0,
page: 2,
page_size: 40,
})
})
it('should pass abort signal when provided', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: { plugins: [], total: 0 },
})
const controller = new AbortController()
const { getMarketplacePlugins } = await import('../utils')
await getMarketplacePlugins({ query: 'test' }, 1, controller.signal)
const call = mockSearchAdvanced.mock.calls[0]
expect(call[1]).toMatchObject({ signal: controller.signal })
})
it('should default page_size to 40 when not provided', async () => {
mockSearchAdvanced.mockResolvedValueOnce({
data: { plugins: [], total: 0 },
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({ query: 'test' }, 1)
expect(result.page_size).toBe(40)
})
it('should handle response with bundles fallback to plugins fallback to empty', async () => {
// No bundles and no plugins in response
mockSearchAdvanced.mockResolvedValueOnce({
data: { total: 0 },
})
const { getMarketplacePlugins } = await import('../utils')
const result = await getMarketplacePlugins({ query: 'test' }, 1)
expect(result.plugins).toEqual([])
})
})
// ================================
// Edge cases for ||/optional chaining branches
// ================================
describe('Utils branch edge cases', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle collectionPlugins returning undefined plugins', async () => {
mockCollectionPlugins.mockResolvedValueOnce({
data: { plugins: undefined },
})
const { getMarketplacePluginsByCollectionId } = await import('../utils')
const result = await getMarketplacePluginsByCollectionId('test-collection')
expect(result).toEqual([])
})
it('should handle collections returning undefined collections list', async () => {
mockCollections.mockResolvedValueOnce({
data: { collections: undefined },
})
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
const result = await getMarketplaceCollectionsAndPlugins()
// undefined || [] evaluates to [], so empty array is expected
expect(result.marketplaceCollections).toEqual([])
})
})

View File

@@ -1,597 +0,0 @@
import { render, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n-config/i18next-config', () => ({
default: {
getFixedT: () => (key: string) => key,
},
}))
const mockSetUrlFilters = vi.fn()
vi.mock('@/hooks/use-query-params', () => ({
useMarketplaceFilters: () => [
{ q: '', tags: [], category: '' },
mockSetUrlFilters,
],
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { plugins: [] },
isSuccess: true,
}),
}))
const mockFetchNextPage = vi.fn()
const mockHasNextPage = false
let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined
let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null
let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => {
capturedQueryFn = queryFn
if (queryFn) {
const controller = new AbortController()
queryFn({ signal: controller.signal }).catch(() => {})
}
return {
data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined,
isFetching: false,
isPending: false,
isSuccess: enabled,
}
}),
useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: {
queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>
getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined
enabled: boolean
}) => {
capturedInfiniteQueryFn = queryFn
capturedGetNextPageParam = getNextPageParam
if (queryFn) {
const controller = new AbortController()
queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {})
}
if (getNextPageParam) {
getNextPageParam({ page: 1, page_size: 40, total: 100 })
getNextPageParam({ page: 3, page_size: 40, total: 100 })
}
return {
data: mockInfiniteQueryData,
isPending: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: mockHasNextPage,
fetchNextPage: mockFetchNextPage,
}
}),
useQueryClient: vi.fn(() => ({
removeQueries: vi.fn(),
})),
}))
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: fn,
cancel: vi.fn(),
}),
}))
let mockPostMarketplaceShouldFail = false
const mockPostMarketplaceResponse = {
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
],
bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>,
total: 2,
},
}
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(() => {
if (mockPostMarketplaceShouldFail)
return Promise.reject(new Error('Mock API error'))
return Promise.resolve(mockPostMarketplaceResponse)
}),
}))
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
IS_MARKETPLACE: false,
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: vi.fn(async () => ({
data: {
collections: [
{
name: 'collection-1',
label: { 'en-US': 'Collection 1' },
description: { 'en-US': 'Desc' },
rule: '',
created_at: '2024-01-01',
updated_at: '2024-01-01',
searchable: true,
search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' },
},
],
},
})),
collectionPlugins: vi.fn(async () => ({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
},
})),
searchAdvanced: vi.fn(async () => ({
data: {
plugins: [
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
],
total: 1,
},
})),
},
}))
// ================================
// useMarketplaceCollectionsAndPlugins Tests
// ================================
describe('useMarketplaceCollectionsAndPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return initial state correctly', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
expect(result.current.setMarketplaceCollections).toBeDefined()
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
})
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
})
it('should provide setMarketplaceCollections function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollections).toBe('function')
})
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
})
it('should return marketplaceCollections from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollections).toBeUndefined()
})
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
})
})
// ================================
// useMarketplacePluginsByCollectionId Tests
// ================================
describe('useMarketplacePluginsByCollectionId', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return initial state when collectionId is undefined', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined))
expect(result.current.plugins).toEqual([])
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
})
it('should return isLoading false when collectionId is provided and query completes', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'))
expect(result.current.isLoading).toBe(false)
})
it('should accept query parameter', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() =>
useMarketplacePluginsByCollectionId('test-collection', {
category: 'tool',
type: 'plugin',
}))
expect(result.current.plugins).toBeDefined()
})
it('should return plugins property from hook', async () => {
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'))
expect(result.current.plugins).toBeDefined()
})
})
// ================================
// useMarketplacePlugins Tests
// ================================
describe('useMarketplacePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
})
it('should return initial state correctly', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(result.current.plugins).toBeUndefined()
expect(result.current.total).toBeUndefined()
expect(result.current.isLoading).toBe(false)
expect(result.current.isFetchingNextPage).toBe(false)
expect(result.current.hasNextPage).toBe(false)
expect(result.current.page).toBe(0)
})
it('should provide queryPlugins function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.queryPlugins).toBe('function')
})
it('should provide queryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.queryPluginsWithDebounced).toBe('function')
})
it('should provide cancelQueryPluginsWithDebounced function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function')
})
it('should provide resetPlugins function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.resetPlugins).toBe('function')
})
it('should provide fetchNextPage function', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(typeof result.current.fetchNextPage).toBe('function')
})
it('should handle queryPlugins call without errors', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPlugins({
query: 'test',
sort_by: 'install_count',
sort_order: 'DESC',
category: 'tool',
page_size: 20,
})
}).not.toThrow()
})
it('should handle queryPlugins with bundle type', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPlugins({
query: 'test',
type: 'bundle',
page_size: 40,
})
}).not.toThrow()
})
it('should handle resetPlugins call', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.resetPlugins()
}).not.toThrow()
})
it('should handle queryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPluginsWithDebounced({
query: 'debounced search',
category: 'all',
})
}).not.toThrow()
})
it('should handle cancelQueryPluginsWithDebounced call', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.cancelQueryPluginsWithDebounced()
}).not.toThrow()
})
it('should return correct page number', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(result.current.page).toBe(0)
})
it('should handle queryPlugins with tags', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(() => {
result.current.queryPlugins({
query: 'test',
tags: ['search', 'image'],
exclude: ['excluded-plugin'],
})
}).not.toThrow()
})
})
// ================================
// Hooks queryFn Coverage Tests
// ================================
describe('Hooks queryFn Coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfiniteQueryData = undefined
mockPostMarketplaceShouldFail = false
capturedInfiniteQueryFn = null
capturedQueryFn = null
})
it('should cover queryFn with pages data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 },
],
}
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({
query: 'test',
category: 'tool',
})
expect(result.current).toBeDefined()
})
it('should expose page and total from infinite query data', async () => {
mockInfiniteQueryData = {
pages: [
{ plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 },
{ plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 },
],
}
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({ query: 'search' })
expect(result.current.page).toBe(2)
})
it('should return undefined total when no query is set', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
expect(result.current.total).toBeUndefined()
})
it('should directly test queryFn execution', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({
query: 'direct test',
category: 'tool',
sort_by: 'install_count',
sort_order: 'DESC',
page_size: 40,
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
}
})
it('should test queryFn with bundle type', async () => {
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({
type: 'bundle',
query: 'bundle test',
})
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal })
expect(response).toBeDefined()
}
})
it('should test queryFn error handling', async () => {
mockPostMarketplaceShouldFail = true
const { useMarketplacePlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplacePlugins())
result.current.queryPlugins({ query: 'test that will fail' })
if (capturedInfiniteQueryFn) {
const controller = new AbortController()
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
expect(response).toBeDefined()
expect(response).toHaveProperty('plugins')
}
mockPostMarketplaceShouldFail = false
})
it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
result.current.queryMarketplaceCollectionsAndPlugins({
condition: 'category=tool',
})
if (capturedQueryFn) {
const controller = new AbortController()
const response = await capturedQueryFn({ signal: controller.signal })
expect(response).toBeDefined()
}
})
it('should test getNextPageParam directly', async () => {
const { useMarketplacePlugins } = await import('./hooks')
renderHook(() => useMarketplacePlugins())
if (capturedGetNextPageParam) {
const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 })
expect(nextPage).toBe(2)
const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 })
expect(noMorePages).toBeUndefined()
const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 })
expect(atBoundary).toBeUndefined()
}
})
})
// ================================
// useMarketplaceContainerScroll Tests
// ================================
describe('useMarketplaceContainerScroll', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should attach scroll event listener to container', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'marketplace-container'
document.body.appendChild(mockContainer)
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback)
return null
}
render(<TestComponent />)
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
document.body.removeChild(mockContainer)
})
it('should call callback when scrolled to bottom', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'scroll-test-container-hooks'
document.body.appendChild(mockContainer)
Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true })
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks')
return null
}
render(<TestComponent />)
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
mockContainer.dispatchEvent(scrollEvent)
expect(mockCallback).toHaveBeenCalled()
document.body.removeChild(mockContainer)
})
it('should not call callback when scrollTop is 0', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'scroll-test-container-hooks-2'
document.body.appendChild(mockContainer)
Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true })
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks-2')
return null
}
render(<TestComponent />)
const scrollEvent = new Event('scroll')
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
mockContainer.dispatchEvent(scrollEvent)
expect(mockCallback).not.toHaveBeenCalled()
document.body.removeChild(mockContainer)
})
it('should remove event listener on unmount', async () => {
const mockCallback = vi.fn()
const mockContainer = document.createElement('div')
mockContainer.id = 'scroll-unmount-container-hooks'
document.body.appendChild(mockContainer)
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
const { useMarketplaceContainerScroll } = await import('./hooks')
const TestComponent = () => {
useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container-hooks')
return null
}
const { unmount } = render(<TestComponent />)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
document.body.removeChild(mockContainer)
})
})

View File

@@ -1,140 +1,7 @@
import type { ReactNode } from 'react'
import type { Credential, PluginPayload } from '../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { AuthCategory, CredentialTypeEnum } from '../types'
const mockGetPluginCredentialInfo = vi.fn()
const mockDeletePluginCredential = vi.fn()
const mockSetPluginDefaultCredential = vi.fn()
const mockUpdatePluginCredential = vi.fn()
const mockInvalidPluginCredentialInfo = vi.fn()
const mockGetPluginOAuthUrl = vi.fn()
const mockGetPluginOAuthClientSchema = vi.fn()
const mockSetPluginOAuthCustomClient = vi.fn()
const mockDeletePluginOAuthCustomClient = vi.fn()
const mockInvalidPluginOAuthClientSchema = vi.fn()
const mockAddPluginCredential = vi.fn()
const mockGetPluginCredentialSchema = vi.fn()
const mockInvalidToolsByType = vi.fn()
vi.mock('@/service/use-plugins-auth', () => ({
useGetPluginCredentialInfo: (url: string) => ({
data: url ? mockGetPluginCredentialInfo() : undefined,
isLoading: false,
}),
useDeletePluginCredential: () => ({
mutateAsync: mockDeletePluginCredential,
}),
useSetPluginDefaultCredential: () => ({
mutateAsync: mockSetPluginDefaultCredential,
}),
useUpdatePluginCredential: () => ({
mutateAsync: mockUpdatePluginCredential,
}),
useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo,
useGetPluginOAuthUrl: () => ({
mutateAsync: mockGetPluginOAuthUrl,
}),
useGetPluginOAuthClientSchema: () => ({
data: mockGetPluginOAuthClientSchema(),
isLoading: false,
}),
useSetPluginOAuthCustomClient: () => ({
mutateAsync: mockSetPluginOAuthCustomClient,
}),
useDeletePluginOAuthCustomClient: () => ({
mutateAsync: mockDeletePluginOAuthCustomClient,
}),
useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema,
useAddPluginCredential: () => ({
mutateAsync: mockAddPluginCredential,
}),
useGetPluginCredentialSchema: () => ({
data: mockGetPluginCredentialSchema(),
isLoading: false,
}),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidToolsByType: () => mockInvalidToolsByType,
}))
const mockIsCurrentWorkspaceManager = vi.fn()
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
}))
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => ({
data: { options: [] },
isLoading: false,
}),
useTriggerPluginDynamicOptionsInfo: () => ({
data: null,
isLoading: false,
}),
useInvalidTriggerDynamicOptions: () => vi.fn(),
}))
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
const _createWrapper = () => {
const testQueryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
)
}
const _createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
category: AuthCategory.tool,
provider: 'test-provider',
...overrides,
})
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
id: 'test-credential-id',
name: 'Test Credential',
provider: 'test-provider',
credential_type: CredentialTypeEnum.API_KEY,
is_default: false,
credentials: { api_key: 'test-key' },
...overrides,
})
const _createCredentialList = (count: number, overrides: Partial<Credential>[] = []): Credential[] => {
return Array.from({ length: count }, (_, i) => createCredential({
id: `credential-${i}`,
name: `Credential ${i}`,
is_default: i === 0,
...overrides[i],
}))
}
describe('Index Exports', () => {
describe('plugin-auth index exports', () => {
it('should export all required components and hooks', async () => {
const exports = await import('../index')
@@ -144,104 +11,23 @@ describe('Index Exports', () => {
expect(exports.Authorized).toBeDefined()
expect(exports.AuthorizedInDataSourceNode).toBeDefined()
expect(exports.AuthorizedInNode).toBeDefined()
expect(exports.usePluginAuth).toBeDefined()
expect(exports.PluginAuth).toBeDefined()
expect(exports.PluginAuthInAgent).toBeDefined()
expect(exports.PluginAuthInDataSourceNode).toBeDefined()
}, 15000)
it('should export AuthCategory enum', async () => {
const exports = await import('../index')
expect(exports.AuthCategory).toBeDefined()
expect(exports.AuthCategory.tool).toBe('tool')
expect(exports.AuthCategory.datasource).toBe('datasource')
expect(exports.AuthCategory.model).toBe('model')
expect(exports.AuthCategory.trigger).toBe('trigger')
}, 15000)
it('should export CredentialTypeEnum', async () => {
const exports = await import('../index')
expect(exports.CredentialTypeEnum).toBeDefined()
expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2')
expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key')
}, 15000)
})
describe('Types', () => {
describe('AuthCategory enum', () => {
it('should have correct values', () => {
expect(AuthCategory.tool).toBe('tool')
expect(AuthCategory.datasource).toBe('datasource')
expect(AuthCategory.model).toBe('model')
expect(AuthCategory.trigger).toBe('trigger')
})
it('should have exactly 4 categories', () => {
const values = Object.values(AuthCategory)
expect(values).toHaveLength(4)
})
expect(exports.usePluginAuth).toBeDefined()
})
describe('CredentialTypeEnum', () => {
it('should have correct values', () => {
expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
expect(CredentialTypeEnum.API_KEY).toBe('api-key')
})
it('should have exactly 2 types', () => {
const values = Object.values(CredentialTypeEnum)
expect(values).toHaveLength(2)
})
it('should re-export AuthCategory enum with correct values', () => {
expect(Object.values(AuthCategory)).toHaveLength(4)
expect(AuthCategory.tool).toBe('tool')
expect(AuthCategory.datasource).toBe('datasource')
expect(AuthCategory.model).toBe('model')
expect(AuthCategory.trigger).toBe('trigger')
})
describe('Credential type', () => {
it('should allow creating valid credentials', () => {
const credential: Credential = {
id: 'test-id',
name: 'Test',
provider: 'test-provider',
is_default: true,
}
expect(credential.id).toBe('test-id')
expect(credential.is_default).toBe(true)
})
it('should allow optional fields', () => {
const credential: Credential = {
id: 'test-id',
name: 'Test',
provider: 'test-provider',
is_default: false,
credential_type: CredentialTypeEnum.API_KEY,
credentials: { key: 'value' },
isWorkspaceDefault: true,
from_enterprise: false,
not_allowed_to_use: false,
}
expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY)
expect(credential.isWorkspaceDefault).toBe(true)
})
})
describe('PluginPayload type', () => {
it('should allow creating valid plugin payload', () => {
const payload: PluginPayload = {
category: AuthCategory.tool,
provider: 'test-provider',
}
expect(payload.category).toBe(AuthCategory.tool)
})
it('should allow optional fields', () => {
const payload: PluginPayload = {
category: AuthCategory.datasource,
provider: 'test-provider',
providerType: 'builtin',
detail: undefined,
}
expect(payload.providerType).toBe('builtin')
})
it('should re-export CredentialTypeEnum with correct values', () => {
expect(Object.values(CredentialTypeEnum)).toHaveLength(2)
expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
expect(CredentialTypeEnum.API_KEY).toBe('api-key')
})
})

View File

@@ -92,7 +92,7 @@ describe('PluginAuth', () => {
expect(screen.queryByTestId('authorized')).not.toBeInTheDocument()
})
it('applies className when not authorized', () => {
it('renders with className wrapper when not authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
@@ -104,10 +104,10 @@ describe('PluginAuth', () => {
})
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
expect((container.firstChild as HTMLElement).className).toContain('custom-class')
expect(container.innerHTML).toContain('custom-class')
})
it('does not apply className when authorized', () => {
it('does not render className wrapper when authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
@@ -119,7 +119,7 @@ describe('PluginAuth', () => {
})
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
expect((container.firstChild as HTMLElement).className).not.toContain('custom-class')
expect(container.innerHTML).not.toContain('custom-class')
})
it('passes pluginPayload.provider to usePluginAuth', () => {

View File

@@ -96,7 +96,7 @@ describe('Authorize', () => {
it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
const pluginPayload = createPluginPayload()
const { container } = render(
render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={false}
@@ -105,10 +105,7 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
// No buttons should be rendered
expect(screen.queryByRole('button')).not.toBeInTheDocument()
// Container should only have wrapper element
expect(container.querySelector('.flex')).toBeInTheDocument()
})
it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
@@ -225,7 +222,7 @@ describe('Authorize', () => {
// ==================== Props Testing ====================
describe('Props Testing', () => {
describe('theme prop', () => {
it('should render buttons with secondary theme variant when theme is secondary', () => {
it('should render buttons when theme is secondary', () => {
const pluginPayload = createPluginPayload()
render(
@@ -239,9 +236,7 @@ describe('Authorize', () => {
)
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button.className).toContain('btn-secondary')
})
expect(buttons).toHaveLength(2)
})
})
@@ -327,10 +322,10 @@ describe('Authorize', () => {
expect(screen.getByRole('button')).toBeDisabled()
})
it('should add opacity class when notAllowCustomCredential is true', () => {
it('should disable all buttons when notAllowCustomCredential is true', () => {
const pluginPayload = createPluginPayload()
const { container } = render(
render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
@@ -340,8 +335,8 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
const wrappers = container.querySelectorAll('.opacity-50')
expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
const buttons = screen.getAllByRole('button')
buttons.forEach(button => expect(button).toBeDisabled())
})
})
})
@@ -459,7 +454,7 @@ describe('Authorize', () => {
expect(screen.getAllByRole('button').length).toBe(2)
})
it('should update button variant when theme changes', () => {
it('should change button styling when theme changes', () => {
const pluginPayload = createPluginPayload()
const { rerender } = render(
@@ -471,9 +466,7 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
const buttonPrimary = screen.getByRole('button')
// Primary theme with canOAuth=false should have primary variant
expect(buttonPrimary.className).toContain('btn-primary')
const primaryClassName = screen.getByRole('button').className
rerender(
<Authorize
@@ -483,7 +476,8 @@ describe('Authorize', () => {
/>,
)
expect(screen.getByRole('button').className).toContain('btn-secondary')
const secondaryClassName = screen.getByRole('button').className
expect(primaryClassName).not.toBe(secondaryClassName)
})
})
@@ -574,38 +568,10 @@ describe('Authorize', () => {
expect(typeof AuthorizeDefault).toBe('object')
})
it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
const pluginPayload = createPluginPayload()
const onUpdate = vi.fn()
const { rerender, container } = render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
notAllowCustomCredential={false}
onUpdate={onUpdate}
/>,
{ wrapper: createWrapper() },
)
const initialOpacityElements = container.querySelectorAll('.opacity-50').length
rerender(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
notAllowCustomCredential={false}
onUpdate={onUpdate}
/>,
)
expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
})
it('should update wrapper when notAllowCustomCredential changes', () => {
it('should reflect notAllowCustomCredential change via button disabled state', () => {
const pluginPayload = createPluginPayload()
const { rerender, container } = render(
const { rerender } = render(
<Authorize
pluginPayload={pluginPayload}
canOAuth={true}
@@ -614,7 +580,7 @@ describe('Authorize', () => {
{ wrapper: createWrapper() },
)
expect(container.querySelectorAll('.opacity-50').length).toBe(0)
expect(screen.getByRole('button')).not.toBeDisabled()
rerender(
<Authorize
@@ -624,7 +590,7 @@ describe('Authorize', () => {
/>,
)
expect(container.querySelectorAll('.opacity-50').length).toBe(1)
expect(screen.getByRole('button')).toBeDisabled()
})
})

View File

@@ -1,5 +1,5 @@
import type { Credential } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '../../types'
import Item from '../item'
@@ -67,7 +67,7 @@ describe('Item Component', () => {
it('should render selected icon when showSelectedIcon is true and credential is selected', () => {
const credential = createCredential({ id: 'selected-id' })
render(
const { container } = render(
<Item
credential={credential}
showSelectedIcon={true}
@@ -75,53 +75,64 @@ describe('Item Component', () => {
/>,
)
// RiCheckLine should be rendered
expect(document.querySelector('.text-text-accent')).toBeInTheDocument()
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should not render selected icon when credential is not selected', () => {
const credential = createCredential({ id: 'not-selected-id' })
render(
const { container: selectedContainer } = render(
<Item
credential={createCredential({ id: 'sel-id' })}
showSelectedIcon={true}
selectedCredentialId="sel-id"
/>,
)
const selectedSvgCount = selectedContainer.querySelectorAll('svg').length
cleanup()
const { container: unselectedContainer } = render(
<Item
credential={credential}
showSelectedIcon={true}
selectedCredentialId="other-id"
/>,
)
const unselectedSvgCount = unselectedContainer.querySelectorAll('svg').length
// Check icon should not be visible
expect(document.querySelector('.text-text-accent')).not.toBeInTheDocument()
expect(unselectedSvgCount).toBeLessThan(selectedSvgCount)
})
it('should render with gray indicator when not_allowed_to_use is true', () => {
it('should render with disabled appearance when not_allowed_to_use is true', () => {
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
// The item should have tooltip wrapper with data-state attribute for unavailable credential
const tooltipTrigger = container.querySelector('[data-state]')
expect(tooltipTrigger).toBeInTheDocument()
// The item should have disabled styles
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
expect(container.querySelector('[data-state]')).toBeInTheDocument()
})
it('should apply disabled styles when disabled is true', () => {
it('should not call onItemClick when disabled is true', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(<Item credential={credential} disabled={true} />)
const { container } = render(<Item credential={credential} onItemClick={onItemClick} disabled={true} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
fireEvent.click(container.firstElementChild!)
expect(onItemClick).not.toHaveBeenCalled()
})
it('should apply disabled styles when not_allowed_to_use is true', () => {
it('should not call onItemClick when not_allowed_to_use is true', () => {
const onItemClick = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
const { container } = render(<Item credential={credential} onItemClick={onItemClick} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
fireEvent.click(container.firstElementChild!)
expect(onItemClick).not.toHaveBeenCalled()
})
})
@@ -135,8 +146,7 @@ describe('Item Component', () => {
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
fireEvent.click(container.firstElementChild!)
expect(onItemClick).toHaveBeenCalledWith('click-test-id')
})
@@ -149,49 +159,22 @@ describe('Item Component', () => {
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
fireEvent.click(container.firstElementChild!)
expect(onItemClick).toHaveBeenCalledWith('')
})
it('should not call onItemClick when disabled', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} disabled={true} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onItemClick when not_allowed_to_use is true', () => {
const onItemClick = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
})
// ==================== Rename Mode Tests ====================
describe('Rename Mode', () => {
it('should enter rename mode when rename button is clicked', () => {
const credential = createCredential()
const renderWithRenameEnabled = (overrides: Record<string, unknown> = {}) => {
const onRename = vi.fn()
const credential = createCredential({ name: 'Original Name', ...overrides })
const { container } = render(
const result = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
@@ -199,224 +182,67 @@ describe('Item Component', () => {
/>,
)
// Since buttons are hidden initially, we need to find the ActionButton
// In the actual implementation, they are rendered but hidden
const actionButtons = container.querySelectorAll('button')
const renameBtn = Array.from(actionButtons).find(btn =>
btn.querySelector('.ri-edit-line') || btn.innerHTML.includes('RiEditLine'),
)
if (renameBtn) {
fireEvent.click(renameBtn)
// Should show input for rename
expect(screen.getByRole('textbox')).toBeInTheDocument()
const enterRenameMode = () => {
const firstButton = result.container.querySelectorAll('button')[0] as HTMLElement
fireEvent.click(firstButton)
}
return { ...result, onRename, enterRenameMode }
}
it('should enter rename mode when rename button is clicked', () => {
const { enterRenameMode } = renderWithRenameEnabled()
enterRenameMode()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should show save and cancel buttons in rename mode', () => {
const onRename = vi.fn()
const credential = createCredential({ name: 'Original Name' })
const { enterRenameMode } = renderWithRenameEnabled()
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
// Find and click rename button to enter rename mode
const actionButtons = container.querySelectorAll('button')
// Find the rename action button by looking for RiEditLine icon
actionButtons.forEach((btn) => {
if (btn.querySelector('svg')) {
fireEvent.click(btn)
}
})
// If we're in rename mode, there should be save/cancel buttons
const buttons = screen.queryAllByRole('button')
if (buttons.length >= 2) {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
}
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
})
it('should call onRename with new name when save is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-test-id', name: 'Original' })
const { enterRenameMode, onRename } = renderWithRenameEnabled({ id: 'rename-test-id' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
// Trigger rename mode by clicking the rename button
const editIcon = container.querySelector('svg.ri-edit-line')
if (editIcon) {
fireEvent.click(editIcon.closest('button')!)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Now in rename mode, change input and save
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
// Click save
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-test-id',
name: 'New Name',
})
}
})
it('should call onRename and exit rename mode when save button is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-save-test', name: 'Original Name' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find and click rename button to enter rename mode
// The button contains RiEditLine svg
const allButtons = Array.from(container.querySelectorAll('button'))
let renameButton: Element | null = null
for (const btn of allButtons) {
if (btn.querySelector('svg')) {
renameButton = btn
break
}
}
if (renameButton) {
fireEvent.click(renameButton)
// Should be in rename mode now
const input = screen.queryByRole('textbox')
if (input) {
expect(input).toHaveValue('Original Name')
// Change the value
fireEvent.change(input, { target: { value: 'Updated Name' } })
expect(input).toHaveValue('Updated Name')
// Click save button
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
// Verify onRename was called with correct parameters
expect(onRename).toHaveBeenCalledTimes(1)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-save-test',
name: 'Updated Name',
})
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-test-id',
name: 'New Name',
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should exit rename mode when cancel is clicked', () => {
const credential = createCredential({ name: 'Original' })
const { enterRenameMode } = renderWithRenameEnabled()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
expect(screen.getByRole('textbox')).toBeInTheDocument()
// Enter rename mode
const editIcon = container.querySelector('svg')?.closest('button')
if (editIcon) {
fireEvent.click(editIcon)
fireEvent.click(screen.getByText('common.operation.cancel'))
// If in rename mode, cancel button should exist
const cancelButton = screen.queryByText('common.operation.cancel')
if (cancelButton) {
fireEvent.click(cancelButton)
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should update rename value when input changes', () => {
const credential = createCredential({ name: 'Original' })
it('should update input value when typing', () => {
const { enterRenameMode } = renderWithRenameEnabled()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
enterRenameMode()
// We need to get into rename mode first
// The rename button appears on hover in the actions area
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Updated Value' } })
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.change(input, { target: { value: 'Updated Value' } })
expect(input).toHaveValue('Updated Value')
}
}
})
it('should stop propagation when clicking input in rename mode', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
onItemClick={onItemClick}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Enter rename mode and click on input
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.click(input)
// onItemClick should not be called when clicking the input
expect(onItemClick).not.toHaveBeenCalled()
}
}
expect(input).toHaveValue('Updated Value')
})
})
@@ -437,12 +263,9 @@ describe('Item Component', () => {
/>,
)
// Find set default button
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
fireEvent.click(setDefaultButton)
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
}
const setDefaultButton = screen.getByText('plugin.auth.setDefault')
fireEvent.click(setDefaultButton)
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
})
it('should not show set default button when credential is already default', () => {
@@ -517,16 +340,13 @@ describe('Item Component', () => {
/>,
)
// Find the edit button (RiEqualizer2Line icon)
const editButton = container.querySelector('svg')?.closest('button')
if (editButton) {
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
api_key: 'secret',
__name__: 'Edit Test',
__credential_id__: 'edit-test-id',
})
}
const editButton = container.querySelector('svg')?.closest('button') as HTMLElement
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
api_key: 'secret',
__name__: 'Edit Test',
__credential_id__: 'edit-test-id',
})
})
it('should not show edit button for OAuth credentials', () => {
@@ -584,12 +404,9 @@ describe('Item Component', () => {
/>,
)
// Find delete button (RiDeleteBinLine icon)
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
}
const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
})
it('should not show delete button when disableDelete is true', () => {
@@ -704,44 +521,15 @@ describe('Item Component', () => {
/>,
)
// Find delete button and click
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
// onDelete should be called but not onItemClick (due to stopPropagation)
expect(onDelete).toHaveBeenCalled()
// Note: onItemClick might still be called due to event bubbling in test environment
}
})
it('should disable action buttons when disabled prop is true', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disabled={true}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
// Set default button should be disabled
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
const button = setDefaultButton.closest('button')
expect(button).toBeDisabled()
}
const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalled()
})
})
// ==================== showAction Logic Tests ====================
describe('Show Action Logic', () => {
it('should not show action area when all actions are disabled', () => {
it('should not render action buttons when all actions are disabled', () => {
const credential = createCredential()
const { container } = render(
@@ -754,12 +542,10 @@ describe('Item Component', () => {
/>,
)
// Should not have action area with hover:flex
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).not.toBeInTheDocument()
expect(container.querySelectorAll('button').length).toBe(0)
})
it('should show action area when at least one action is enabled', () => {
it('should render action buttons when at least one action is enabled', () => {
const credential = createCredential()
const { container } = render(
@@ -772,38 +558,33 @@ describe('Item Component', () => {
/>,
)
// Should have action area
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).toBeInTheDocument()
expect(container.querySelectorAll('button').length).toBeGreaterThan(0)
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle credential with empty name', () => {
const credential = createCredential({ name: '' })
render(<Item credential={credential} />)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
expect(() => {
render(<Item credential={credential} />)
}).not.toThrow()
})
it('should handle credential with undefined credentials object', () => {
const credential = createCredential({ credentials: undefined })
render(
<Item
credential={credential}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
expect(() => {
render(
<Item
credential={credential}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
}).not.toThrow()
})
it('should handle all optional callbacks being undefined', () => {
@@ -814,13 +595,13 @@ describe('Item Component', () => {
}).not.toThrow()
})
it('should properly display long credential names with truncation', () => {
it('should display long credential names with title attribute', () => {
const longName = 'A'.repeat(100)
const credential = createCredential({ name: longName })
const { container } = render(<Item credential={credential} />)
const nameElement = container.querySelector('.truncate')
const nameElement = container.querySelector('[title]')
expect(nameElement).toBeInTheDocument()
expect(nameElement?.getAttribute('title')).toBe(longName)
})

View File

@@ -4,10 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointCard from '../endpoint-card'
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
const mockHandleChange = vi.fn()
const mockEnableEndpoint = vi.fn()
const mockDisableEndpoint = vi.fn()
@@ -133,6 +129,10 @@ describe('EndpointCard', () => {
failureFlags.update = false
// Mock Toast.notify to prevent toast elements from accumulating in DOM
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
// Polyfill document.execCommand for copy-to-clipboard in jsdom
if (typeof document.execCommand !== 'function') {
document.execCommand = vi.fn().mockReturnValue(true)
}
})
afterEach(() => {
@@ -192,12 +192,8 @@ describe('EndpointCard', () => {
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)
fireEvent.click(allButtons[1])
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument()
})
@@ -206,10 +202,7 @@ describe('EndpointCard', () => {
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(allButtons[1])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
@@ -218,10 +211,8 @@ describe('EndpointCard', () => {
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)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
@@ -229,10 +220,8 @@ describe('EndpointCard', () => {
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)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
@@ -243,20 +232,14 @@ describe('EndpointCard', () => {
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)
fireEvent.click(allButtons[2])
act(() => {
vi.advanceTimersByTime(2000)
})
act(() => {
vi.advanceTimersByTime(2000)
})
// After timeout, the component should still be rendered correctly
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
}
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
})
@@ -296,10 +279,7 @@ describe('EndpointCard', () => {
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(allButtons[1])
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
@@ -310,10 +290,8 @@ describe('EndpointCard', () => {
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)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
@@ -348,9 +326,7 @@ describe('EndpointCard', () => {
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(allButtons[1])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalled()
@@ -359,21 +335,15 @@ describe('EndpointCard', () => {
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)
const allButtons = screen.getAllByRole('button')
fireEvent.click(allButtons[0])
// Verify modal is open
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
// Set failure flag before save is clicked
failureFlags.update = true
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
// On error, handleChange is not called
expect(mockHandleChange).not.toHaveBeenCalled()
})
})

View File

@@ -112,8 +112,7 @@ describe('EndpointList', () => {
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
})
@@ -121,9 +120,8 @@ describe('EndpointList', () => {
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)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
@@ -131,9 +129,8 @@ describe('EndpointList', () => {
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)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
@@ -143,9 +140,8 @@ describe('EndpointList', () => {
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)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalled()
@@ -158,7 +154,6 @@ describe('EndpointList', () => {
detail.declaration.tool = {} as PluginDetail['declaration']['tool']
render(<EndpointList detail={detail} />)
// Verify the component renders correctly
expect(screen.getByText('plugin.detailPanel.endpoints')).toBeInTheDocument()
})
})
@@ -177,23 +172,12 @@ describe('EndpointList', () => {
})
})
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)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
@@ -202,9 +186,8 @@ describe('EndpointList', () => {
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)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalledWith({

View File

@@ -158,11 +158,8 @@ describe('EndpointModal', () => {
/>,
)
// 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)
fireEvent.click(allButtons[0])
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
@@ -318,7 +315,16 @@ describe('EndpointModal', () => {
})
describe('Boolean Field Processing', () => {
it('should convert string "true" to boolean true', () => {
it.each([
{ input: 'true', expected: true },
{ input: '1', expected: true },
{ input: 'True', expected: true },
{ input: 'false', expected: false },
{ input: 1, expected: true },
{ input: 0, expected: false },
{ input: true, expected: true },
{ input: false, expected: false },
])('should convert $input to $expected for boolean fields', ({ input, expected }) => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
@@ -326,7 +332,7 @@ describe('EndpointModal', () => {
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'true' }}
defaultValues={{ enabled: input }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
@@ -335,147 +341,7 @@ describe('EndpointModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.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: 'common.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: 'common.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: 'common.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: 'common.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: 'common.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: 'common.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: 'common.operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: expected })
})
it('should not process non-boolean fields', () => {

View File

@@ -136,18 +136,27 @@ describe('SubscriptionList', () => {
expect(screen.getByText('Subscription One')).toBeInTheDocument()
})
it('should highlight the selected subscription when selectedId is provided', () => {
render(
it('should visually distinguish selected subscription from unselected', () => {
const { rerender } = render(
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
selectedId="sub-1"
/>,
)
const selectedButton = screen.getByRole('button', { name: 'Subscription One' })
const selectedRow = selectedButton.closest('div')
const getRowClassName = () =>
screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? ''
expect(selectedRow).toHaveClass('bg-state-base-hover')
const selectedClassName = getRowClassName()
rerender(
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
selectedId="other-id"
/>,
)
expect(selectedClassName).not.toBe(getRowClassName())
})
})
@@ -190,11 +199,9 @@ describe('SubscriptionList', () => {
/>,
)
const deleteButton = container.querySelector('.subscription-delete-btn')
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
expect(deleteButton).toBeTruthy()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(deleteButton)
expect(onSelect).not.toHaveBeenCalled()
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()

View File

@@ -1,17 +1,12 @@
import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import LogViewer from '../log-viewer'
const mockToastNotify = vi.fn()
const mockWriteText = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: { type: string, message: string }) => mockToastNotify(args),
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value }: { value: unknown }) => (
<div data-testid="code-editor">{JSON.stringify(value)}</div>
@@ -62,6 +57,10 @@ beforeEach(() => {
},
configurable: true,
})
vi.spyOn(Toast, 'notify').mockImplementation((args) => {
mockToastNotify(args)
return { clear: vi.fn() }
})
})
describe('LogViewer', () => {
@@ -99,13 +98,20 @@ describe('LogViewer', () => {
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
})
it('should render error styling when response is an error', () => {
render(<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />)
it('should apply distinct styling when response is an error', () => {
const { container: errorContainer } = render(
<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />,
)
const errorWrapperClass = errorContainer.querySelector('[class*="border"]')?.className ?? ''
const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
const wrapper = trigger.parentElement as HTMLElement
cleanup()
expect(wrapper).toHaveClass('border-state-destructive-border')
const { container: okContainer } = render(
<LogViewer logs={[createLog()]} />,
)
const okWrapperClass = okContainer.querySelector('[class*="border"]')?.className ?? ''
expect(errorWrapperClass).not.toBe(okWrapperClass)
})
it('should render raw response text and allow copying', () => {
@@ -121,10 +127,9 @@ describe('LogViewer', () => {
expect(screen.getByText('plain response')).toBeInTheDocument()
const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton)
expect(copyButton).toBeDefined()
if (copyButton)
fireEvent.click(copyButton)
const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton) as HTMLElement
expect(copyButton).toBeTruthy()
fireEvent.click(copyButton)
expect(mockWriteText).toHaveBeenCalledWith('plain response')
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
})

View File

@@ -1,6 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/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 { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorView } from '../selector-view'
@@ -25,12 +26,6 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
id: 'sub-1',
name: 'Subscription One',
@@ -47,6 +42,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
beforeEach(() => {
vi.clearAllMocks()
mockSubscriptions = [createSubscription()]
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('SubscriptionSelectorView', () => {
@@ -75,18 +71,19 @@ describe('SubscriptionSelectorView', () => {
}).not.toThrow()
})
it('should highlight selected subscription row when selectedId matches', () => {
render(<SubscriptionSelectorView selectedId="sub-1" />)
it('should distinguish selected vs unselected subscription row', () => {
const { rerender } = render(<SubscriptionSelectorView selectedId="sub-1" />)
const selectedRow = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
expect(selectedRow).toHaveClass('bg-state-base-hover')
})
const getRowClassName = () =>
screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? ''
it('should not highlight row when selectedId does not match', () => {
render(<SubscriptionSelectorView selectedId="other-id" />)
const selectedClassName = getRowClassName()
const row = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
expect(row).not.toHaveClass('bg-state-base-hover')
rerender(<SubscriptionSelectorView selectedId="other-id" />)
const unselectedClassName = getRowClassName()
expect(selectedClassName).not.toBe(unselectedClassName)
})
it('should omit header when there are no subscriptions', () => {
@@ -100,11 +97,9 @@ describe('SubscriptionSelectorView', () => {
it('should show delete confirm when delete action is clicked', () => {
const { container } = render(<SubscriptionSelectorView />)
const deleteButton = container.querySelector('.subscription-delete-btn')
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
expect(deleteButton).toBeTruthy()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
})
@@ -113,9 +108,8 @@ describe('SubscriptionSelectorView', () => {
const onSelect = vi.fn()
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
const deleteButton = container.querySelector('.subscription-delete-btn')
if (deleteButton)
fireEvent.click(deleteButton)
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
@@ -127,9 +121,8 @@ describe('SubscriptionSelectorView', () => {
const onSelect = vi.fn()
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
const deleteButton = container.querySelector('.subscription-delete-btn')
if (deleteButton)
fireEvent.click(deleteButton)
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ }))

View File

@@ -1,6 +1,7 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/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 { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import SubscriptionCard from '../subscription-card'
@@ -29,12 +30,6 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
id: 'sub-1',
name: 'Subscription One',
@@ -50,6 +45,7 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('SubscriptionCard', () => {
@@ -69,11 +65,9 @@ describe('SubscriptionCard', () => {
it('should open delete confirmation when delete action is clicked', () => {
const { container } = render(<SubscriptionCard data={createSubscription()} />)
const deleteButton = container.querySelector('.subscription-delete-btn')
const deleteButton = container.querySelector('.subscription-delete-btn') as HTMLElement
expect(deleteButton).toBeTruthy()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
})
@@ -81,9 +75,7 @@ describe('SubscriptionCard', () => {
it('should open edit modal when edit action is clicked', () => {
const { container } = render(<SubscriptionCard data={createSubscription()} />)
const actionButtons = container.querySelectorAll('button')
const editButton = actionButtons[0]
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()

View File

@@ -1,4 +1,3 @@
import type { PropsWithChildren } from 'react'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
@@ -16,23 +15,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('next/image', () => ({
default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => (
// eslint-disable-next-line next/no-img-element
<img src={src} alt={alt} width={width} height={height} data-testid="mock-image" />
),
}))
vi.mock('next/dynamic', () => ({
default: (importFn: () => Promise<{ default: React.ComponentType<unknown> }>, options?: { ssr?: boolean }) => {
const DynamicComponent = ({ children, ...props }: PropsWithChildren) => {
return <div data-testid="dynamic-component" data-ssr={options?.ssr ?? true} {...props}>{children}</div>
}
DynamicComponent.displayName = 'DynamicComponent'
return DynamicComponent
},
}))
let mockShowImportDSLModal = false
const mockSetShowImportDSLModal = vi.fn((value: boolean) => {
mockShowImportDSLModal = value
@@ -247,18 +229,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
useToastContext: () => ({
notify: vi.fn(),
}),
ToastContext: {
Provider: ({ children }: PropsWithChildren) => children,
},
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
@@ -276,7 +246,7 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({ children }: PropsWithChildren) => (
WorkflowWithInnerContext: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-inner-context">{children}</div>
),
}))
@@ -300,16 +270,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
}),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => (
<div data-testid="dsl-export-confirm-modal">
<span data-testid="env-count">{envList.length}</span>
<button data-testid="export-confirm" onClick={onConfirm}>Confirm</button>
<button data-testid="export-close" onClick={onClose}>Close</button>
</div>
),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
@@ -322,125 +282,6 @@ vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyNameBySystem: (key: string) => key,
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: {
title: string
content: string
isShow: boolean
onConfirm: () => void
onCancel: () => void
isLoading?: boolean
isDisabled?: boolean
}) => isShow
? (
<div data-testid="confirm-modal">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button
data-testid="confirm-btn"
onClick={onConfirm}
disabled={isDisabled || isLoading}
>
Confirm
</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow, onClose, className }: PropsWithChildren<{
isShow: boolean
onClose: () => void
className?: string
}>) => isShow
? (
<div data-testid="modal" className={className} onClick={e => e.target === e.currentTarget && onClose()}>
{children}
</div>
)
: null,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange, placeholder }: {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
}) => (
<input
data-testid="input"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
),
}))
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, className }: {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
placeholder?: string
className?: string
}) => (
<textarea
data-testid="textarea"
value={value}
onChange={onChange}
placeholder={placeholder}
className={className}
/>
),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick, iconType, icon, background, imageUrl, className, size }: {
onClick?: () => void
iconType?: string
icon?: string
background?: string
imageUrl?: string
className?: string
size?: string
}) => (
<div
data-testid="app-icon"
data-icon-type={iconType}
data-icon={icon}
data-background={background}
data-image-url={imageUrl}
data-size={size}
className={className}
onClick={onClick}
/>
),
}))
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void
onClose: () => void
}) => (
<div data-testid="app-icon-picker">
<button
data-testid="select-emoji"
onClick={() => onSelect({ type: 'emoji', icon: '🚀', background: '#000000' })}
>
Select Emoji
</button>
<button
data-testid="select-image"
onClick={() => onSelect({ type: 'image', url: 'https://example.com/icon.png' })}
>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>Close</button>
</div>
),
}))
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ file, updateFile, className, accept, displayName }: {
file?: File
@@ -466,12 +307,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
),
}))
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({
notify: vi.fn(),
})),
}))
vi.mock('../rag-pipeline-header', () => ({
default: () => <div data-testid="rag-pipeline-header" />,
}))
@@ -512,6 +347,28 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
// Silence expected console.error from Dialog/Modal rendering
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
// Helper to find the name input in PublishAsKnowledgePipelineModal
function getNameInput() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
}
// Helper to find the description textarea in PublishAsKnowledgePipelineModal
function getDescriptionTextarea() {
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.descriptionPlaceholder')
}
// Helper to find the AppIcon span in PublishAsKnowledgePipelineModal
// HeadlessUI Dialog renders via portal to document.body, so we search the full document
function getAppIcon() {
const emoji = document.querySelector('em-emoji')
return emoji?.closest('span') as HTMLElement
}
describe('Conversion', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -546,7 +403,8 @@ describe('Conversion', () => {
it('should render PipelineScreenShot component', () => {
render(<Conversion />)
expect(screen.getByTestId('mock-image')).toBeInTheDocument()
// PipelineScreenShot renders a <picture> element with <source> children
expect(document.querySelector('picture')).toBeInTheDocument()
})
})
@@ -557,8 +415,9 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByTestId('confirm-title')).toHaveTextContent('datasetPipeline.conversion.confirm.title')
// Real Confirm renders title and content via portal
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.content')).toBeInTheDocument()
})
it('should hide confirm modal when cancel is clicked', () => {
@@ -566,10 +425,11 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
// Real Confirm renders cancel button with i18n text
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
})
})
@@ -588,7 +448,7 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalledWith('test-dataset-id', expect.objectContaining({
@@ -607,12 +467,12 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.conversion.confirm.title')).not.toBeInTheDocument()
})
})
@@ -625,12 +485,13 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalled()
})
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
// Confirm modal stays open on failure
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
})
it('should show error toast when conversion throws error', async () => {
@@ -642,7 +503,7 @@ describe('Conversion', () => {
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
fireEvent.click(convertButton)
fireEvent.click(screen.getByTestId('confirm-btn'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockConvertFn).toHaveBeenCalled()
@@ -681,23 +542,24 @@ describe('PipelineScreenShot', () => {
it('should render without crashing', () => {
render(<PipelineScreenShot />)
expect(screen.getByTestId('mock-image')).toBeInTheDocument()
expect(document.querySelector('picture')).toBeInTheDocument()
})
it('should render with correct image attributes', () => {
it('should render source elements for different resolutions', () => {
render(<PipelineScreenShot />)
const img = screen.getByTestId('mock-image')
expect(img).toHaveAttribute('alt', 'Pipeline Screenshot')
expect(img).toHaveAttribute('width', '692')
expect(img).toHaveAttribute('height', '456')
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(3)
expect(sources[0]).toHaveAttribute('media', '(resolution: 1x)')
expect(sources[1]).toHaveAttribute('media', '(resolution: 2x)')
expect(sources[2]).toHaveAttribute('media', '(resolution: 3x)')
})
it('should use correct theme-based source path', () => {
render(<PipelineScreenShot />)
const img = screen.getByTestId('mock-image')
expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png')
const source = document.querySelector('source')
expect(source).toHaveAttribute('srcSet', '/public/screenshots/light/Pipeline.png')
})
})
@@ -752,20 +614,22 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should render name input with default value from store', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const input = screen.getByTestId('input')
const input = getNameInput()
expect(input).toHaveValue('Test Knowledge')
})
it('should render description textarea', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('textarea')).toBeInTheDocument()
expect(getDescriptionTextarea()).toBeInTheDocument()
})
it('should render app icon', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
// Real AppIcon renders an em-emoji custom element inside a span
// HeadlessUI Dialog renders via portal, so search the full document
expect(document.querySelector('em-emoji')).toBeInTheDocument()
})
it('should render cancel and confirm buttons', () => {
@@ -780,7 +644,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update name when input changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: 'New Pipeline Name' } })
expect(input).toHaveValue('New Pipeline Name')
@@ -789,7 +653,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update description when textarea changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByTestId('textarea')
const textarea = getDescriptionTextarea()
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(textarea).toHaveValue('New description')
@@ -816,8 +680,8 @@ describe('PublishAsKnowledgePipelineModal', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } })
fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } })
fireEvent.change(getNameInput(), { target: { value: ' Trimmed Name ' } })
fireEvent.change(getDescriptionTextarea(), { target: { value: ' Trimmed Description ' } })
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
@@ -831,40 +695,57 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should show app icon picker when icon is clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
const appIcon = getAppIcon()
fireEvent.click(appIcon)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
// Real AppIconPicker renders with Cancel and OK buttons
expect(screen.getByRole('button', { name: /iconPicker\.cancel/ })).toBeInTheDocument()
})
it('should update icon when emoji is selected', () => {
it('should update icon when emoji is selected', async () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
const appIcon = getAppIcon()
fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('select-emoji'))
// Click the first emoji in the grid (search full document since Dialog uses portal)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
expect(gridEmojis.length).toBeGreaterThan(0)
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Click OK to confirm selection
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
// Picker should close
await waitFor(() => {
expect(screen.queryByRole('button', { name: /iconPicker\.cancel/ })).not.toBeInTheDocument()
})
})
it('should update icon when image is selected', () => {
it('should switch to image tab in icon picker', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
const appIcon = getAppIcon()
fireEvent.click(appIcon)
fireEvent.click(screen.getByTestId('select-image'))
// Switch to image tab
const imageTab = screen.getByRole('button', { name: /iconPicker\.image/ })
fireEvent.click(imageTab)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
// Picker should still be open
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
})
it('should close picker and restore icon when picker is closed', () => {
it('should close picker when cancel is clicked', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('app-icon'))
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
const appIcon = getAppIcon()
fireEvent.click(appIcon)
expect(screen.getByRole('button', { name: /iconPicker\.cancel/ })).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-picker'))
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /iconPicker\.ok/ })).not.toBeInTheDocument()
})
})
@@ -872,7 +753,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should disable publish button when name is empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('input'), { target: { value: '' } })
fireEvent.change(getNameInput(), { target: { value: '' } })
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
expect(publishButton).toBeDisabled()
@@ -881,7 +762,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should disable publish button when name is only whitespace', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } })
fireEvent.change(getNameInput(), { target: { value: ' ' } })
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
expect(publishButton).toBeDisabled()
@@ -908,7 +789,8 @@ describe('PublishAsKnowledgePipelineModal', () => {
const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
// HeadlessUI Dialog renders via portal, so search the full document
expect(document.querySelector('em-emoji')).toBeInTheDocument()
})
})
})
@@ -1132,12 +1014,18 @@ describe('Integration Tests', () => {
/>,
)
fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } })
fireEvent.change(getNameInput(), { target: { value: 'My Pipeline' } })
fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } })
fireEvent.change(getDescriptionTextarea(), { target: { value: 'A great pipeline' } })
fireEvent.click(screen.getByTestId('app-icon'))
fireEvent.click(screen.getByTestId('select-emoji'))
// Open picker and select an emoji
const appIcon = getAppIcon()
fireEvent.click(appIcon)
const gridEmojis = document.querySelectorAll('.grid em-emoji')
if (gridEmojis.length > 0) {
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
}
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
@@ -1145,9 +1033,7 @@ describe('Integration Tests', () => {
expect(mockOnConfirm).toHaveBeenCalledWith(
'My Pipeline',
expect.objectContaining({
icon_type: 'emoji',
icon: '🚀',
icon_background: '#000000',
icon_type: expect.any(String),
}),
'A great pipeline',
)
@@ -1170,7 +1056,7 @@ describe('Edge Cases', () => {
/>,
)
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: '' } })
expect(input).toHaveValue('')
})
@@ -1186,7 +1072,7 @@ describe('Edge Cases', () => {
)
const longName = 'A'.repeat(1000)
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: longName } })
expect(input).toHaveValue(longName)
})
@@ -1200,7 +1086,7 @@ describe('Edge Cases', () => {
)
const specialName = '<script>alert("xss")</script>'
const input = screen.getByTestId('input')
const input = getNameInput()
fireEvent.change(input, { target: { value: specialName } })
expect(input).toHaveValue(specialName)
})
@@ -1226,8 +1112,8 @@ describe('Accessibility', () => {
/>,
)
expect(screen.getByTestId('input')).toBeInTheDocument()
expect(screen.getByTestId('textarea')).toBeInTheDocument()
expect(getNameInput()).toBeInTheDocument()
expect(getDescriptionTextarea()).toBeInTheDocument()
})
it('should have accessible buttons', () => {

View File

@@ -20,6 +20,7 @@ describe('VersionMismatchModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('rendering', () => {

View File

@@ -2,6 +2,7 @@ import type { FormData, InputFieldFormProps } from '../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { PipelineInputVarType } from '@/models/pipeline'
import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks'
import InputFieldForm from '../index'
@@ -25,12 +26,6 @@ vi.mock('@/service/use-common', () => ({
}),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const createFormData = (overrides?: Partial<FormData>): FormData => ({
type: PipelineInputVarType.textInput,
label: 'Test Label',
@@ -85,6 +80,12 @@ const renderHookWithProviders = <TResult,>(hook: () => TResult) => {
return renderHook(hook, { wrapper: TestWrapper })
}
// Silence expected console.error from form submit preventDefault
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('InputFieldForm', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -197,7 +198,6 @@ describe('InputFieldForm', () => {
})
it('should show Toast error when form validation fails on submit', async () => {
const Toast = await import('@/app/components/base/toast')
const initialData = createFormData({
variable: '', // Empty variable should fail validation
label: 'Test Label',
@@ -210,7 +210,7 @@ describe('InputFieldForm', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.default.notify).toHaveBeenCalledWith(
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.any(String),

View File

@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import Publisher from '../index'
import Popup from '../popup'
@@ -18,53 +19,6 @@ vi.mock('next/link', () => ({
),
}))
let keyPressCallback: ((e: KeyboardEvent) => void) | null = null
vi.mock('ahooks', () => ({
useBoolean: (defaultValue = false) => {
const [value, setValue] = React.useState(defaultValue)
return [value, {
setTrue: () => setValue(true),
setFalse: () => setValue(false),
toggle: () => setValue(v => !v),
}]
},
useKeyPress: (key: string, callback: (e: KeyboardEvent) => void) => {
keyPressCallback = callback
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => {
mockPortalOpen = open
return <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
}) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
},
}))
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
vi.mock('@/app/components/workflow/hooks', () => ({
@@ -120,11 +74,6 @@ vi.mock('@/context/provider-context', () => ({
}))
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => 'https://api.dify.ai/v1/datasets/test-dataset-id',
@@ -207,7 +156,9 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{ui}
</ToastContext.Provider>
</QueryClientProvider>,
)
}
@@ -215,8 +166,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
describe('publisher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
keyPressCallback = null
vi.spyOn(console, 'error').mockImplementation(() => {})
mockPublishedAt.mockReturnValue(null)
mockDraftUpdatedAt.mockReturnValue(1700000000)
mockPipelineId.mockReturnValue('test-pipeline-id')
@@ -236,8 +186,9 @@ describe('publisher', () => {
it('should render portal element in closed state by default', () => {
renderWithQueryClient(<Publisher />)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
const trigger = screen.getByText('workflow.common.publish').closest('[data-state]')
expect(trigger).toHaveAttribute('data-state', 'closed')
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
it('should render down arrow icon in button', () => {
@@ -252,24 +203,24 @@ describe('publisher', () => {
it('should open popup when trigger is clicked', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
})
it('should close popup when trigger is clicked again while open', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger')) // open
fireEvent.click(screen.getByText('workflow.common.publish')) // open
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('portal-trigger')) // close
fireEvent.click(screen.getByText('workflow.common.publish')) // close
await waitFor(() => {
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
})
})
@@ -278,20 +229,20 @@ describe('publisher', () => {
it('should call handleSyncWorkflowDraft when popup opens', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should not call handleSyncWorkflowDraft when popup closes', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger')) // open
fireEvent.click(screen.getByText('workflow.common.publish')) // open
vi.clearAllMocks()
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('portal-trigger')) // close
fireEvent.click(screen.getByText('workflow.common.publish')) // close
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
@@ -306,10 +257,10 @@ describe('publisher', () => {
it('should render popup content when opened', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
})
})
@@ -811,10 +762,8 @@ describe('publisher', () => {
mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 })
renderWithQueryClient(<Popup />)
const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
expect(mockEvent.preventDefault).toHaveBeenCalled()
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
@@ -834,10 +783,8 @@ describe('publisher', () => {
vi.clearAllMocks()
const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
@@ -845,8 +792,7 @@ describe('publisher', () => {
mockPublishedAt.mockReturnValue(null)
renderWithQueryClient(<Popup />)
const mockEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
await waitFor(() => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
@@ -861,16 +807,14 @@ describe('publisher', () => {
}))
renderWithQueryClient(<Popup />)
const mockEvent1 = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent1)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
await waitFor(() => {
const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i })
expect(publishButton).toBeDisabled()
})
const mockEvent2 = { preventDefault: vi.fn() } as unknown as KeyboardEvent
keyPressCallback?.(mockEvent2)
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
expect(mockPublishWorkflow).toHaveBeenCalledTimes(1)
@@ -1066,10 +1010,10 @@ describe('publisher', () => {
it('should show Publisher button and open popup with Popup component', async () => {
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)

View File

@@ -0,0 +1,99 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
// Mock return value for useInspectVarsCrudCommon
const mockApis = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis)
vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args),
}))
const mockConfigsMap = {
flowId: 'pipeline-123',
flowType: 'rag_pipeline',
fileSettings: {
image: { enabled: false },
fileUploadConfig: {},
},
}
vi.mock('../use-configs-map', () => ({
useConfigsMap: () => mockConfigsMap,
}))
describe('useInspectVarsCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify the hook composes useConfigsMap with useInspectVarsCrudCommon
describe('Composition', () => {
it('should pass configsMap to useInspectVarsCrudCommon', () => {
renderHook(() => useInspectVarsCrud())
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith(
expect.objectContaining({
flowId: 'pipeline-123',
flowType: 'rag_pipeline',
}),
)
})
it('should return all APIs from useInspectVarsCrudCommon', () => {
const { result } = renderHook(() => useInspectVarsCrud())
expect(result.current.hasNodeInspectVars).toBe(mockApis.hasNodeInspectVars)
expect(result.current.fetchInspectVarValue).toBe(mockApis.fetchInspectVarValue)
expect(result.current.editInspectVarValue).toBe(mockApis.editInspectVarValue)
expect(result.current.deleteInspectVar).toBe(mockApis.deleteInspectVar)
expect(result.current.deleteAllInspectorVars).toBe(mockApis.deleteAllInspectorVars)
expect(result.current.resetToLastRunVar).toBe(mockApis.resetToLastRunVar)
expect(result.current.resetConversationVar).toBe(mockApis.resetConversationVar)
})
})
// Verify the hook spreads all returned properties
describe('API Surface', () => {
it('should expose all expected API methods', () => {
const { result } = renderHook(() => useInspectVarsCrud())
const expectedKeys = [
'hasNodeInspectVars',
'hasSetInspectVar',
'fetchInspectVarValue',
'editInspectVarValue',
'renameInspectVarName',
'appendNodeInspectVars',
'deleteInspectVar',
'deleteNodeInspectorVars',
'deleteAllInspectorVars',
'isInspectVarEdited',
'resetToLastRunVar',
'invalidateSysVarValues',
'resetConversationVar',
'invalidateConversationVarValues',
]
for (const key of expectedKeys)
expect(result.current).toHaveProperty(key)
})
})
})

View File

@@ -46,6 +46,7 @@ describe('usePipelineInit', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockWorkflowStoreGetState.mockReturnValue({
setEnvSecrets: mockSetEnvSecrets,

View File

@@ -1,26 +1,17 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from './countdown'
// Mock useCountDown from ahooks
let mockTime = COUNT_DOWN_TIME_MS
let mockOnEnd: (() => void) | undefined
vi.mock('ahooks', () => ({
useCountDown: ({ onEnd }: { leftTime: number, onEnd?: () => void }) => {
mockOnEnd = onEnd
return [mockTime]
},
}))
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Countdown, { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../countdown'
describe('Countdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTime = COUNT_DOWN_TIME_MS
mockOnEnd = undefined
vi.useFakeTimers()
localStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
@@ -29,16 +20,15 @@ describe('Countdown', () => {
})
it('should display countdown time when time > 0', () => {
mockTime = 30000 // 30 seconds
localStorage.setItem(COUNT_DOWN_KEY, '30000')
render(<Countdown />)
// The countdown displays number and 's' in the same span
expect(screen.getByText(/30/)).toBeInTheDocument()
expect(screen.getByText(/s$/)).toBeInTheDocument()
})
it('should display resend link when time <= 0', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
@@ -46,7 +36,7 @@ describe('Countdown', () => {
})
it('should not display resend link when time > 0', () => {
mockTime = 1000
localStorage.setItem(COUNT_DOWN_KEY, '1000')
render(<Countdown />)
expect(screen.queryByText('login.checkCode.resend')).not.toBeInTheDocument()
@@ -57,7 +47,7 @@ describe('Countdown', () => {
describe('State Management', () => {
it('should initialize leftTime from localStorage if available', () => {
const savedTime = 45000
vi.mocked(localStorage.getItem).mockReturnValueOnce(String(savedTime))
localStorage.setItem(COUNT_DOWN_KEY, String(savedTime))
render(<Countdown />)
@@ -65,25 +55,26 @@ describe('Countdown', () => {
})
it('should use default COUNT_DOWN_TIME_MS when localStorage is empty', () => {
vi.mocked(localStorage.getItem).mockReturnValueOnce(null)
render(<Countdown />)
expect(localStorage.getItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
})
it('should save time to localStorage on time change', () => {
mockTime = 50000
render(<Countdown />)
expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, String(mockTime))
act(() => {
vi.advanceTimersByTime(1000)
})
expect(localStorage.setItem).toHaveBeenCalledWith(COUNT_DOWN_KEY, expect.any(String))
})
})
// Event Handler Tests
describe('Event Handlers', () => {
it('should call onResend callback when resend is clicked', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
const onResend = vi.fn()
render(<Countdown onResend={onResend} />)
@@ -95,7 +86,7 @@ describe('Countdown', () => {
})
it('should reset countdown when resend is clicked', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
@@ -106,7 +97,7 @@ describe('Countdown', () => {
})
it('should work without onResend callback (optional prop)', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
@@ -118,11 +109,12 @@ describe('Countdown', () => {
// Countdown End Tests
describe('Countdown End', () => {
it('should remove localStorage item when countdown ends', () => {
localStorage.setItem(COUNT_DOWN_KEY, '1000')
render(<Countdown />)
// Simulate countdown end
act(() => {
mockOnEnd?.()
vi.advanceTimersByTime(2000)
})
expect(localStorage.removeItem).toHaveBeenCalledWith(COUNT_DOWN_KEY)
@@ -132,28 +124,28 @@ describe('Countdown', () => {
// Edge Cases
describe('Edge Cases', () => {
it('should handle time exactly at 0', () => {
mockTime = 0
localStorage.setItem(COUNT_DOWN_KEY, '0')
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should handle negative time values', () => {
mockTime = -1000
localStorage.setItem(COUNT_DOWN_KEY, '-1000')
render(<Countdown />)
expect(screen.getByText('login.checkCode.resend')).toBeInTheDocument()
})
it('should round time display correctly', () => {
mockTime = 29500 // Should display as 30 (Math.round)
localStorage.setItem(COUNT_DOWN_KEY, '29500')
render(<Countdown />)
expect(screen.getByText(/30/)).toBeInTheDocument()
})
it('should display 1 second correctly', () => {
mockTime = 1000
localStorage.setItem(COUNT_DOWN_KEY, '1000')
render(<Countdown />)
expect(screen.getByText(/^1/)).toBeInTheDocument()
@@ -163,8 +155,8 @@ describe('Countdown', () => {
// Props Tests
describe('Props', () => {
it('should render correctly with onResend prop', () => {
localStorage.setItem(COUNT_DOWN_KEY, '0')
const onResend = vi.fn()
mockTime = 0
render(<Countdown onResend={onResend} />)

View File

@@ -1,14 +1,9 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import ProviderList from '../provider-list'
let mockActiveTab = 'builtin'
const mockSetActiveTab = vi.fn((val: string) => {
mockActiveTab = val
})
vi.mock('nuqs', () => ({
useQueryState: () => [mockActiveTab, mockSetActiveTab],
}))
import { getToolType } from '../utils'
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
@@ -18,11 +13,13 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
let mockEnableMarketplace = false
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({ enable_marketplace: false }),
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) =>
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
}))
const mockCollections = [
const createDefaultCollections = () => [
{
id: 'builtin-1',
name: 'google-search',
@@ -36,6 +33,33 @@ const mockCollections = [
allow_delete: false,
labels: ['search'],
},
{
id: 'builtin-2',
name: 'weather-tool',
author: 'Dify',
description: { en_US: 'Weather Tool', zh_Hans: '天气工具' },
icon: 'icon-weather',
label: { en_US: 'Weather Tool', zh_Hans: '天气工具' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['utility'],
},
{
id: 'builtin-plugin',
name: 'plugin-tool',
author: 'Dify',
description: { en_US: 'Plugin Tool', zh_Hans: '插件工具' },
icon: 'icon-plugin',
label: { en_US: 'Plugin Tool', zh_Hans: '插件工具' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'org/plugin-tool',
},
{
id: 'api-1',
name: 'my-api',
@@ -64,38 +88,22 @@ const mockCollections = [
},
]
let mockCollectionData: ReturnType<typeof createDefaultCollections> = []
const mockRefetch = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: mockCollections,
data: mockCollectionData,
refetch: mockRefetch,
}),
}))
let mockCheckedInstalledData: { plugins: { id: string, name: string }[] } | null = null
const mockInvalidateInstalledPluginList = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: null }),
useInvalidateInstalledPluginList: () => vi.fn(),
}))
vi.mock('@/app/components/base/tab-slider-new', () => ({
default: ({ value, onChange, options }: {
value: string
onChange: (val: string) => void
options: { value: string, text: string }[]
}) => (
<div data-testid="tab-slider">
{options.map(opt => (
<button
key={opt.value}
data-testid={`tab-${opt.value}`}
data-active={value === opt.value}
onClick={() => onChange(opt.value)}
>
{opt.text}
</button>
))}
</div>
),
useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({
data: enabled ? mockCheckedInstalledData : null,
}),
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
}))
vi.mock('@/app/components/plugins/card', () => ({
@@ -136,16 +144,33 @@ vi.mock('@/app/components/tools/provider/empty', () => ({
}))
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
default: ({ detail }: { detail: unknown }) =>
detail ? <div data-testid="plugin-detail-panel" /> : null,
default: ({ detail, onUpdate, onHide }: { detail: unknown, onUpdate: () => void, onHide: () => void }) =>
detail
? (
<div data-testid="plugin-detail-panel">
<button data-testid="plugin-update" onClick={onUpdate}>Update</button>
<button data-testid="plugin-close" onClick={onHide}>Close</button>
</div>
)
: null,
}))
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
default: ({ text }: { text: string }) => <div data-testid="empty">{text}</div>,
}))
const mockHandleScroll = vi.fn()
vi.mock('../marketplace', () => ({
default: () => <div data-testid="marketplace">Marketplace</div>,
default: ({ showMarketplacePanel, isMarketplaceArrowVisible }: {
showMarketplacePanel: () => void
isMarketplaceArrowVisible: boolean
}) => (
<div data-testid="marketplace">
<button data-testid="marketplace-arrow" onClick={showMarketplacePanel}>
{isMarketplaceArrowVisible ? 'arrow-visible' : 'arrow-hidden'}
</button>
</div>
),
}))
vi.mock('../marketplace/hooks', () => ({
@@ -154,7 +179,7 @@ vi.mock('../marketplace/hooks', () => ({
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
plugins: [],
handleScroll: vi.fn(),
handleScroll: mockHandleScroll,
page: 1,
}),
}))
@@ -168,10 +193,33 @@ vi.mock('../mcp', () => ({
),
}))
describe('getToolType', () => {
it.each([
['builtin', ToolTypeEnum.BuiltIn],
['api', ToolTypeEnum.Custom],
['workflow', ToolTypeEnum.Workflow],
['mcp', ToolTypeEnum.MCP],
['unknown', ToolTypeEnum.BuiltIn],
])('returns correct ToolTypeEnum for "%s"', (input, expected) => {
expect(getToolType(input)).toBe(expected)
})
})
const renderProviderList = (searchParams?: Record<string, string>) => {
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<ProviderList />
</NuqsTestingAdapter>,
)
}
describe('ProviderList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockActiveTab = 'builtin'
mockEnableMarketplace = false
mockCollectionData = createDefaultCollections()
mockCheckedInstalledData = null
Element.prototype.scrollTo = vi.fn()
})
afterEach(() => {
@@ -180,84 +228,239 @@ describe('ProviderList', () => {
describe('Tab Navigation', () => {
it('renders all four tabs', () => {
render(<ProviderList />)
expect(screen.getByTestId('tab-builtin')).toHaveTextContent('tools.type.builtIn')
expect(screen.getByTestId('tab-api')).toHaveTextContent('tools.type.custom')
expect(screen.getByTestId('tab-workflow')).toHaveTextContent('tools.type.workflow')
expect(screen.getByTestId('tab-mcp')).toHaveTextContent('MCP')
renderProviderList()
expect(screen.getByText('tools.type.builtIn')).toBeInTheDocument()
expect(screen.getByText('tools.type.custom')).toBeInTheDocument()
expect(screen.getByText('tools.type.workflow')).toBeInTheDocument()
expect(screen.getByText('MCP')).toBeInTheDocument()
})
it('switches tab when clicked', () => {
render(<ProviderList />)
fireEvent.click(screen.getByTestId('tab-api'))
expect(mockSetActiveTab).toHaveBeenCalledWith('api')
renderProviderList()
fireEvent.click(screen.getByText('tools.type.custom'))
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
})
it('resets current provider when switching to a different tab', () => {
renderProviderList()
fireEvent.click(screen.getByTestId('card-google-search'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
fireEvent.click(screen.getByText('tools.type.custom'))
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
})
it('does not reset provider when clicking the already active tab', () => {
renderProviderList()
fireEvent.click(screen.getByTestId('card-google-search'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
fireEvent.click(screen.getByText('tools.type.builtIn'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
})
})
describe('Filtering', () => {
it('shows only builtin collections by default', () => {
render(<ProviderList />)
renderProviderList()
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument()
expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument()
})
it('filters by search keyword', () => {
render(<ProviderList />)
renderProviderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'nonexistent' } })
expect(screen.queryByTestId('card-google-search')).not.toBeInTheDocument()
})
it('filters by search keyword matching label', () => {
renderProviderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Google' } })
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument()
})
it('filters collections by tag', () => {
renderProviderList()
fireEvent.click(screen.getByTestId('add-filter'))
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument()
expect(screen.queryByTestId('card-plugin-tool')).not.toBeInTheDocument()
})
it('restores all collections when tag filter is cleared', () => {
renderProviderList()
fireEvent.click(screen.getByTestId('add-filter'))
expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('clear-filter'))
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument()
})
it('clears search with clear button', () => {
renderProviderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Google' } })
expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('input-clear'))
expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument()
})
it('shows label filter for non-MCP tabs', () => {
render(<ProviderList />)
renderProviderList()
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
})
it('hides label filter for MCP tab', () => {
renderProviderList({ category: 'mcp' })
expect(screen.queryByTestId('label-filter')).not.toBeInTheDocument()
})
it('renders search input', () => {
render(<ProviderList />)
renderProviderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
describe('Custom Tab', () => {
it('shows custom create card when on api tab', () => {
mockActiveTab = 'api'
render(<ProviderList />)
renderProviderList({ category: 'api' })
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
})
})
describe('Workflow Tab', () => {
it('shows empty state when no workflow collections', () => {
mockActiveTab = 'workflow'
render(<ProviderList />)
// Only one workflow collection exists, so it should show
it('shows workflow collections', () => {
renderProviderList({ category: 'workflow' })
expect(screen.getByTestId('card-wf-tool')).toBeInTheDocument()
})
it('shows empty state when no workflow collections exist', () => {
mockCollectionData = createDefaultCollections().filter(c => c.type !== 'workflow')
renderProviderList({ category: 'workflow' })
expect(screen.getByTestId('workflow-empty')).toBeInTheDocument()
})
})
describe('Builtin Tab Empty State', () => {
it('shows empty component when no builtin collections', () => {
mockCollectionData = createDefaultCollections().filter(c => c.type !== 'builtin')
renderProviderList()
expect(screen.getByTestId('empty')).toBeInTheDocument()
})
it('renders collection that has no labels property', () => {
mockCollectionData = [{
id: 'no-labels',
name: 'no-label-tool',
author: 'Dify',
description: { en_US: 'Tool', zh_Hans: '工具' },
icon: 'icon',
label: { en_US: 'No Label Tool', zh_Hans: '无标签工具' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
}] as unknown as ReturnType<typeof createDefaultCollections>
renderProviderList()
expect(screen.getByTestId('card-no-label-tool')).toBeInTheDocument()
})
})
describe('MCP Tab', () => {
it('renders MCPList component', () => {
mockActiveTab = 'mcp'
render(<ProviderList />)
renderProviderList({ category: 'mcp' })
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
})
})
describe('Provider Detail', () => {
it('opens provider detail when a non-plugin collection is clicked', () => {
render(<ProviderList />)
renderProviderList()
fireEvent.click(screen.getByTestId('card-google-search'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
expect(screen.getByTestId('provider-detail')).toHaveTextContent('google-search')
})
it('closes provider detail when close button is clicked', () => {
render(<ProviderList />)
renderProviderList()
fireEvent.click(screen.getByTestId('card-google-search'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('detail-close'))
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
})
})
describe('Plugin Detail Panel', () => {
it('shows plugin detail panel when collection with plugin_id is selected', () => {
mockCheckedInstalledData = {
plugins: [{ id: 'org/plugin-tool', name: 'Plugin Tool' }],
}
renderProviderList()
expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('card-plugin-tool'))
expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument()
})
it('calls invalidateInstalledPluginList on plugin update', () => {
mockCheckedInstalledData = {
plugins: [{ id: 'org/plugin-tool', name: 'Plugin Tool' }],
}
renderProviderList()
fireEvent.click(screen.getByTestId('card-plugin-tool'))
fireEvent.click(screen.getByTestId('plugin-update'))
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
})
it('clears current provider on plugin panel close', () => {
mockCheckedInstalledData = {
plugins: [{ id: 'org/plugin-tool', name: 'Plugin Tool' }],
}
renderProviderList()
fireEvent.click(screen.getByTestId('card-plugin-tool'))
expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('plugin-close'))
expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument()
})
})
describe('Marketplace', () => {
it('shows marketplace when enable_marketplace is true and on builtin tab', () => {
mockEnableMarketplace = true
renderProviderList()
expect(screen.getByTestId('marketplace')).toBeInTheDocument()
})
it('does not show marketplace when enable_marketplace is false', () => {
renderProviderList()
expect(screen.queryByTestId('marketplace')).not.toBeInTheDocument()
})
it('scrolls to marketplace panel on arrow click', () => {
mockEnableMarketplace = true
renderProviderList()
fireEvent.click(screen.getByTestId('marketplace-arrow'))
expect(Element.prototype.scrollTo).toHaveBeenCalled()
})
})
describe('Scroll Handling', () => {
it('delegates scroll events to marketplace handleScroll', () => {
mockEnableMarketplace = true
const { container } = renderProviderList()
const scrollContainer = container.querySelector('.overflow-y-auto') as HTMLDivElement
fireEvent.scroll(scrollContainer)
expect(mockHandleScroll).toHaveBeenCalled()
})
it('updates marketplace arrow visibility on scroll', () => {
mockEnableMarketplace = true
renderProviderList()
expect(screen.getByTestId('marketplace-arrow')).toHaveTextContent('arrow-visible')
const scrollContainer = document.querySelector('.overflow-y-auto') as HTMLDivElement
fireEvent.scroll(scrollContainer)
expect(screen.getByTestId('marketplace-arrow')).toHaveTextContent('arrow-hidden')
})
})
})

View File

@@ -18,25 +18,11 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { ToolTypeEnum } from '../workflow/block-selector/types'
import Marketplace from './marketplace'
import { useMarketplace } from './marketplace/hooks'
import MCPList from './mcp'
import { getToolType } from './utils'
const getToolType = (type: string) => {
switch (type) {
case 'builtin':
return ToolTypeEnum.BuiltIn
case 'api':
return ToolTypeEnum.Custom
case 'workflow':
return ToolTypeEnum.Workflow
case 'mcp':
return ToolTypeEnum.MCP
default:
return ToolTypeEnum.BuiltIn
}
}
const ProviderList = () => {
// const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow'

View File

@@ -1,6 +1,22 @@
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { VisionFile } from '@/types/app'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
export const getToolType = (type: string) => {
switch (type) {
case 'builtin':
return ToolTypeEnum.BuiltIn
case 'api':
return ToolTypeEnum.Custom
case 'workflow':
return ToolTypeEnum.Workflow
case 'mcp':
return ToolTypeEnum.MCP
default:
return ToolTypeEnum.BuiltIn
}
}
export const sortAgentSorts = (list: ThoughtItem[]) => {
if (!list)

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import ChatVariableTrigger from './chat-variable-trigger'
import ChatVariableTrigger from '../chat-variable-trigger'
const mockUseNodesReadOnly = vi.fn()
const mockUseIsChatMode = vi.fn()
@@ -8,7 +8,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
vi.mock('../../hooks', () => ({
vi.mock('../../../hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))

View File

@@ -7,7 +7,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
import FeaturesTrigger from '../features-trigger'
const mockUseIsChatMode = vi.fn()
const mockUseTheme = vi.fn()

View File

@@ -4,7 +4,7 @@ import type { App } from '@/types/app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppModeEnum } from '@/types/app'
import WorkflowHeader from './index'
import WorkflowHeader from '../index'
const mockResetWorkflowVersionHistory = vi.fn()

View File

@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowOnboardingModal from './index'
import WorkflowOnboardingModal from '../index'
// Mock Modal component
vi.mock('@/app/components/base/modal', () => ({
@@ -33,14 +33,9 @@ vi.mock('@/app/components/base/modal', () => ({
},
}))
// Mock useDocLink hook
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock StartNodeSelectionPanel (using real component would be better for integration,
// but for this test we'll mock to control behavior)
vi.mock('./start-node-selection-panel', () => ({
vi.mock('../start-node-selection-panel', () => ({
default: function MockStartNodeSelectionPanel({
onSelectUserInput,
onSelectTrigger,

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import StartNodeOption from './start-node-option'
import StartNodeOption from '../start-node-option'
describe('StartNodeOption', () => {
const mockOnClick = vi.fn()

View File

@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeSelectionPanel from './start-node-selection-panel'
import StartNodeSelectionPanel from '../start-node-selection-panel'
// Mock NodeSelector component
vi.mock('@/app/components/workflow/block-selector', () => ({
@@ -11,7 +11,12 @@ vi.mock('@/app/components/workflow/block-selector', () => ({
onOpenChange,
onSelect,
trigger,
}: any) {
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (type: BlockEnum) => void
trigger: (() => React.ReactNode) | React.ReactNode
}) {
// trigger is a function that returns a React element
const triggerElement = typeof trigger === 'function' ? trigger() : trigger