mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 13:34:38 +08:00
480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
import type { ReactElement } from 'react'
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { Plan } from '@/app/components/billing/type'
|
|
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
|
import { ToastContext } from '@/app/components/base/toast'
|
|
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
|
import FeaturesTrigger from './features-trigger'
|
|
|
|
const mockUseIsChatMode = jest.fn()
|
|
const mockUseTheme = jest.fn()
|
|
const mockUseNodesReadOnly = jest.fn()
|
|
const mockUseChecklist = jest.fn()
|
|
const mockUseChecklistBeforePublish = jest.fn()
|
|
const mockUseNodesSyncDraft = jest.fn()
|
|
const mockUseFeatures = jest.fn()
|
|
const mockUseProviderContext = jest.fn()
|
|
const mockUseNodes = jest.fn()
|
|
const mockUseEdges = jest.fn()
|
|
const mockUseAppStoreSelector = jest.fn()
|
|
|
|
const mockNotify = jest.fn()
|
|
const mockHandleCheckBeforePublish = jest.fn()
|
|
const mockHandleSyncWorkflowDraft = jest.fn()
|
|
const mockPublishWorkflow = jest.fn()
|
|
const mockUpdatePublishedWorkflow = jest.fn()
|
|
const mockResetWorkflowVersionHistory = jest.fn()
|
|
const mockInvalidateAppTriggers = jest.fn()
|
|
const mockFetchAppDetail = jest.fn()
|
|
const mockSetAppDetail = jest.fn()
|
|
const mockSetPublishedAt = jest.fn()
|
|
const mockSetLastPublishedHasUserInput = jest.fn()
|
|
|
|
const mockWorkflowStoreSetState = jest.fn()
|
|
const mockWorkflowStoreSetShowFeaturesPanel = jest.fn()
|
|
|
|
let workflowStoreState = {
|
|
showFeaturesPanel: false,
|
|
isRestoring: false,
|
|
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
|
|
setPublishedAt: mockSetPublishedAt,
|
|
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
|
|
}
|
|
|
|
const mockWorkflowStore = {
|
|
getState: () => workflowStoreState,
|
|
setState: mockWorkflowStoreSetState,
|
|
}
|
|
|
|
jest.mock('@/app/components/workflow/hooks', () => ({
|
|
__esModule: true,
|
|
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
|
|
useChecklistBeforePublish: () => mockUseChecklistBeforePublish(),
|
|
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
|
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
|
|
useIsChatMode: () => mockUseIsChatMode(),
|
|
}))
|
|
|
|
jest.mock('@/app/components/workflow/store', () => ({
|
|
__esModule: true,
|
|
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
|
const state: Record<string, unknown> = {
|
|
publishedAt: null,
|
|
draftUpdatedAt: null,
|
|
toolPublished: false,
|
|
lastPublishedHasUserInput: false,
|
|
}
|
|
return selector(state)
|
|
},
|
|
useWorkflowStore: () => mockWorkflowStore,
|
|
}))
|
|
|
|
jest.mock('@/app/components/base/features/hooks', () => ({
|
|
__esModule: true,
|
|
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
|
|
}))
|
|
|
|
jest.mock('@/context/provider-context', () => ({
|
|
__esModule: true,
|
|
useProviderContext: () => mockUseProviderContext(),
|
|
}))
|
|
|
|
jest.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
|
__esModule: true,
|
|
default: () => mockUseNodes(),
|
|
}))
|
|
|
|
jest.mock('reactflow', () => ({
|
|
__esModule: true,
|
|
useEdges: () => mockUseEdges(),
|
|
}))
|
|
|
|
jest.mock('@/app/components/app/app-publisher', () => ({
|
|
__esModule: true,
|
|
default: (props: AppPublisherProps) => {
|
|
const inputs = props.inputs ?? []
|
|
return (
|
|
<div
|
|
data-testid='app-publisher'
|
|
data-disabled={String(Boolean(props.disabled))}
|
|
data-publish-disabled={String(Boolean(props.publishDisabled))}
|
|
data-start-node-limit-exceeded={String(Boolean(props.startNodeLimitExceeded))}
|
|
data-has-trigger-node={String(Boolean(props.hasTriggerNode))}
|
|
data-inputs={JSON.stringify(inputs)}
|
|
>
|
|
<button type="button" onClick={() => { props.onRefreshData?.() }}>
|
|
publisher-refresh
|
|
</button>
|
|
<button type="button" onClick={() => { props.onToggle?.(true) }}>
|
|
publisher-toggle-on
|
|
</button>
|
|
<button type="button" onClick={() => { props.onToggle?.(false) }}>
|
|
publisher-toggle-off
|
|
</button>
|
|
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
|
|
publisher-publish
|
|
</button>
|
|
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
|
publisher-publish-with-params
|
|
</button>
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
jest.mock('@/service/use-workflow', () => ({
|
|
__esModule: true,
|
|
useInvalidateAppWorkflow: () => mockUpdatePublishedWorkflow,
|
|
usePublishWorkflow: () => ({ mutateAsync: mockPublishWorkflow }),
|
|
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
|
|
}))
|
|
|
|
jest.mock('@/service/use-tools', () => ({
|
|
__esModule: true,
|
|
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
|
|
}))
|
|
|
|
jest.mock('@/service/apps', () => ({
|
|
__esModule: true,
|
|
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
|
|
}))
|
|
|
|
jest.mock('@/hooks/use-theme', () => ({
|
|
__esModule: true,
|
|
default: () => mockUseTheme(),
|
|
}))
|
|
|
|
jest.mock('@/app/components/app/store', () => ({
|
|
__esModule: true,
|
|
useStore: (selector: (state: { appDetail?: { id: string }; setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector),
|
|
}))
|
|
|
|
const createProviderContext = ({
|
|
type = Plan.sandbox,
|
|
isFetchedPlan = true,
|
|
}: {
|
|
type?: Plan
|
|
isFetchedPlan?: boolean
|
|
}) => ({
|
|
plan: { type },
|
|
isFetchedPlan,
|
|
})
|
|
|
|
const renderWithToast = (ui: ReactElement) => {
|
|
return render(
|
|
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
|
|
{ui}
|
|
</ToastContext.Provider>,
|
|
)
|
|
}
|
|
|
|
describe('FeaturesTrigger', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
workflowStoreState = {
|
|
showFeaturesPanel: false,
|
|
isRestoring: false,
|
|
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
|
|
setPublishedAt: mockSetPublishedAt,
|
|
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
|
|
}
|
|
|
|
mockUseTheme.mockReturnValue({ theme: 'light' })
|
|
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
|
mockUseChecklist.mockReturnValue([])
|
|
mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish })
|
|
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
|
mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft })
|
|
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } }))
|
|
mockUseProviderContext.mockReturnValue(createProviderContext({}))
|
|
mockUseNodes.mockReturnValue([])
|
|
mockUseEdges.mockReturnValue([])
|
|
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail }))
|
|
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
|
|
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
|
|
})
|
|
|
|
// Verifies the feature toggle button only appears in chatflow mode.
|
|
describe('Rendering', () => {
|
|
it('should not render the features button when not in chat mode', () => {
|
|
// Arrange
|
|
mockUseIsChatMode.mockReturnValue(false)
|
|
|
|
// Act
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Assert
|
|
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render the features button when in chat mode', () => {
|
|
// Arrange
|
|
mockUseIsChatMode.mockReturnValue(true)
|
|
|
|
// Act
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Assert
|
|
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
|
|
})
|
|
|
|
it('should apply dark theme styling when theme is dark', () => {
|
|
// Arrange
|
|
mockUseIsChatMode.mockReturnValue(true)
|
|
mockUseTheme.mockReturnValue({ theme: 'dark' })
|
|
|
|
// Act
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Assert
|
|
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
|
|
})
|
|
})
|
|
|
|
// Verifies user clicks toggle the features panel visibility.
|
|
describe('User Interactions', () => {
|
|
it('should toggle features panel when clicked and nodes are editable', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockUseIsChatMode.mockReturnValue(true)
|
|
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
|
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
|
|
|
|
// Assert
|
|
expect(mockWorkflowStoreSetShowFeaturesPanel).toHaveBeenCalledWith(true)
|
|
})
|
|
})
|
|
|
|
// Covers read-only gating that prevents toggling unless restoring.
|
|
describe('Edge Cases', () => {
|
|
it('should not toggle features panel when nodes are read-only and not restoring', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockUseIsChatMode.mockReturnValue(true)
|
|
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true, getNodesReadOnly: () => true })
|
|
workflowStoreState = {
|
|
...workflowStoreState,
|
|
isRestoring: false,
|
|
}
|
|
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
|
|
|
|
// Assert
|
|
expect(mockWorkflowStoreSetShowFeaturesPanel).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// Verifies the publisher reflects the presence of workflow nodes.
|
|
describe('Props', () => {
|
|
it('should disable AppPublisher when there are no workflow nodes', () => {
|
|
// Arrange
|
|
mockUseIsChatMode.mockReturnValue(false)
|
|
mockUseNodes.mockReturnValue([])
|
|
|
|
// Act
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
|
|
})
|
|
})
|
|
|
|
// Verifies derived props passed into AppPublisher (variables, limits, and triggers).
|
|
describe('Computed Props', () => {
|
|
it('should append image input when file image upload is enabled', () => {
|
|
// Arrange
|
|
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({
|
|
features: { file: { image: { enabled: true } } },
|
|
}))
|
|
mockUseNodes.mockReturnValue([
|
|
{ id: 'start', data: { type: BlockEnum.Start } },
|
|
])
|
|
|
|
// Act
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Assert
|
|
const inputs = JSON.parse(screen.getByTestId('app-publisher').getAttribute('data-inputs') ?? '[]') as Array<{
|
|
type?: string
|
|
variable?: string
|
|
required?: boolean
|
|
label?: string
|
|
}>
|
|
expect(inputs).toContainEqual({
|
|
type: InputVarType.files,
|
|
variable: '__image',
|
|
required: false,
|
|
label: 'files',
|
|
})
|
|
})
|
|
|
|
it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => {
|
|
// Arrange
|
|
mockUseNodes.mockReturnValue([
|
|
{ id: 'start', data: { type: BlockEnum.Start } },
|
|
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } },
|
|
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } },
|
|
{ id: 'end', data: { type: BlockEnum.End } },
|
|
])
|
|
|
|
// Act
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Assert
|
|
const publisher = screen.getByTestId('app-publisher')
|
|
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', 'true')
|
|
expect(publisher).toHaveAttribute('data-publish-disabled', 'true')
|
|
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
|
|
})
|
|
})
|
|
|
|
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
|
|
describe('Callbacks', () => {
|
|
it('should set toolPublished when AppPublisher refreshes data', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-refresh' }))
|
|
|
|
// Assert
|
|
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
|
|
})
|
|
|
|
it('should sync workflow draft when AppPublisher toggles on', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-toggle-on' }))
|
|
|
|
// Assert
|
|
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('should not sync workflow draft when AppPublisher toggles off', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-toggle-off' }))
|
|
|
|
// Assert
|
|
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// Verifies publishing behavior across warnings, validation, and success.
|
|
describe('Publishing', () => {
|
|
it('should notify error and reject publish when checklist has warning nodes', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
|
|
})
|
|
expect(mockPublishWorkflow).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should reject publish when checklist before publish fails', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockHandleCheckBeforePublish.mockResolvedValue(false)
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act & Assert
|
|
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleCheckBeforePublish).toHaveBeenCalled()
|
|
})
|
|
expect(mockPublishWorkflow).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should publish workflow and update related stores when validation passes', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockUseNodes.mockReturnValue([
|
|
{ id: 'start', data: { type: BlockEnum.Start } },
|
|
])
|
|
mockUseEdges.mockReturnValue([
|
|
{ source: 'start' },
|
|
])
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
|
url: '/apps/app-id/workflows/publish',
|
|
title: '',
|
|
releaseNotes: '',
|
|
})
|
|
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
|
|
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
|
|
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
|
|
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
|
|
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
|
|
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
|
|
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
|
|
expect(mockSetAppDetail).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should pass publish params to workflow publish mutation', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-publish-with-params' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
|
url: '/apps/app-id/workflows/publish',
|
|
title: 'Test title',
|
|
releaseNotes: 'Test notes',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should log error when app detail refresh fails after publish', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
|
|
|
|
renderWithToast(<FeaturesTrigger />)
|
|
|
|
// Act
|
|
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
})
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
})
|
|
})
|