diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx new file mode 100644 index 0000000000..9907cd895a --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -0,0 +1,281 @@ +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog' +import type { SnippetDetail } from '@/models/snippet' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import SnippetInfoDropdown from '../dropdown' + +const mockReplace = vi.fn() +const mockDownloadBlob = vi.fn() +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +const mockUpdateMutate = vi.fn() +const mockExportMutateAsync = vi.fn() +const mockDeleteMutate = vi.fn() +let mockDropdownOpen = false +let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/base/ui/dropdown-menu', () => ({ + DropdownMenu: ({ + open, + onOpenChange, + children, + }: { + open?: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode + }) => { + mockDropdownOpen = !!open + mockDropdownOnOpenChange = onOpenChange + return
{children}
+ }, + DropdownMenuTrigger: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => ( + + ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( + mockDropdownOpen ?
{children}
: null + ), + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( + + ), + DropdownMenuSeparator: () =>
, +})) + +vi.mock('@/service/use-snippets', () => ({ + useUpdateSnippetMutation: () => ({ + mutate: mockUpdateMutate, + isPending: false, + }), + useExportSnippetMutation: () => ({ + mutateAsync: mockExportMutateAsync, + isPending: false, + }), + useDeleteSnippetMutation: () => ({ + mutate: mockDeleteMutate, + isPending: false, + }), +})) + +type MockCreateSnippetDialogProps = { + isOpen: boolean + title?: string + confirmText?: string + initialValue?: { + name?: string + description?: string + icon?: AppIconSelection + } + onClose: () => void + onConfirm: (payload: CreateSnippetDialogPayload) => void +} + +vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({ + default: ({ + isOpen, + title, + confirmText, + initialValue, + onClose, + onConfirm, + }: MockCreateSnippetDialogProps) => { + if (!isOpen) + return null + + return ( +
+
{title}
+
{confirmText}
+
{initialValue?.name}
+
{initialValue?.description}
+ + +
+ ) + }, +})) + +const mockSnippet: SnippetDetail = { + id: 'snippet-1', + name: 'Social Media Repurposer', + description: 'Turn one blog post into multiple social media variations.', + author: 'Dify', + updatedAt: '2026-03-25 10:00', + usage: '12', + icon: '🤖', + iconBackground: '#F0FDF9', + status: undefined, +} + +describe('SnippetInfoDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDropdownOpen = false + mockDropdownOnOpenChange = undefined + }) + + // Rendering coverage for the menu trigger itself. + describe('Rendering', () => { + it('should render the dropdown trigger button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + // Edit flow should seed the dialog with current snippet info and submit updates. + describe('Edit Snippet', () => { + it('should open the edit dialog and submit snippet updates', async () => { + const user = userEvent.setup() + mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + + render() + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.editInfo')) + + expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() + expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument() + expect(screen.getByText('common.operation.save')).toBeInTheDocument() + expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() + expect(screen.getByText(mockSnippet.description)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'submit-edit' })) + + expect(mockUpdateMutate).toHaveBeenCalledWith({ + params: { snippetId: mockSnippet.id }, + body: { + name: 'Updated snippet', + description: 'Updated description', + icon_info: { + icon: '✨', + icon_type: 'emoji', + icon_background: '#FFFFFF', + icon_url: undefined, + }, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone') + }) + }) + + // Export should call the export hook and download the returned YAML blob. + describe('Export Snippet', () => { + it('should export and download the snippet yaml', async () => { + const user = userEvent.setup() + mockExportMutateAsync.mockResolvedValue('yaml: content') + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.exportSnippet')) + + await waitFor(() => { + expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id }) + }) + + expect(mockDownloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: `${mockSnippet.name}.yml`, + }) + }) + + it('should show an error toast when export fails', async () => { + const user = userEvent.setup() + mockExportMutateAsync.mockRejectedValue(new Error('export failed')) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.exportSnippet')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed') + }) + }) + }) + + // Delete should require confirmation and redirect after a successful mutation. + describe('Delete Snippet', () => { + it('should confirm deletion and redirect to the snippets list', async () => { + const user = userEvent.setup() + mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.deleteSnippet')) + + expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument() + expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' })) + + expect(mockDeleteMutate).toHaveBeenCalledWith({ + params: { snippetId: mockSnippet.id }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted') + expect(mockReplace).toHaveBeenCalledWith('/snippets') + }) + }) +}) diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/index.spec.tsx new file mode 100644 index 0000000000..50754ffd23 --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/__tests__/index.spec.tsx @@ -0,0 +1,62 @@ +import type { SnippetDetail } from '@/models/snippet' +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import SnippetInfo from '..' + +vi.mock('../dropdown', () => ({ + default: () =>
, +})) + +const mockSnippet: SnippetDetail = { + id: 'snippet-1', + name: 'Social Media Repurposer', + description: 'Turn one blog post into multiple social media variations.', + author: 'Dify', + updatedAt: '2026-03-25 10:00', + usage: '12', + icon: '🤖', + iconBackground: '#F0FDF9', + status: undefined, +} + +describe('SnippetInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the collapsed and expanded sidebar header states. + describe('Rendering', () => { + it('should render the expanded snippet details and dropdown when expand is true', () => { + render() + + expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() + expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument() + expect(screen.getByText(mockSnippet.description)).toBeInTheDocument() + expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument() + }) + + it('should hide the expanded-only content when expand is false', () => { + render() + + expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument() + expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument() + expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument() + expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument() + }) + }) + + // Edge cases around optional snippet fields should not break the header layout. + describe('Edge Cases', () => { + it('should omit the description block when the snippet has no description', () => { + render( + , + ) + + expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() + expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument() + }) + }) +})