mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
test(web): add tests for snippets
This commit is contained in:
@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Snippets from '../index'
|
||||
|
||||
const mockUseInfiniteSnippetList = vi.fn()
|
||||
const mockHandleInsertSnippet = vi.fn()
|
||||
const mockHandleCreateSnippet = vi.fn()
|
||||
const mockHandleOpenCreateSnippetDialog = vi.fn()
|
||||
const mockHandleCloseCreateSnippetDialog = vi.fn()
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteScroll: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useInfiniteSnippetList: (...args: unknown[]) => mockUseInfiniteSnippetList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../use-insert-snippet', () => ({
|
||||
useInsertSnippet: () => ({
|
||||
handleInsertSnippet: mockHandleInsertSnippet,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-create-snippet', () => ({
|
||||
useCreateSnippet: () => ({
|
||||
createSnippetMutation: { isPending: false },
|
||||
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
|
||||
handleCreateSnippet: mockHandleCreateSnippet,
|
||||
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
|
||||
isCreateSnippetDialogOpen: false,
|
||||
isCreatingSnippet: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../create-snippet-dialog', () => ({
|
||||
default: ({ isOpen }: { isOpen: boolean }) => isOpen ? <div data-testid="create-snippet-dialog" /> : null,
|
||||
}))
|
||||
|
||||
describe('Snippets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render loading skeleton when loading', () => {
|
||||
const { container } = render(<Snippets loading searchText="" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render empty state when snippet list is empty', () => {
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render snippet rows from infinite list data', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
|
||||
render(<Snippets searchText="customer" />)
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: 'customer',
|
||||
is_published: true,
|
||||
})
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should delegate create action from empty state', () => {
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
|
||||
|
||||
expect(mockHandleOpenCreateSnippetDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should delegate insert action when snippet item is clicked', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
fireEvent.click(screen.getByText('Customer Review'))
|
||||
|
||||
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
import type { PublishedSnippetListItem } from '../snippet-detail-card'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetDetailCard from '../snippet-detail-card'
|
||||
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
|
||||
}))
|
||||
|
||||
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetDetailCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render snippet summary information', () => {
|
||||
render(<SnippetDetailCard snippet={createSnippet()} />)
|
||||
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
expect(screen.getByText('Snippet description')).toBeInTheDocument()
|
||||
expect(screen.getByText('Evan')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unique block icons from published workflow graph', () => {
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [
|
||||
{ data: { type: 'llm' } },
|
||||
{ data: { type: 'code' } },
|
||||
{ data: { type: 'llm' } },
|
||||
{ data: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<SnippetDetailCard snippet={createSnippet()} />)
|
||||
|
||||
expect(container.querySelectorAll('[data-icon="Llm"], [data-icon="Code"]')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,31 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetEmptyState from '../snippet-empty-state'
|
||||
|
||||
describe('SnippetEmptyState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render empty state copy and create action', () => {
|
||||
const handleCreate = vi.fn()
|
||||
|
||||
render(<SnippetEmptyState onCreate={handleCreate} />)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCreate when create button is clicked', () => {
|
||||
const handleCreate = vi.fn()
|
||||
|
||||
render(<SnippetEmptyState onCreate={handleCreate} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
|
||||
|
||||
expect(handleCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,85 @@
|
||||
import type { PublishedSnippetListItem } from '../snippet-detail-card'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetListItem from '../snippet-list-item'
|
||||
|
||||
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetListItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render snippet name', () => {
|
||||
render(
|
||||
<SnippetListItem
|
||||
snippet={createSnippet()}
|
||||
isHovered={false}
|
||||
onMouseEnter={vi.fn()}
|
||||
onMouseLeave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Evan')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render author when hovered', () => {
|
||||
render(
|
||||
<SnippetListItem
|
||||
snippet={createSnippet()}
|
||||
isHovered
|
||||
onMouseEnter={vi.fn()}
|
||||
onMouseLeave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Evan')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should forward click and hover handlers', () => {
|
||||
const handleClick = vi.fn()
|
||||
const handleMouseEnter = vi.fn()
|
||||
const handleMouseLeave = vi.fn()
|
||||
|
||||
render(
|
||||
<SnippetListItem
|
||||
snippet={createSnippet()}
|
||||
isHovered={false}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const item = screen.getByText('Customer Review').closest('div')!
|
||||
|
||||
fireEvent.mouseEnter(item)
|
||||
fireEvent.mouseLeave(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(handleMouseLeave).toHaveBeenCalledTimes(1)
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,139 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useCreateSnippet } from '../use-create-snippet'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockSyncDraftWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
snippets: {
|
||||
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useCreateSnippet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('State', () => {
|
||||
it('should open and close create snippet dialog', () => {
|
||||
const { result } = renderHook(() => useCreateSnippet())
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenCreateSnippetDialog()
|
||||
})
|
||||
expect(result.current.isCreateSnippetDialogOpen).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseCreateSnippetDialog()
|
||||
})
|
||||
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create Flow', () => {
|
||||
it('should create snippet, sync draft workflow, and navigate on success', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ id: 'snippet-123' })
|
||||
mockSyncDraftWorkflow.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useCreateSnippet())
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenCreateSnippetDialog()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateSnippet({
|
||||
name: 'My snippet',
|
||||
description: 'desc',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'My snippet',
|
||||
description: 'desc',
|
||||
icon_info: {
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-123' },
|
||||
body: {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
|
||||
expect(result.current.isCreatingSnippet).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast when create fails', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('create failed'))
|
||||
|
||||
const { result } = renderHook(() => useCreateSnippet())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateSnippet({
|
||||
name: 'My snippet',
|
||||
description: '',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('create failed')
|
||||
expect(result.current.isCreatingSnippet).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useInsertSnippet } from '../use-insert-snippet'
|
||||
|
||||
const mockFetchQuery = vi.fn()
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockGetNodes = vi.fn()
|
||||
const mockSetNodes = vi.fn()
|
||||
const mockSetEdges = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
fetchQuery: mockFetchQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
setNodes: mockSetNodes,
|
||||
edges: [{ id: 'existing-edge', source: 'old', target: 'old-2' }],
|
||||
setEdges: mockSetEdges,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
useWorkflowHistory: () => ({
|
||||
saveStateToHistory: mockSaveStateToHistory,
|
||||
}),
|
||||
WorkflowHistoryEvent: {
|
||||
NodePaste: 'NodePaste',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useInsertSnippet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodes.mockReturnValue([
|
||||
{
|
||||
id: 'existing-node',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { selected: true },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('Insert Flow', () => {
|
||||
it('should append remapped snippet graph into current workflow graph', async () => {
|
||||
mockFetchQuery.mockResolvedValue({
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'snippet-node-1',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { selected: false, _children: [{ nodeId: 'snippet-node-2', nodeType: 'code' }] },
|
||||
},
|
||||
{
|
||||
id: 'snippet-node-2',
|
||||
parentId: 'snippet-node-1',
|
||||
position: { x: 30, y: 40 },
|
||||
data: { selected: false },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'snippet-node-1',
|
||||
sourceHandle: 'source',
|
||||
target: 'snippet-node-2',
|
||||
targetHandle: 'target',
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
})
|
||||
|
||||
expect(mockFetchQuery).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetEdges).toHaveBeenCalledTimes(1)
|
||||
|
||||
const nextNodes = mockSetNodes.mock.calls[0][0]
|
||||
expect(nextNodes[0].selected).toBe(false)
|
||||
expect(nextNodes[0].data.selected).toBe(false)
|
||||
expect(nextNodes).toHaveLength(3)
|
||||
expect(nextNodes[1].id).not.toBe('snippet-node-1')
|
||||
expect(nextNodes[2].parentId).toBe(nextNodes[1].id)
|
||||
expect(nextNodes[1].data._children[0].nodeId).toBe(nextNodes[2].id)
|
||||
|
||||
const nextEdges = mockSetEdges.mock.calls[0][0]
|
||||
expect(nextEdges).toHaveLength(2)
|
||||
expect(nextEdges[1].source).toBe(nextNodes[1].id)
|
||||
expect(nextEdges[1].target).toBe(nextNodes[2].id)
|
||||
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodePaste', {
|
||||
nodeId: nextNodes[1].id,
|
||||
})
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show error toast when fetching snippet workflow fails', async () => {
|
||||
mockFetchQuery.mockRejectedValue(new Error('insert failed'))
|
||||
|
||||
const { result } = renderHook(() => useInsertSnippet())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1')
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('insert failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user