diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx new file mode 100644 index 0000000000..d0d489c215 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/index.spec.tsx @@ -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('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 ?
: 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() + + expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0) + }) + + it('should render empty state when snippet list is empty', () => { + render() + + 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() + + 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() + + 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() + + fireEvent.click(screen.getByText('Customer Review')) + + expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1') + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx new file mode 100644 index 0000000000..ade2bd2d61 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-detail-card.spec.tsx @@ -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 => ({ + 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() + + 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() + + expect(container.querySelectorAll('[data-icon="Llm"], [data-icon="Code"]')).toHaveLength(2) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx new file mode 100644 index 0000000000..1ab2f712bf --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-empty-state.spec.tsx @@ -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() + + 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() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })) + + expect(handleCreate).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx new file mode 100644 index 0000000000..0a1292a68e --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/snippet-list-item.spec.tsx @@ -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 => ({ + 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( + , + ) + + expect(screen.getByText('Customer Review')).toBeInTheDocument() + expect(screen.queryByText('Evan')).not.toBeInTheDocument() + }) + + it('should render author when hovered', () => { + render( + , + ) + + 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( + , + ) + + 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) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-create-snippet.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-create-snippet.spec.tsx new file mode 100644 index 0000000000..5d70d9c5c9 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-create-snippet.spec.tsx @@ -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) + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx new file mode 100644 index 0000000000..2f8864a411 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx @@ -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') + }) + }) +})