mirror of
https://github.com/langgenius/dify.git
synced 2026-02-12 08:30:11 -05:00
Compare commits
3 Commits
refactor/r
...
test/add-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3768516b7 | ||
|
|
62b2523326 | ||
|
|
046953a630 |
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>,
|
||||
}))
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
31
web/app/components/datasets/create/__tests__/icons.spec.ts
Normal file
31
web/app/components/datasets/create/__tests__/icons.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
246
web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
Normal file
246
web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
Normal 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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
220
web/app/components/plugins/marketplace/__tests__/query.spec.tsx
Normal file
220
web/app/components/plugins/marketplace/__tests__/query.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
267
web/app/components/plugins/marketplace/__tests__/state.spec.tsx
Normal file
267
web/app/components/plugins/marketplace/__tests__/state.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' }))
|
||||
})
|
||||
|
||||
@@ -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/ }))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('VersionMismatchModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,7 @@ describe('usePipelineInit', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
setEnvSecrets: mockSetEnvSecrets,
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user