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()
+ })
+ })
+})