From a761ab5ceedc29a03b15339e854acfa27dc975f6 Mon Sep 17 00:00:00 2001 From: yyh Date: Sat, 7 Feb 2026 16:53:58 +0800 Subject: [PATCH] test(skill): add comprehensive unit tests for file-tree domain --- .../artifacts/artifacts-section.spec.tsx | 202 +++++++ .../artifacts/artifacts-tree.spec.tsx | 189 ++++++ .../tree/drag-action-tooltip.spec.tsx | 116 ++++ .../skill/file-tree/tree/file-tree.spec.tsx | 545 ++++++++++++++++++ .../skill/file-tree/tree/menu-item.spec.tsx | 133 +++++ .../skill/file-tree/tree/node-menu.spec.tsx | 252 ++++++++ .../tree/search-result-list.spec.tsx | 171 ++++++ .../file-tree/tree/tree-context-menu.spec.tsx | 174 ++++++ .../file-tree/tree/tree-guide-lines.spec.tsx | 51 ++ .../file-tree/tree/tree-node-icon.spec.tsx | 122 ++++ .../skill/file-tree/tree/tree-node.spec.tsx | 339 +++++++++++ .../tree/upload-status-tooltip.spec.tsx | 187 ++++++ .../data/use-skill-asset-tree.spec.tsx | 165 ++++++ .../use-skill-tree-collaboration.spec.tsx | 168 ++++++ .../file-tree/dnd/use-file-drop.spec.tsx | 278 +++++++++ .../dnd/use-folder-file-drop.spec.tsx | 242 ++++++++ .../file-tree/dnd/use-root-file-drop.spec.tsx | 237 ++++++++ .../file-tree/dnd/use-unified-drag.spec.tsx | 187 ++++++ .../interaction/use-delayed-click.spec.tsx | 145 +++++ .../interaction/use-skill-shortcuts.spec.tsx | 190 ++++++ .../use-tree-node-handlers.spec.tsx | 237 ++++++++ .../operations/use-create-operations.spec.tsx | 427 ++++++++++++++ .../use-download-operation.spec.tsx | 173 ++++++ .../operations/use-file-operations.spec.tsx | 335 +++++++++++ .../operations/use-modify-operations.spec.tsx | 338 +++++++++++ .../operations/use-node-move.spec.tsx | 135 +++++ .../operations/use-node-reorder.spec.tsx | 126 ++++ .../operations/use-paste-operation.spec.tsx | 381 ++++++++++++ .../skill-body/tabs/file-tab-item.spec.tsx | 100 ++++ .../skill/skill-body/tabs/file-tabs.spec.tsx | 239 ++++++++ .../skill-body/tabs/start-tab-item.spec.tsx | 61 ++ 31 files changed, 6645 insertions(+) create mode 100644 web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/drag-action-tooltip.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/search-result-list.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/tree-guide-lines.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/tree-node-icon.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx create mode 100644 web/app/components/workflow/skill/file-tree/tree/upload-status-tooltip.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/data/use-skill-tree-collaboration.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/dnd/use-file-drop.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/dnd/use-folder-file-drop.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/dnd/use-root-file-drop.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/dnd/use-unified-drag.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/interaction/use-delayed-click.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/interaction/use-skill-shortcuts.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/interaction/use-tree-node-handlers.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-file-operations.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-modify-operations.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-node-move.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-node-reorder.spec.tsx create mode 100644 web/app/components/workflow/skill/hooks/file-tree/operations/use-paste-operation.spec.tsx create mode 100644 web/app/components/workflow/skill/skill-body/tabs/file-tab-item.spec.tsx create mode 100644 web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx create mode 100644 web/app/components/workflow/skill/skill-body/tabs/start-tab-item.spec.tsx diff --git a/web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx b/web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx new file mode 100644 index 0000000000..a39472dae9 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx @@ -0,0 +1,202 @@ +import type { SandboxFileDownloadTicket, SandboxFileTreeNode } from '@/types/sandbox-file' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import ArtifactsSection from './artifacts-section' + +type MockStoreState = { + appId: string | undefined + selectedArtifactPath: string | null +} + +const mocks = vi.hoisted(() => ({ + storeState: { + appId: 'app-1', + selectedArtifactPath: null, + } as MockStoreState, + treeData: undefined as SandboxFileTreeNode[] | undefined, + hasFiles: false, + isLoading: false, + isDownloading: false, + selectArtifact: vi.fn(), + fetchDownloadUrl: vi.fn<(path: string) => Promise>(), + downloadUrl: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockStoreState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => ({ + getState: () => ({ + selectArtifact: mocks.selectArtifact, + }), + }), +})) + +vi.mock('@/service/use-sandbox-file', () => ({ + useSandboxFilesTree: () => ({ + data: mocks.treeData, + hasFiles: mocks.hasFiles, + isLoading: mocks.isLoading, + }), + useDownloadSandboxFile: () => ({ + mutateAsync: mocks.fetchDownloadUrl, + isPending: mocks.isDownloading, + }), +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: (...args: unknown[]) => mocks.downloadUrl(...args), +})) + +const createNode = (overrides: Partial = {}): SandboxFileTreeNode => ({ + id: 'node-1', + name: 'report.txt', + path: 'report.txt', + node_type: 'file', + size: 1, + mtime: 1700000000, + extension: 'txt', + children: [], + ...overrides, +}) + +describe('ArtifactsSection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storeState.appId = 'app-1' + mocks.storeState.selectedArtifactPath = null + mocks.treeData = undefined + mocks.hasFiles = false + mocks.isLoading = false + mocks.isDownloading = false + mocks.fetchDownloadUrl.mockResolvedValue({ + download_url: 'https://example.com/download/report.txt', + expires_in: 3600, + export_id: 'abc123def4567890', + }) + }) + + // Covers collapsed header rendering and visual indicators. + describe('Rendering', () => { + it('should render collapsed header and apply custom className', () => { + const { container } = render() + + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByText('workflow.skillSidebar.artifacts.title')).toBeInTheDocument() + expect(container.firstChild).toHaveClass('px-2') + }) + + it('should show blue dot when collapsed and files exist', () => { + mocks.hasFiles = true + mocks.treeData = [createNode()] + + const { container } = render() + + expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument() + }) + + it('should show spinner when file tree is loading', () => { + mocks.isLoading = true + + const { container } = render() + + expect(container.querySelector('.animate-spin')).toBeInTheDocument() + }) + }) + + // Covers expanded branches for empty and loading states. + describe('Expanded content', () => { + it('should render empty state when expanded and there are no files', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })) + + expect(screen.getByText('workflow.skillSidebar.artifacts.emptyState')).toBeInTheDocument() + }) + + it('should not render empty state content while loading even when expanded', () => { + mocks.isLoading = true + + render() + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })) + + expect(screen.queryByText('workflow.skillSidebar.artifacts.emptyState')).not.toBeInTheDocument() + }) + }) + + // Covers real tree integration for selecting and downloading artifacts. + describe('Artifacts tree interactions', () => { + it('should render file rows and select artifact path when a file is clicked', () => { + const selectedFile = createNode({ id: 'selected', name: 'a.txt', path: 'a.txt' }) + const otherFile = createNode({ id: 'other', name: 'b.txt', path: 'b.txt' }) + mocks.hasFiles = true + mocks.treeData = [selectedFile, otherFile] + mocks.storeState.selectedArtifactPath = 'a.txt' + + render() + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })) + + expect(screen.getByRole('button', { name: 'a.txt' })).toHaveAttribute('aria-selected', 'true') + fireEvent.click(screen.getByRole('button', { name: 'b.txt' })) + + expect(mocks.selectArtifact).toHaveBeenCalledTimes(1) + expect(mocks.selectArtifact).toHaveBeenCalledWith('b.txt') + }) + + it('should request download URL and trigger browser download when file download succeeds', async () => { + const file = createNode({ name: 'export.csv', path: 'export.csv', extension: 'csv' }) + mocks.hasFiles = true + mocks.treeData = [file] + mocks.fetchDownloadUrl.mockResolvedValue({ + download_url: 'https://example.com/download/export.csv', + expires_in: 3600, + export_id: 'fedcba9876543210', + }) + + render() + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })) + fireEvent.click(screen.getByRole('button', { name: 'Download export.csv' })) + + await waitFor(() => { + expect(mocks.fetchDownloadUrl).toHaveBeenCalledWith('export.csv') + }) + await waitFor(() => { + expect(mocks.downloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/download/export.csv', + fileName: 'export.csv', + }) + }) + }) + + it('should log error and skip browser download when download request fails', async () => { + const file = createNode({ name: 'broken.bin', path: 'broken.bin', extension: 'bin' }) + const error = new Error('request failed') + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mocks.hasFiles = true + mocks.treeData = [file] + mocks.fetchDownloadUrl.mockRejectedValue(error) + + render() + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })) + fireEvent.click(screen.getByRole('button', { name: 'Download broken.bin' })) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Download failed:', error) + }) + expect(mocks.downloadUrl).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + + it('should disable download buttons when a download request is pending', () => { + const file = createNode({ name: 'asset.png', path: 'asset.png', extension: 'png' }) + mocks.hasFiles = true + mocks.treeData = [file] + mocks.isDownloading = true + + render() + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })) + + expect(screen.getByRole('button', { name: 'Download asset.png' })).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx b/web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx new file mode 100644 index 0000000000..0938926841 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx @@ -0,0 +1,189 @@ +import type { SandboxFileTreeNode } from '@/types/sandbox-file' +import { fireEvent, render, screen } from '@testing-library/react' +import ArtifactsTree from './artifacts-tree' + +const createNode = (overrides: Partial = {}): SandboxFileTreeNode => ({ + id: 'node-1', + name: 'report.txt', + path: 'report.txt', + node_type: 'file', + size: 1, + mtime: 1700000000, + extension: 'txt', + children: [], + ...overrides, +}) + +describe('ArtifactsTree', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Covers guard branches when no tree data is available. + describe('Rendering', () => { + it('should render nothing when data is undefined', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when data is empty', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should reveal and hide children when folder row is toggled', () => { + const child = createNode({ + id: 'node-child', + name: 'nested.txt', + path: 'docs/nested.txt', + }) + const folder = createNode({ + id: 'node-folder', + name: 'docs', + path: 'docs', + node_type: 'folder', + extension: null, + children: [child], + }) + + render() + + const folderButton = screen.getByRole('button', { name: 'docs folder' }) + expect(folderButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: 'nested.txt' })).not.toBeInTheDocument() + + fireEvent.click(folderButton) + + expect(folderButton).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: 'nested.txt' })).toBeInTheDocument() + + fireEvent.click(folderButton) + + expect(folderButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: 'nested.txt' })).not.toBeInTheDocument() + }) + }) + + // Covers keyboard-driven expansion/selection behavior. + describe('Keyboard interactions', () => { + it('should toggle a folder when Enter and Space keys are pressed', () => { + const folder = createNode({ + id: 'node-folder', + name: 'assets', + path: 'assets', + node_type: 'folder', + extension: null, + }) + + render() + + const folderButton = screen.getByRole('button', { name: 'assets folder' }) + + fireEvent.keyDown(folderButton, { key: 'Enter' }) + expect(folderButton).toHaveAttribute('aria-expanded', 'true') + + fireEvent.keyDown(folderButton, { key: ' ' }) + expect(folderButton).toHaveAttribute('aria-expanded', 'false') + }) + + it('should call onSelect when Enter is pressed on a file row', () => { + const file = createNode({ name: 'guide.md', path: 'guide.md', extension: 'md' }) + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.keyDown(screen.getByRole('button', { name: 'guide.md' }), { key: 'Enter' }) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(file) + }) + }) + + // Covers selection state and click behavior for file rows. + describe('Selection', () => { + it('should call onSelect when a file row is clicked', () => { + const file = createNode({ name: 'main.py', path: 'src/main.py', extension: 'py' }) + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'main.py' })) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(file) + }) + + it('should mark only the matching file path as selected', () => { + const selectedFile = createNode({ id: 'selected', name: 'a.txt', path: 'a.txt' }) + const otherFile = createNode({ id: 'other', name: 'b.txt', path: 'b.txt' }) + + render( + , + ) + + expect(screen.getByRole('button', { name: 'a.txt' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('button', { name: 'b.txt' })).toHaveAttribute('aria-selected', 'false') + }) + }) + + // Covers download events including stopPropagation and disabled state. + describe('Download', () => { + it('should call onDownload without triggering onSelect when download button is clicked', () => { + const file = createNode({ name: 'archive.zip', path: 'archive.zip', extension: 'zip' }) + const onDownload = vi.fn() + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Download archive.zip' })) + + expect(onDownload).toHaveBeenCalledTimes(1) + expect(onDownload).toHaveBeenCalledWith(file) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('should disable download buttons when isDownloading is true', () => { + const file = createNode({ name: 'asset.png', path: 'asset.png', extension: 'png' }) + const onDownload = vi.fn() + + render( + , + ) + + const downloadButton = screen.getByRole('button', { name: 'Download asset.png' }) + expect(downloadButton).toBeDisabled() + + fireEvent.click(downloadButton) + + expect(onDownload).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/drag-action-tooltip.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/drag-action-tooltip.spec.tsx new file mode 100644 index 0000000000..6e126e145c --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/drag-action-tooltip.spec.tsx @@ -0,0 +1,116 @@ +import type { AppAssetTreeView } from '@/types/app-asset' +import { render, screen } from '@testing-library/react' +import { ROOT_ID } from '../../constants' +import DragActionTooltip from './drag-action-tooltip' + +type MockWorkflowState = { + dragOverFolderId: string | null +} + +const mocks = vi.hoisted(() => ({ + storeState: { + dragOverFolderId: null, + } as MockWorkflowState, + nodeMap: undefined as Map | undefined, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), +})) + +vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({ + useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }), +})) + +const createNode = (overrides: Partial = {}): AppAssetTreeView => ({ + id: 'folder-1', + node_type: 'folder', + name: 'assets', + path: '/assets', + extension: '', + size: 0, + children: [], + ...overrides, +}) + +const setDragOverFolderId = (value: string | null) => { + mocks.storeState.dragOverFolderId = value +} + +const setNodeMap = (nodes: AppAssetTreeView[] = []) => { + mocks.nodeMap = new Map(nodes.map(node => [node.id, node])) +} + +describe('DragActionTooltip', () => { + beforeEach(() => { + vi.clearAllMocks() + setDragOverFolderId(null) + setNodeMap([]) + }) + + // Tooltip should only render while dragging over a valid target. + describe('Rendering', () => { + it('should render nothing when dragOverFolderId is null', () => { + // Arrange + setDragOverFolderId(null) + + // Act + const { container } = render() + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render upload action and root folder label for root target', () => { + // Arrange + setDragOverFolderId(ROOT_ID) + + // Act + render() + + // Assert + expect(screen.getByText(/workflow\.skillSidebar\.dragAction\.uploadTo/i)).toBeInTheDocument() + expect(screen.getByText('workflow.skillSidebar.rootFolder')).toBeInTheDocument() + }) + }) + + // Target path resolution should normalize node paths. + describe('Path resolution', () => { + it('should strip leading slash from node path for move action', () => { + // Arrange + setDragOverFolderId('folder-1') + setNodeMap([createNode({ id: 'folder-1', path: '/skills/assets' })]) + + // Act + render() + + // Assert + expect(screen.getByText(/workflow\.skillSidebar\.dragAction\.moveTo/i)).toBeInTheDocument() + expect(screen.getByText('skills/assets')).toBeInTheDocument() + }) + + it('should keep path unchanged when it does not start with slash', () => { + // Arrange + setDragOverFolderId('folder-1') + setNodeMap([createNode({ id: 'folder-1', path: 'relative/path' })]) + + // Act + render() + + // Assert + expect(screen.getByText('relative/path')).toBeInTheDocument() + }) + + it('should render nothing when target node path is missing', () => { + // Arrange + setDragOverFolderId('missing-folder') + setNodeMap([createNode({ id: 'folder-1' })]) + + // Act + const { container } = render() + + // Assert + expect(container.firstChild).toBeNull() + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx new file mode 100644 index 0000000000..4f60fad145 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx @@ -0,0 +1,545 @@ +import type { ReactNode, Ref } from 'react' +import type { AppAssetTreeView } from '@/types/app-asset' +import { fireEvent, render, screen } from '@testing-library/react' +import { CONTEXT_MENU_TYPE, ROOT_ID } from '../../constants' +import FileTree from './file-tree' + +type MockWorkflowState = { + expandedFolderIds: Set + activeTabId: string | null + dragOverFolderId: string | null + currentDragType: 'move' | 'upload' | null + fileTreeSearchTerm: string +} + +type MockWorkflowActions = { + toggleFolder: (id: string) => void + openTab: (id: string, options: { pinned: boolean }) => void + setSelectedNodeIds: (ids: string[]) => void + clearSelection: () => void + setContextMenu: (menu: { top: number, left: number, type: string } | null) => void + setDragInsertTarget: (target: { parentId: string | null, index: number } | null) => void + setFileTreeSearchTerm: (term: string) => void +} + +type MockAssetTreeHookResult = { + data: { children: AppAssetTreeView[] } | undefined + isLoading: boolean + error: Error | null + dataUpdatedAt: number +} + +type MockInlineCreateNodeResult = { + treeNodes: AppAssetTreeView[] + handleRename: (payload: { id: string, name: string }) => void + searchMatch: (node: { data: { name: string } }, term: string) => boolean + hasPendingCreate: boolean +} + +type MockTreeApi = { + deselectAll: () => void + state: { + nodes: { + drag: { + id: string | null + destinationParentId: string | null + destinationIndex: number | null + } + } + } + store: { + subscribe: (listener: () => void) => () => void + } + root: { + id: string + children: Array<{ id: string }> + } + dragDestinationIndex: number | null | undefined +} + +type CapturedTreeProps = { + onToggle: (id: string) => void + onSelect: (nodes: Array<{ id: string }>) => void + onActivate: (node: { data: { id: string, node_type: 'file' | 'folder' }, toggle: () => void }) => void + onMove: (args: { + dragIds: string[] + parentId: string | null + index: number + dragNodes: Array<{ id: string, data: { node_type: 'file' | 'folder' }, parent: { id: string, isRoot?: boolean } | null }> + parentNode: { id: string, children: Array<{ id: string }> } | undefined + }) => void + disableDrop: (args: { + parentNode: { id: string, data: { node_type: 'file' | 'folder' }, children: Array<{ id: string }> } + dragNodes: Array<{ id: string, data: { node_type: 'file' | 'folder' } }> + index: number + }) => boolean +} + +function createNode(overrides: Partial = {}): AppAssetTreeView { + return { + id: overrides.id ?? 'file-1', + node_type: overrides.node_type ?? 'file', + name: overrides.name ?? 'guide.md', + path: overrides.path ?? '/guide.md', + extension: overrides.extension ?? 'md', + size: overrides.size ?? 1, + children: overrides.children ?? [], + } +} + +function createTreeApiMock(): MockTreeApi { + return { + deselectAll: vi.fn(), + state: { + nodes: { + drag: { + id: null, + destinationParentId: null, + destinationIndex: null, + }, + }, + }, + store: { + subscribe: vi.fn(() => vi.fn()), + }, + root: { + id: 'root', + children: [], + }, + dragDestinationIndex: null, + } +} + +function createRootDropHandlersMock() { + return { + handleRootDragEnter: vi.fn(), + handleRootDragLeave: vi.fn(), + handleRootDragOver: vi.fn(), + handleRootDrop: vi.fn(), + resetRootDragCounter: vi.fn(), + } +} + +function createInlineCreateNodeMock(): MockInlineCreateNodeResult { + return { + treeNodes: [createNode()], + handleRename: vi.fn(), + searchMatch: vi.fn(() => true), + hasPendingCreate: false, + } +} + +const mocks = vi.hoisted(() => ({ + storeState: { + expandedFolderIds: new Set(), + activeTabId: null, + dragOverFolderId: null, + currentDragType: null, + fileTreeSearchTerm: '', + } as MockWorkflowState, + actions: { + toggleFolder: vi.fn(), + openTab: vi.fn(), + setSelectedNodeIds: vi.fn(), + clearSelection: vi.fn(), + setContextMenu: vi.fn(), + setDragInsertTarget: vi.fn(), + setFileTreeSearchTerm: vi.fn(), + } as MockWorkflowActions, + skillAssetTreeData: { + data: { children: [createNode()] }, + isLoading: false, + error: null, + dataUpdatedAt: 1, + } as MockAssetTreeHookResult, + inlineCreateNode: createInlineCreateNodeMock(), + rootDropHandlers: createRootDropHandlersMock(), + executeMoveNode: vi.fn(), + executeReorderNode: vi.fn(), + useSkillTreeCollaboration: vi.fn(), + useSkillShortcuts: vi.fn(), + useSyncTreeWithActiveTab: vi.fn(), + usePasteOperation: vi.fn(), + treeApi: createTreeApiMock(), + treeProps: null as CapturedTreeProps | null, + isMutating: 0, + containerSize: { height: 320 } as { height: number } | undefined, + isDescendantOf: vi.fn<(parentId: string, nodeId: string, treeChildren: AppAssetTreeView[]) => boolean>(() => false), +})) + +vi.mock('react-arborist', async () => { + const React = await vi.importActual('react') + + type MockTreeComponentProps = { + children?: ReactNode + } & Record + + const Tree = React.forwardRef((props: MockTreeComponentProps, ref: Ref) => { + mocks.treeProps = props as unknown as CapturedTreeProps + + if (typeof ref === 'function') + ref(mocks.treeApi) + else if (ref) + (ref as { current: unknown }).current = mocks.treeApi + + return
+ }) + + return { Tree } +}) + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { + ...actual, + useIsMutating: () => mocks.isMutating, + } +}) + +vi.mock('ahooks', () => ({ + useSize: () => mocks.containerSize, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => ({ + getState: () => mocks.actions, + }), +})) + +vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({ + useSkillAssetTreeData: () => mocks.skillAssetTreeData, +})) + +vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({ + useSkillTreeCollaboration: () => mocks.useSkillTreeCollaboration(), +})) + +vi.mock('../../hooks/file-tree/dnd/use-root-file-drop', () => ({ + useRootFileDrop: () => mocks.rootDropHandlers, +})) + +vi.mock('../../hooks/file-tree/interaction/use-inline-create-node', () => ({ + useInlineCreateNode: () => mocks.inlineCreateNode, +})) + +vi.mock('../../hooks/file-tree/interaction/use-skill-shortcuts', () => ({ + useSkillShortcuts: (args: unknown) => mocks.useSkillShortcuts(args), +})) + +vi.mock('../../hooks/file-tree/interaction/use-sync-tree-with-active-tab', () => ({ + useSyncTreeWithActiveTab: (args: unknown) => mocks.useSyncTreeWithActiveTab(args), +})) + +vi.mock('../../hooks/file-tree/operations/use-node-move', () => ({ + useNodeMove: () => ({ executeMoveNode: mocks.executeMoveNode }), +})) + +vi.mock('../../hooks/file-tree/operations/use-node-reorder', () => ({ + useNodeReorder: () => ({ executeReorderNode: mocks.executeReorderNode }), +})) + +vi.mock('../../hooks/file-tree/operations/use-paste-operation', () => ({ + usePasteOperation: (args: unknown) => mocks.usePasteOperation(args), +})) + +vi.mock('../../utils/tree-utils', () => ({ + isDescendantOf: (parentId: string, nodeId: string, treeChildren: AppAssetTreeView[]) => + mocks.isDescendantOf(parentId, nodeId, treeChildren), +})) + +vi.mock('./search-result-list', () => ({ + default: ({ searchTerm }: { searchTerm: string }) => ( +
{searchTerm}
+ ), +})) + +vi.mock('./drag-action-tooltip', () => ({ + default: ({ action }: { action: string }) => ( +
{action}
+ ), +})) + +vi.mock('./upload-status-tooltip', () => ({ + default: ({ fallback }: { fallback?: ReactNode }) => ( +
{fallback}
+ ), +})) + +vi.mock('./tree-context-menu', () => ({ + default: () =>
, +})) + +function getCapturedTreeProps(): CapturedTreeProps { + if (!mocks.treeProps) + throw new Error('Tree props were not captured') + return mocks.treeProps +} + +function getTreeDropZone(): HTMLElement { + const tree = screen.getByTestId('arborist-tree') + const dropZone = tree.parentElement + if (!dropZone) + throw new Error('Tree drop zone not found') + return dropZone +} + +function resetMockState() { + mocks.storeState.expandedFolderIds = new Set() + mocks.storeState.activeTabId = null + mocks.storeState.dragOverFolderId = null + mocks.storeState.currentDragType = null + mocks.storeState.fileTreeSearchTerm = '' + + mocks.skillAssetTreeData = { + data: { children: [createNode()] }, + isLoading: false, + error: null, + dataUpdatedAt: 1, + } + + mocks.inlineCreateNode = createInlineCreateNodeMock() + mocks.rootDropHandlers = createRootDropHandlersMock() + mocks.executeMoveNode = vi.fn() + mocks.executeReorderNode = vi.fn() + mocks.treeApi = createTreeApiMock() + mocks.treeProps = null + mocks.isMutating = 0 + mocks.containerSize = { height: 320 } + mocks.isDescendantOf = vi.fn(() => false) +} + +describe('FileTree', () => { + beforeEach(() => { + vi.clearAllMocks() + resetMockState() + }) + + describe('Tree states', () => { + it('should render loading state when tree data is loading', () => { + mocks.skillAssetTreeData.isLoading = true + mocks.skillAssetTreeData.data = undefined + + render() + + expect(screen.getByRole('status', { name: /appApi\.loading/i })).toBeInTheDocument() + }) + + it('should render error state when tree query fails', () => { + mocks.skillAssetTreeData.error = new Error('request failed') + + render() + + expect(screen.getByText('workflow.skillSidebar.loadError')).toBeInTheDocument() + }) + + it('should render empty state and root drop tip when tree has no children', () => { + mocks.skillAssetTreeData.data = { children: [] } + mocks.inlineCreateNode.treeNodes = [] + + render() + + expect(screen.getByText('workflow.skillSidebar.empty')).toBeInTheDocument() + expect(screen.getByText('workflow.skillSidebar.dropTip')).toBeInTheDocument() + }) + + it('should render search no result state and reset filter action', () => { + mocks.storeState.fileTreeSearchTerm = 'missing-keyword' + mocks.skillAssetTreeData.data = { + children: [createNode({ name: 'existing.txt', extension: 'txt', path: '/existing.txt' })], + } + mocks.inlineCreateNode.treeNodes = mocks.skillAssetTreeData.data.children + + render() + + expect(screen.getByText('workflow.skillSidebar.searchNoResults')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.resetFilter/i })) + + expect(mocks.actions.setFileTreeSearchTerm).toHaveBeenCalledWith('') + }) + + it('should render search result list when search term has matches', () => { + mocks.storeState.fileTreeSearchTerm = 'guide' + mocks.skillAssetTreeData.data = { + children: [createNode({ name: 'guide.md' })], + } + mocks.inlineCreateNode.treeNodes = mocks.skillAssetTreeData.data.children + + render() + + expect(screen.getByTestId('search-result-list')).toHaveTextContent('guide') + expect(screen.queryByTestId('arborist-tree')).not.toBeInTheDocument() + }) + + it('should render normal tree view with root drag highlight and drag action tooltip', () => { + mocks.storeState.dragOverFolderId = ROOT_ID + mocks.storeState.currentDragType = 'move' + mocks.isMutating = 1 + + render() + + const treeContainer = document.querySelector('[data-skill-tree-container]') + expect(treeContainer).toHaveClass('pointer-events-none') + + const dropZone = getTreeDropZone() + expect(dropZone).toHaveClass('bg-state-accent-hover') + expect(screen.getByTestId('drag-action-tooltip')).toHaveTextContent('move') + expect(screen.queryByTestId('upload-status-tooltip')).not.toBeInTheDocument() + expect(screen.getByTestId('tree-context-menu')).toBeInTheDocument() + }) + }) + + describe('Container interactions', () => { + it('should deselect tree and clear store selection when blank area is clicked', () => { + render() + + fireEvent.click(getTreeDropZone()) + + expect(mocks.treeApi.deselectAll).toHaveBeenCalledTimes(1) + expect(mocks.actions.clearSelection).toHaveBeenCalledTimes(1) + }) + + it('should open blank context menu with pointer position on right click', () => { + render() + + fireEvent.contextMenu(getTreeDropZone(), { clientX: 64, clientY: 128 }) + + expect(mocks.treeApi.deselectAll).toHaveBeenCalledTimes(1) + expect(mocks.actions.clearSelection).toHaveBeenCalledTimes(1) + expect(mocks.actions.setContextMenu).toHaveBeenCalledWith({ + top: 128, + left: 64, + type: CONTEXT_MENU_TYPE.BLANK, + }) + }) + + it('should forward root drag events to root file drop handlers', () => { + render() + + const dropZone = getTreeDropZone() + fireEvent.dragEnter(dropZone) + fireEvent.dragOver(dropZone) + fireEvent.dragLeave(dropZone) + fireEvent.drop(dropZone) + + expect(mocks.rootDropHandlers.handleRootDragEnter).toHaveBeenCalledTimes(1) + expect(mocks.rootDropHandlers.handleRootDragOver).toHaveBeenCalledTimes(1) + expect(mocks.rootDropHandlers.handleRootDragLeave).toHaveBeenCalledTimes(1) + expect(mocks.rootDropHandlers.handleRootDrop).toHaveBeenCalledTimes(1) + }) + }) + + describe('Tree callbacks', () => { + it('should open file tab when file node is activated and toggle folder node', () => { + render() + const treeProps = getCapturedTreeProps() + + const folderToggle = vi.fn() + treeProps.onActivate({ + data: { id: 'file-9', node_type: 'file' }, + toggle: vi.fn(), + }) + treeProps.onActivate({ + data: { id: 'folder-9', node_type: 'folder' }, + toggle: folderToggle, + }) + + expect(mocks.actions.openTab).toHaveBeenCalledWith('file-9', { pinned: true }) + expect(folderToggle).toHaveBeenCalledTimes(1) + }) + + it('should update expanded and selected ids from tree callbacks', () => { + render() + const treeProps = getCapturedTreeProps() + + treeProps.onToggle('folder-1') + treeProps.onSelect([{ id: 'file-1' }, { id: 'file-2' }]) + + expect(mocks.actions.toggleFolder).toHaveBeenCalledWith('folder-1') + expect(mocks.actions.setSelectedNodeIds).toHaveBeenCalledWith(['file-1', 'file-2']) + }) + + it('should disable drop for invalid targets and allow valid folder drops', () => { + render() + const treeProps = getCapturedTreeProps() + + const dropToFile = treeProps.disableDrop({ + parentNode: { id: 'file-parent', data: { node_type: 'file' }, children: [] }, + dragNodes: [{ id: 'drag-1', data: { node_type: 'file' } }], + index: 0, + }) + const dropToSelf = treeProps.disableDrop({ + parentNode: { id: 'folder-self', data: { node_type: 'folder' }, children: [] }, + dragNodes: [{ id: 'folder-self', data: { node_type: 'folder' } }], + index: 0, + }) + + mocks.isDescendantOf = vi.fn(() => true) + const circularDrop = treeProps.disableDrop({ + parentNode: { id: 'folder-child', data: { node_type: 'folder' }, children: [] }, + dragNodes: [{ id: 'folder-parent', data: { node_type: 'folder' } }], + index: 0, + }) + + mocks.isDescendantOf = vi.fn(() => false) + const validDrop = treeProps.disableDrop({ + parentNode: { id: 'folder-target', data: { node_type: 'folder' }, children: [] }, + dragNodes: [{ id: 'file-3', data: { node_type: 'file' } }], + index: 0, + }) + + expect(dropToFile).toBe(true) + expect(dropToSelf).toBe(true) + expect(circularDrop).toBe(true) + expect(validDrop).toBe(false) + }) + + it('should reorder node when drag is insert-line within same parent', () => { + mocks.treeApi.dragDestinationIndex = 2 + render() + const treeProps = getCapturedTreeProps() + + treeProps.onMove({ + dragIds: ['file-b'], + parentId: 'folder-1', + index: 2, + dragNodes: [{ + id: 'file-b', + data: { node_type: 'file' }, + parent: { id: 'folder-1', isRoot: false }, + }], + parentNode: { + id: 'folder-1', + children: [{ id: 'file-a' }, { id: 'file-b' }, { id: 'file-c' }], + }, + }) + + expect(mocks.executeReorderNode).toHaveBeenCalledWith('file-b', 'file-a') + expect(mocks.executeMoveNode).not.toHaveBeenCalled() + }) + + it('should move node when destination parent differs or insert line is absent', () => { + mocks.treeApi.dragDestinationIndex = null + render() + const treeProps = getCapturedTreeProps() + + treeProps.onMove({ + dragIds: ['file-1'], + parentId: 'folder-2', + index: 0, + dragNodes: [{ + id: 'file-1', + data: { node_type: 'file' }, + parent: { id: 'folder-1', isRoot: false }, + }], + parentNode: { + id: 'folder-2', + children: [{ id: 'file-4' }], + }, + }) + + expect(mocks.executeMoveNode).toHaveBeenCalledWith('file-1', 'folder-2') + expect(mocks.executeReorderNode).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx new file mode 100644 index 0000000000..037e728fd2 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx @@ -0,0 +1,133 @@ +import type { MenuItemProps } from './menu-item' +import { fireEvent, render, screen } from '@testing-library/react' +import MenuItem from './menu-item' + +const MockIcon = (props: React.SVGProps) => + +const defaultProps: MenuItemProps = { + icon: MockIcon, + label: 'Rename', + onClick: vi.fn(), +} + +const renderMenuItem = (overrides: Partial = {}) => { + const props = { ...defaultProps, ...overrides } + return { + ...render(), + onClick: props.onClick, + } +} + +describe('MenuItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Menu item should render its interactive label and style variants. + describe('Rendering', () => { + it('should render a button with the provided label', () => { + // Arrange + renderMenuItem() + + // Assert + expect(screen.getByRole('button', { name: /rename/i })).toBeInTheDocument() + }) + + it('should apply destructive variant styles when variant is destructive', () => { + // Arrange + renderMenuItem({ variant: 'destructive', label: 'Delete' }) + + // Act + const button = screen.getByRole('button', { name: /delete/i }) + + // Assert + expect(button).toHaveClass('group') + expect(button).toHaveClass('hover:bg-state-destructive-hover') + }) + }) + + // Optional props should alter the visible content. + describe('Props', () => { + it('should render keyboard shortcut hints when kbd has values', () => { + // Arrange + renderMenuItem({ kbd: ['k'] }) + + // Assert + expect(screen.getByText('k')).toBeInTheDocument() + }) + + it('should not render keyboard shortcut hints when kbd is empty', () => { + // Arrange + renderMenuItem({ kbd: [] }) + + // Assert + expect(screen.queryByText('k')).not.toBeInTheDocument() + }) + + it('should show tooltip content when hovering the tooltip trigger', async () => { + // Arrange + const tooltipText = 'Show help' + const { container } = renderMenuItem({ tooltip: tooltipText }) + const tooltipIcon = container.querySelector('svg.text-text-quaternary') + + // Act + expect(tooltipIcon).toBeTruthy() + fireEvent.mouseEnter(tooltipIcon!) + + // Assert + expect(await screen.findByText(tooltipText)).toBeInTheDocument() + }) + }) + + // Click handling should call actions without leaking events upward. + describe('Interactions', () => { + it('should call onClick and stop click propagation when button is clicked', () => { + // Arrange + const outerClick = vi.fn() + const onClick = vi.fn() + render( +
+ +
, + ) + + // Act + fireEvent.click(screen.getByRole('button', { name: /rename/i })) + + // Assert + expect(onClick).toHaveBeenCalledTimes(1) + expect(outerClick).not.toHaveBeenCalled() + }) + + it('should not trigger onClick when tooltip icon is clicked', () => { + // Arrange + const onClick = vi.fn() + const { container } = renderMenuItem({ onClick, tooltip: 'Help' }) + const tooltipIcon = container.querySelector('svg.text-text-quaternary') + + // Act + expect(tooltipIcon).toBeTruthy() + fireEvent.click(tooltipIcon!) + + // Assert + expect(onClick).not.toHaveBeenCalled() + }) + }) + + // Disabled state should block interaction. + describe('Edge cases', () => { + it('should disable the button and ignore click when disabled is true', () => { + // Arrange + const onClick = vi.fn() + renderMenuItem({ onClick, disabled: true }) + const button = screen.getByRole('button', { name: /rename/i }) + + // Act + fireEvent.click(button) + + // Assert + expect(button).toBeDisabled() + expect(onClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx new file mode 100644 index 0000000000..f3913921fd --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx @@ -0,0 +1,252 @@ +import type { ReactElement, RefObject } from 'react' +import type { NodeApi, TreeApi } from 'react-arborist' +import type { TreeNodeData } from '../../type' +import { fireEvent, render, screen } from '@testing-library/react' +import { NODE_MENU_TYPE } from '../../constants' +import NodeMenu from './node-menu' + +type MockWorkflowState = { + selectedNodeIds: Set + hasClipboard: () => boolean +} + +type MockFileOperations = { + fileInputRef: RefObject + folderInputRef: RefObject + showDeleteConfirm: boolean + isLoading: boolean + isDeleting: boolean + handleDownload: () => void + handleNewFile: () => void + handleNewFolder: () => void + handleFileChange: () => void + handleFolderChange: () => void + handleRename: () => void + handleDeleteClick: () => void + handleDeleteConfirm: () => void + handleDeleteCancel: () => void +} + +type RenderNodeMenuProps = { + type?: 'root' | 'folder' | 'file' + nodeId?: string + onClose?: () => void + treeRef?: RefObject | null> + node?: NodeApi +} + +function createFileOperationsMock(): MockFileOperations { + return ({ + fileInputRef: { current: null }, + folderInputRef: { current: null }, + showDeleteConfirm: false, + isLoading: false, + isDeleting: false, + handleDownload: vi.fn(), + handleNewFile: vi.fn(), + handleNewFolder: vi.fn(), + handleFileChange: vi.fn(), + handleFolderChange: vi.fn(), + handleRename: vi.fn(), + handleDeleteClick: vi.fn(), + handleDeleteConfirm: vi.fn(), + handleDeleteCancel: vi.fn(), + }) +} + +const mocks = vi.hoisted(() => ({ + storeState: { + selectedNodeIds: new Set(), + hasClipboard: () => false, + } as MockWorkflowState, + cutNodes: vi.fn(), + fileOperations: createFileOperationsMock(), +})) + +vi.mock('next/dynamic', () => ({ + default: () => { + const MockImportSkillModal = ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }): ReactElement | null => { + if (!isOpen) + return null + + return ( +
+ +
+ ) + } + + return MockImportSkillModal + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => ({ + getState: () => ({ + cutNodes: mocks.cutNodes, + }), + }), +})) + +vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({ + useFileOperations: () => mocks.fileOperations, +})) + +const renderNodeMenu = ({ + type = NODE_MENU_TYPE.FOLDER, + nodeId = 'node-1', + onClose = vi.fn(), + treeRef, + node, +}: RenderNodeMenuProps = {}) => { + render( + , + ) + + return { onClose } +} + +describe('NodeMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storeState.selectedNodeIds = new Set() + mocks.storeState.hasClipboard = () => false + mocks.fileOperations = createFileOperationsMock() + }) + + describe('Rendering', () => { + it('should render root folder actions and hide file-only actions', () => { + renderNodeMenu({ type: NODE_MENU_TYPE.ROOT }) + + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.importSkills/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.rename/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.delete/i })).not.toBeInTheDocument() + }) + + it('should render file actions and hide folder-only actions', () => { + renderNodeMenu({ type: NODE_MENU_TYPE.FILE }) + + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.download/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.rename/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.delete/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.paste/i })).not.toBeInTheDocument() + }) + + it('should disable menu actions when file operations are loading', () => { + mocks.fileOperations.isLoading = true + renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER }) + + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })).toBeDisabled() + }) + }) + + describe('Menu actions', () => { + it('should trigger create operations when clicking new file and new folder', () => { + renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })) + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })) + + expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1) + expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1) + }) + + it('should trigger hidden file and folder input clicks from upload actions', () => { + const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click') + renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })) + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i })) + + expect(clickSpy).toHaveBeenCalledTimes(2) + }) + + it('should cut selected nodes and close menu when cut is clicked', () => { + mocks.storeState.selectedNodeIds = new Set(['file-1', 'file-2']) + const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'fallback-id' }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i })) + + expect(mocks.cutNodes).toHaveBeenCalledTimes(1) + expect(mocks.cutNodes).toHaveBeenCalledWith(['file-1', 'file-2']) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should cut current node id when no multi-selection exists', () => { + const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'file-3' }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i })) + + expect(mocks.cutNodes).toHaveBeenCalledWith(['file-3']) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should dispatch paste event and close when paste is clicked', () => { + mocks.storeState.hasClipboard = () => true + const pasteListener = vi.fn() + window.addEventListener('skill:paste', pasteListener) + const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.paste/i })) + + expect(pasteListener).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + window.removeEventListener('skill:paste', pasteListener) + }) + + it('should call download, rename, and delete handlers for file menu actions', () => { + renderNodeMenu({ type: NODE_MENU_TYPE.FILE }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.download/i })) + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.rename/i })) + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.delete/i })) + + expect(mocks.fileOperations.handleDownload).toHaveBeenCalledTimes(1) + expect(mocks.fileOperations.handleRename).toHaveBeenCalledTimes(1) + expect(mocks.fileOperations.handleDeleteClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('Dialogs', () => { + it('should open and close import modal from root menu', () => { + renderNodeMenu({ type: NODE_MENU_TYPE.ROOT }) + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.importSkills/i })) + expect(screen.getByTestId('import-skill-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /close-import-modal/i })) + expect(screen.queryByTestId('import-skill-modal')).not.toBeInTheDocument() + }) + + it('should render delete confirmation content for files and forward confirm callbacks', () => { + mocks.fileOperations.showDeleteConfirm = true + renderNodeMenu({ type: NODE_MENU_TYPE.FILE }) + + expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument() + expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i })) + + expect(mocks.fileOperations.handleDeleteConfirm).toHaveBeenCalledTimes(1) + expect(mocks.fileOperations.handleDeleteCancel).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/search-result-list.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/search-result-list.spec.tsx new file mode 100644 index 0000000000..f5f2561d53 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/search-result-list.spec.tsx @@ -0,0 +1,171 @@ +import type { AppAssetTreeView } from '@/types/app-asset' +import { fireEvent, render, screen } from '@testing-library/react' +import SearchResultList from './search-result-list' + +type MockWorkflowState = { + activeTabId: string | null +} + +const mocks = vi.hoisted(() => ({ + storeState: { + activeTabId: null, + } as MockWorkflowState, + clearArtifactSelection: vi.fn(), + openTab: vi.fn(), + revealFile: vi.fn(), + setFileTreeSearchTerm: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => ({ + getState: () => ({ + clearArtifactSelection: mocks.clearArtifactSelection, + openTab: mocks.openTab, + revealFile: mocks.revealFile, + setFileTreeSearchTerm: mocks.setFileTreeSearchTerm, + }), + }), +})) + +const createNode = (overrides: Partial = {}): AppAssetTreeView => ({ + id: 'node-1', + node_type: 'file', + name: 'readme.md', + path: '/readme.md', + extension: 'md', + size: 10, + children: [], + ...overrides, +}) + +const setActiveTabId = (activeTabId: string | null) => { + mocks.storeState.activeTabId = activeTabId +} + +describe('SearchResultList', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + setActiveTabId(null) + }) + + // Search results should render only matching items with path hints when available. + describe('Rendering', () => { + it('should render matching nodes and parent path when search term matches', () => { + const treeChildren = [ + createNode({ id: 'file-1', name: 'readme.md', path: '/src/readme.md' }), + createNode({ id: 'file-2', name: 'guide.txt', path: '/guide.txt', extension: 'txt' }), + ] + + render() + + expect(screen.getByText('readme.md')).toBeInTheDocument() + expect(screen.queryByText('guide.txt')).not.toBeInTheDocument() + expect(screen.getByText('src')).toBeInTheDocument() + }) + + it('should render active row style when node id matches active tab id', () => { + setActiveTabId('file-1') + const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })] + + render() + + const row = screen.getByText('readme.md').closest('[role="button"]') + expect(row).toHaveClass('bg-state-base-active') + }) + + it('should render no rows when search term is empty', () => { + const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })] + + render() + + expect(screen.queryByText('readme.md')).not.toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + }) + + // File and folder actions should dispatch the correct store operations. + describe('Interactions', () => { + it('should open file preview when file row is single clicked', () => { + vi.useFakeTimers() + const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })] + + render() + + const row = screen.getByText('readme.md').closest('[role="button"]') + if (!row) + throw new Error('Expected row element for readme.md') + + fireEvent.click(row) + expect(mocks.openTab).not.toHaveBeenCalled() + + vi.runAllTimers() + + expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledWith('file-1', { pinned: false }) + }) + + it('should open file pinned when file row is double clicked', () => { + vi.useFakeTimers() + const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })] + + render() + + const row = screen.getByText('readme.md').closest('[role="button"]') + if (!row) + throw new Error('Expected row element for readme.md') + + fireEvent.doubleClick(row) + vi.runAllTimers() + + expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledWith('file-1', { pinned: true }) + }) + + it('should reveal folder and clear search when folder row is clicked', () => { + const treeChildren = [ + createNode({ + id: 'folder-1', + node_type: 'folder', + name: 'src', + path: '/src', + extension: '', + children: [createNode({ id: 'file-1', name: 'readme.md', path: '/src/readme.md' })], + }), + ] + + render() + + const row = screen.getByText('src').closest('[role="button"]') + if (!row) + throw new Error('Expected row element for src') + + fireEvent.click(row) + + expect(mocks.revealFile).toHaveBeenCalledTimes(1) + expect(mocks.revealFile).toHaveBeenCalledWith(['folder-1']) + expect(mocks.setFileTreeSearchTerm).toHaveBeenCalledTimes(1) + expect(mocks.setFileTreeSearchTerm).toHaveBeenCalledWith('') + expect(mocks.openTab).not.toHaveBeenCalled() + }) + + it('should open file pinned when Enter key is pressed on a file row', () => { + const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })] + + render() + + const row = screen.getByText('readme.md').closest('[role="button"]') + if (!row) + throw new Error('Expected row element for readme.md') + + fireEvent.keyDown(row, { key: 'Enter' }) + + expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledWith('file-1', { pinned: true }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx new file mode 100644 index 0000000000..66b4dc2fce --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx @@ -0,0 +1,174 @@ +import type { ReactNode } from 'react' +import type { ContextMenuState } from '@/app/components/workflow/store/workflow/skill-editor/types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { CONTEXT_MENU_TYPE, NODE_MENU_TYPE, ROOT_ID } from '../../constants' +import TreeContextMenu from './tree-context-menu' + +type MockWorkflowState = { + contextMenu: ContextMenuState | null +} + +type FloatingOptions = { + open: boolean + onOpenChange: (open: boolean) => void + position: { + x: number + y: number + } +} + +const mocks = vi.hoisted(() => ({ + storeState: { + contextMenu: null, + } as MockWorkflowState, + setContextMenu: vi.fn(), + floatingOptions: null as FloatingOptions | null, + getFloatingProps: vi.fn(() => ({ 'data-floating-props': 'applied' })), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => ({ + getState: () => ({ + setContextMenu: mocks.setContextMenu, + }), + }), +})) + +vi.mock('@floating-ui/react', () => ({ + FloatingPortal: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem/use-context-menu-floating', () => ({ + useContextMenuFloating: (options: FloatingOptions) => { + mocks.floatingOptions = options + return { + refs: { + setFloating: vi.fn(), + }, + floatingStyles: { + left: `${options.position.x}px`, + top: `${options.position.y}px`, + }, + getFloatingProps: mocks.getFloatingProps, + isPositioned: true, + } + }, +})) + +vi.mock('./node-menu', () => ({ + default: ({ + type, + nodeId, + onClose, + }: { + type: string + nodeId?: string + onClose: () => void + }) => ( +
+ +
+ ), +})) + +const setContextMenuState = (contextMenu: ContextMenuState | null) => { + mocks.storeState.contextMenu = contextMenu +} + +describe('TreeContextMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.floatingOptions = null + setContextMenuState(null) + }) + + // Rendering should depend on context-menu state in the workflow store. + describe('Rendering', () => { + it('should render nothing when context menu state is null', () => { + render() + + expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument() + expect(screen.queryByTestId('floating-portal')).not.toBeInTheDocument() + }) + + it('should render file menu with node id when node context is on a file', () => { + setContextMenuState({ + top: 40, + left: 24, + type: CONTEXT_MENU_TYPE.NODE, + nodeId: 'file-1', + isFolder: false, + }) + + render() + + const menu = screen.getByTestId('node-menu') + expect(menu).toHaveAttribute('data-type', NODE_MENU_TYPE.FILE) + expect(menu).toHaveAttribute('data-node-id', 'file-1') + expect(menu.parentElement).toHaveStyle({ + left: '24px', + top: '40px', + visibility: 'visible', + }) + expect(mocks.getFloatingProps).toHaveBeenCalledTimes(1) + expect(mocks.floatingOptions?.open).toBe(true) + expect(mocks.floatingOptions?.position).toEqual({ x: 24, y: 40 }) + }) + + it('should render root menu with root id when context is blank area', () => { + setContextMenuState({ + top: 100, + left: 80, + type: CONTEXT_MENU_TYPE.BLANK, + }) + + render() + + const menu = screen.getByTestId('node-menu') + expect(menu).toHaveAttribute('data-type', NODE_MENU_TYPE.ROOT) + expect(menu).toHaveAttribute('data-node-id', ROOT_ID) + }) + }) + + // Close events from floating layer and menu should reset store context menu. + describe('Closing behavior', () => { + it('should clear context menu when floating layer requests close', () => { + setContextMenuState({ + top: 12, + left: 16, + type: CONTEXT_MENU_TYPE.NODE, + nodeId: 'file-1', + isFolder: false, + }) + + render() + + act(() => { + mocks.floatingOptions?.onOpenChange(false) + }) + + expect(mocks.setContextMenu).toHaveBeenCalledTimes(1) + expect(mocks.setContextMenu).toHaveBeenCalledWith(null) + }) + + it('should clear context menu when node menu closes', () => { + setContextMenuState({ + top: 12, + left: 16, + type: CONTEXT_MENU_TYPE.NODE, + nodeId: 'file-1', + isFolder: false, + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'close' })) + + expect(mocks.setContextMenu).toHaveBeenCalledTimes(1) + expect(mocks.setContextMenu).toHaveBeenCalledWith(null) + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-guide-lines.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-guide-lines.spec.tsx new file mode 100644 index 0000000000..578c4ad7c5 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/tree-guide-lines.spec.tsx @@ -0,0 +1,51 @@ +import { render } from '@testing-library/react' +import TreeGuideLines from './tree-guide-lines' + +describe('TreeGuideLines', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior for root-level and nested nodes. + describe('Rendering', () => { + it('should render nothing when level is 0', () => { + // Arrange + const { container } = render() + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render one guideline per level with default spacing', () => { + // Arrange + const { container } = render() + + // Act + const guides = container.querySelectorAll('.border-divider-subtle') + + // Assert + expect(guides).toHaveLength(3) + expect(guides[0]).toHaveStyle({ left: '10px' }) + expect(guides[1]).toHaveStyle({ left: '30px' }) + expect(guides[2]).toHaveStyle({ left: '50px' }) + }) + }) + + // Custom spacing props should influence guideline position. + describe('Props', () => { + it('should apply custom indentSize and lineOffset when provided', () => { + // Arrange + const { container } = render( + , + ) + + // Act + const guides = container.querySelectorAll('.border-divider-subtle') + + // Assert + expect(guides).toHaveLength(2) + expect(guides[0]).toHaveStyle({ left: '20px' }) + expect(guides[1]).toHaveStyle({ left: '44px' }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-node-icon.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-node-icon.spec.tsx new file mode 100644 index 0000000000..b6c664e7a4 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/tree-node-icon.spec.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { TreeNodeIcon } from './tree-node-icon' + +const mocks = vi.hoisted(() => ({ + getFileIconType: vi.fn(() => 'document'), +})) + +vi.mock('../../utils/file-utils', () => ({ + getFileIconType: mocks.getFileIconType, +})) + +describe('TreeNodeIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Folder nodes should render toggle affordances and icon state. + describe('Folder nodes', () => { + it('should render an open-folder icon when folder is expanded', () => { + // Arrange + render( + , + ) + + // Act + const toggleButton = screen.getByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i }) + const icon = toggleButton.querySelector('svg') + + // Assert + expect(toggleButton).toBeInTheDocument() + expect(icon).toHaveClass('text-text-accent') + expect(mocks.getFileIconType).not.toHaveBeenCalled() + }) + + it('should render a closed-folder icon when folder is collapsed', () => { + // Arrange + render( + , + ) + + // Act + const toggleButton = screen.getByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i }) + const icon = toggleButton.querySelector('svg') + + // Assert + expect(icon).toHaveClass('text-text-secondary') + }) + + it('should call onToggle when folder icon button is clicked', () => { + // Arrange + const onToggle = vi.fn() + render( + , + ) + + // Act + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i })) + + // Assert + expect(onToggle).toHaveBeenCalledTimes(1) + }) + }) + + // File nodes should resolve icon type and optionally show dirty indicator. + describe('File nodes', () => { + it('should resolve file icon type and show dirty marker when file is dirty', () => { + // Arrange + const { container } = render( + , + ) + + // Act + const dirtyMarker = container.querySelector('.bg-text-warning-secondary') + + // Assert + expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i })).not.toBeInTheDocument() + expect(mocks.getFileIconType).toHaveBeenCalledWith('guide.md', 'md') + expect(dirtyMarker).toBeInTheDocument() + }) + + it('should hide dirty marker when file is clean', () => { + // Arrange + const { container } = render( + , + ) + + // Act + const dirtyMarker = container.querySelector('.bg-text-warning-secondary') + + // Assert + expect(mocks.getFileIconType).toHaveBeenCalledWith('README', undefined) + expect(dirtyMarker).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx new file mode 100644 index 0000000000..1c228cef26 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx @@ -0,0 +1,339 @@ +import type { NodeApi, NodeRendererProps, TreeApi } from 'react-arborist' +import type { TreeNodeData } from '../../type' +import { fireEvent, render, screen } from '@testing-library/react' +import TreeNode from './tree-node' + +type MockWorkflowSelectorState = { + dirtyContents: Set + contextMenu: { + nodeId?: string + } | null + isCutNode: (nodeId: string) => boolean +} + +type NodeState = { + id: string + nodeType: 'file' | 'folder' + name: string + extension: string + isSelected: boolean + isOpen: boolean + isDragging: boolean + willReceiveDrop: boolean + isEditing: boolean + level: number +} + +const workflowState = vi.hoisted(() => ({ + dirtyContents: new Set(), + cutNodeIds: new Set(), + contextMenuNodeId: null as string | null, + dragOverFolderId: null as string | null, +})) + +const storeActions = vi.hoisted(() => ({ + setCurrentDragType: vi.fn(), + setDragOverFolderId: vi.fn(), +})) + +const handlerMocks = vi.hoisted(() => ({ + handleClick: vi.fn(), + handleDoubleClick: vi.fn(), + handleToggle: vi.fn(), + handleContextMenu: vi.fn(), + handleKeyDown: vi.fn(), +})) + +const dndMocks = vi.hoisted(() => ({ + isDragOver: false, + isBlinking: false, + onDragEnter: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + onDragLeave: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowSelectorState) => unknown) => selector({ + dirtyContents: workflowState.dirtyContents, + contextMenu: workflowState.contextMenuNodeId + ? { nodeId: workflowState.contextMenuNodeId } + : null, + isCutNode: (nodeId: string) => workflowState.cutNodeIds.has(nodeId), + }), + useWorkflowStore: () => ({ + getState: () => ({ + dragOverFolderId: workflowState.dragOverFolderId, + setCurrentDragType: (type: 'move' | null) => { + storeActions.setCurrentDragType(type) + }, + setDragOverFolderId: (folderId: string | null) => { + workflowState.dragOverFolderId = folderId + storeActions.setDragOverFolderId(folderId) + }, + }), + }), +})) + +vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({ + useTreeNodeHandlers: () => ({ + handleClick: handlerMocks.handleClick, + handleDoubleClick: handlerMocks.handleDoubleClick, + handleToggle: handlerMocks.handleToggle, + handleContextMenu: handlerMocks.handleContextMenu, + handleKeyDown: handlerMocks.handleKeyDown, + }), +})) + +vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({ + useFolderFileDrop: () => ({ + isDragOver: dndMocks.isDragOver, + isBlinking: dndMocks.isBlinking, + dragHandlers: { + onDragEnter: dndMocks.onDragEnter, + onDragOver: dndMocks.onDragOver, + onDrop: dndMocks.onDrop, + onDragLeave: dndMocks.onDragLeave, + }, + }), +})) + +vi.mock('./node-menu', () => ({ + default: ({ type, onClose }: { type: string, onClose: () => void }) => ( +
+ +
+ ), +})) + +const createNode = (overrides: Partial = {}): NodeApi => { + const resolved: NodeState = { + id: overrides.id ?? 'file-1', + nodeType: overrides.nodeType ?? 'file', + name: overrides.name ?? 'readme.md', + extension: overrides.extension ?? 'md', + isSelected: overrides.isSelected ?? false, + isOpen: overrides.isOpen ?? false, + isDragging: overrides.isDragging ?? false, + willReceiveDrop: overrides.willReceiveDrop ?? false, + isEditing: overrides.isEditing ?? false, + level: overrides.level ?? 0, + } + + return { + data: { + id: resolved.id, + node_type: resolved.nodeType, + name: resolved.name, + path: `/${resolved.name}`, + extension: resolved.nodeType === 'folder' ? '' : resolved.extension, + size: 0, + children: [], + }, + isSelected: resolved.isSelected, + isOpen: resolved.isOpen, + isDragging: resolved.isDragging, + willReceiveDrop: resolved.willReceiveDrop, + isEditing: resolved.isEditing, + level: resolved.level, + } as unknown as NodeApi +} + +const buildProps = (nodeOverrides: Partial = {}): NodeRendererProps & { + treeChildren: TreeNodeData[] +} => ({ + node: createNode(nodeOverrides), + style: {}, + tree: {} as TreeApi, + dragHandle: vi.fn(), + treeChildren: [], +}) + +describe('TreeNode', () => { + beforeEach(() => { + vi.clearAllMocks() + + workflowState.dirtyContents.clear() + workflowState.cutNodeIds.clear() + workflowState.contextMenuNodeId = null + workflowState.dragOverFolderId = null + + dndMocks.isDragOver = false + dndMocks.isBlinking = false + }) + + // Core rendering should reflect selection, folder expansion, and store-driven visual states. + describe('Rendering', () => { + it('should render file node with context-menu highlight and action button label', () => { + workflowState.contextMenuNodeId = 'file-1' + const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' }) + + render() + + const treeItem = screen.getByRole('treeitem') + expect(treeItem).toHaveAttribute('aria-selected', 'false') + expect(treeItem).not.toHaveAttribute('aria-expanded') + expect(treeItem).toHaveClass('bg-state-base-hover') + expect(screen.getByText('readme.md')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i })).toBeInTheDocument() + }) + + it('should render selected open folder with folder expansion aria state', () => { + const props = buildProps({ + id: 'folder-1', + name: 'src', + nodeType: 'folder', + isSelected: true, + isOpen: true, + }) + + render() + + const treeItem = screen.getByRole('treeitem') + expect(treeItem).toHaveAttribute('aria-selected', 'true') + expect(treeItem).toHaveAttribute('aria-expanded', 'true') + expect(treeItem).toHaveClass('bg-state-base-active') + }) + + it('should apply drag-over, blinking, and cut styles when states are active', () => { + dndMocks.isDragOver = true + dndMocks.isBlinking = true + workflowState.cutNodeIds.add('folder-1') + const props = buildProps({ + id: 'folder-1', + nodeType: 'folder', + name: 'src', + }) + + render() + + const treeItem = screen.getByRole('treeitem') + expect(treeItem).toHaveClass('ring-state-accent-solid') + expect(treeItem).toHaveClass('animate-drag-blink') + expect(treeItem).toHaveClass('opacity-50') + }) + }) + + // User interactions on the node surface should forward to handler hooks and DnD hooks. + describe('Event wiring', () => { + it('should call click and double-click handlers from main content interactions', () => { + const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' }) + + render() + + const label = screen.getByText('readme.md') + fireEvent.click(label) + fireEvent.doubleClick(label) + + expect(handlerMocks.handleClick).toHaveBeenCalled() + expect(handlerMocks.handleDoubleClick).toHaveBeenCalled() + }) + + it('should call keyboard and context-menu handlers on tree item', () => { + const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' }) + + render() + + const treeItem = screen.getByRole('treeitem') + fireEvent.keyDown(treeItem, { key: 'Enter' }) + fireEvent.contextMenu(treeItem) + + expect(handlerMocks.handleKeyDown).toHaveBeenCalledTimes(1) + expect(handlerMocks.handleContextMenu).toHaveBeenCalledTimes(1) + }) + + it('should attach folder drag handlers only when node is a folder', () => { + const folderProps = buildProps({ id: 'folder-1', name: 'src', nodeType: 'folder' }) + const { rerender } = render() + + const folderTreeItem = screen.getByRole('treeitem') + fireEvent.dragEnter(folderTreeItem) + fireEvent.dragOver(folderTreeItem) + fireEvent.drop(folderTreeItem) + fireEvent.dragLeave(folderTreeItem) + + expect(dndMocks.onDragEnter).toHaveBeenCalledTimes(1) + expect(dndMocks.onDragOver).toHaveBeenCalledTimes(1) + expect(dndMocks.onDrop).toHaveBeenCalledTimes(1) + expect(dndMocks.onDragLeave).toHaveBeenCalledTimes(1) + + vi.clearAllMocks() + + const fileProps = buildProps({ id: 'file-2', name: 'guide.md', nodeType: 'file' }) + rerender() + + const fileTreeItem = screen.getByRole('treeitem') + fireEvent.dragEnter(fileTreeItem) + fireEvent.dragOver(fileTreeItem) + fireEvent.drop(fileTreeItem) + fireEvent.dragLeave(fileTreeItem) + + expect(dndMocks.onDragEnter).not.toHaveBeenCalled() + expect(dndMocks.onDragOver).not.toHaveBeenCalled() + expect(dndMocks.onDrop).not.toHaveBeenCalled() + expect(dndMocks.onDragLeave).not.toHaveBeenCalled() + }) + + it('should open and close dropdown menu when more actions button is toggled', () => { + const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' }) + + render() + + expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i })) + + expect(screen.getByTestId('node-menu')).toHaveAttribute('data-type', 'file') + + fireEvent.click(screen.getByRole('button', { name: 'close-menu' })) + + expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument() + }) + }) + + // Effects should synchronize external drag status transitions into workflow store state. + describe('Drag state synchronization effects', () => { + it('should set drag type on drag start and clear drag state on drag end', () => { + const initialProps = buildProps({ id: 'file-1', nodeType: 'file', isDragging: false }) + const { rerender } = render() + + const draggingProps = buildProps({ id: 'file-1', nodeType: 'file', isDragging: true }) + rerender() + + expect(storeActions.setCurrentDragType).toHaveBeenCalledWith('move') + + const notDraggingProps = buildProps({ id: 'file-1', nodeType: 'file', isDragging: false }) + rerender() + + expect(storeActions.setCurrentDragType).toHaveBeenCalledWith(null) + expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith(null) + }) + + it('should sync drag-over folder id when folder willReceiveDrop changes', () => { + const initialProps = buildProps({ + id: 'folder-1', + nodeType: 'folder', + willReceiveDrop: false, + }) + const { rerender } = render() + + const receiveDropProps = buildProps({ + id: 'folder-1', + nodeType: 'folder', + willReceiveDrop: true, + }) + rerender() + + expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith('folder-1') + + const stopReceiveDropProps = buildProps({ + id: 'folder-1', + nodeType: 'folder', + willReceiveDrop: false, + }) + rerender() + + expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith(null) + }) + }) +}) diff --git a/web/app/components/workflow/skill/file-tree/tree/upload-status-tooltip.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/upload-status-tooltip.spec.tsx new file mode 100644 index 0000000000..efa9fe28a2 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree/upload-status-tooltip.spec.tsx @@ -0,0 +1,187 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import UploadStatusTooltip from './upload-status-tooltip' + +type MockWorkflowState = { + uploadStatus: 'idle' | 'uploading' | 'success' | 'partial_error' + uploadProgress: { + uploaded: number + total: number + failed: number + } +} + +const mocks = vi.hoisted(() => ({ + storeState: { + uploadStatus: 'idle', + uploadProgress: { uploaded: 0, total: 0, failed: 0 }, + } as MockWorkflowState, + resetUpload: vi.fn(), + storeApi: { + getState: () => ({ + resetUpload: mocks.resetUpload, + }), + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => mocks.storeApi, +})) + +const setUploadState = (overrides: Partial = {}) => { + mocks.storeState.uploadStatus = overrides.uploadStatus ?? 'idle' + mocks.storeState.uploadProgress = overrides.uploadProgress ?? { uploaded: 0, total: 0, failed: 0 } +} + +describe('UploadStatusTooltip', () => { + beforeEach(() => { + vi.clearAllMocks() + setUploadState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // Different upload states should render different user-facing feedback. + describe('Rendering', () => { + it('should render fallback content when upload status is idle', () => { + // Arrange + setUploadState({ uploadStatus: 'idle' }) + + // Act + render(Idle fallback} />) + + // Assert + expect(screen.getByText('Idle fallback')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /common\.operation\.close/i })).not.toBeInTheDocument() + }) + + it('should render uploading text and progress width when upload is in progress', () => { + // Arrange + setUploadState({ + uploadStatus: 'uploading', + uploadProgress: { uploaded: 2, total: 5, failed: 0 }, + }) + + // Act + const { container } = render() + const progressBar = container.querySelector('.bg-components-progress-bar-progress') + + // Assert + expect(screen.getByText(/workflow\.skillSidebar\.uploadingItems/i)).toBeInTheDocument() + expect(screen.getByText(/"uploaded":2/)).toBeInTheDocument() + expect(progressBar).toBeInTheDocument() + expect(progressBar).toHaveStyle({ width: '40%' }) + }) + + it('should clamp uploading progress width to 0% when total is 0', () => { + // Arrange + setUploadState({ + uploadStatus: 'uploading', + uploadProgress: { uploaded: 2, total: 0, failed: 0 }, + }) + + // Act + const { container } = render() + const progressBar = container.querySelector('.bg-components-progress-bar-progress') + + // Assert + expect(progressBar).toHaveStyle({ width: '0%' }) + }) + + it('should render success title and detail when upload succeeds', () => { + // Arrange + setUploadState({ + uploadStatus: 'success', + uploadProgress: { uploaded: 3, total: 3, failed: 0 }, + }) + + // Act + render() + + // Assert + expect(screen.getByText('workflow.skillSidebar.uploadSuccess')).toBeInTheDocument() + expect(screen.getByText(/workflow\.skillSidebar\.uploadSuccessDetail/i)).toBeInTheDocument() + }) + + it('should render partial error title and detail when upload partially fails', () => { + // Arrange + setUploadState({ + uploadStatus: 'partial_error', + uploadProgress: { uploaded: 2, total: 5, failed: 3 }, + }) + + // Act + render() + + // Assert + expect(screen.getByText('workflow.skillSidebar.uploadPartialError')).toBeInTheDocument() + expect(screen.getByText(/workflow\.skillSidebar\.uploadPartialErrorDetail/i)).toBeInTheDocument() + }) + }) + + // User action should dismiss the tooltip via store action. + describe('Interactions', () => { + it('should call resetUpload when close button is clicked', () => { + // Arrange + setUploadState({ + uploadStatus: 'partial_error', + uploadProgress: { uploaded: 2, total: 5, failed: 3 }, + }) + render() + + // Act + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + + // Assert + expect(mocks.resetUpload).toHaveBeenCalledTimes(1) + }) + }) + + // Success state uses a timer and should clean it up correctly. + describe('Success timer', () => { + it('should reset upload automatically after success display duration', () => { + // Arrange + vi.useFakeTimers() + setUploadState({ + uploadStatus: 'success', + uploadProgress: { uploaded: 1, total: 1, failed: 0 }, + }) + render() + + // Act + vi.advanceTimersByTime(1999) + + // Assert + expect(mocks.resetUpload).not.toHaveBeenCalled() + + // Act + vi.advanceTimersByTime(1) + + // Assert + expect(mocks.resetUpload).toHaveBeenCalledTimes(1) + }) + + it('should clear pending success timer when status changes before timeout', () => { + // Arrange + vi.useFakeTimers() + setUploadState({ + uploadStatus: 'success', + uploadProgress: { uploaded: 1, total: 1, failed: 0 }, + }) + const { rerender } = render(v1} />) + + // Act + setUploadState({ + uploadStatus: 'uploading', + uploadProgress: { uploaded: 1, total: 3, failed: 0 }, + }) + rerender(v2} />) + vi.advanceTimersByTime(3000) + + // Assert + expect(mocks.resetUpload).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx new file mode 100644 index 0000000000..64c767b289 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx @@ -0,0 +1,165 @@ +import type { App, AppSSO } from '@/types/app' +import type { AppAssetTreeResponse, AppAssetTreeView } from '@/types/app-asset' +import { renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { + useExistingSkillNames, + useSkillAssetNodeMap, + useSkillAssetTreeData, +} from './use-skill-asset-tree' + +const { mockUseGetAppAssetTree } = vi.hoisted(() => ({ + mockUseGetAppAssetTree: vi.fn(), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useGetAppAssetTree: (...args: unknown[]) => mockUseGetAppAssetTree(...args), +})) + +const createTreeNode = ( + overrides: Partial & Pick, +): AppAssetTreeView => ({ + id: overrides.id, + node_type: overrides.node_type, + name: overrides.name, + path: overrides.path ?? `/${overrides.name}`, + extension: overrides.extension ?? '', + size: overrides.size ?? 0, + children: overrides.children ?? [], +}) + +describe('useSkillAssetTree', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + mockUseGetAppAssetTree.mockReturnValue({ + data: null, + isPending: false, + error: null, + }) + }) + + // Scenario: should pass app id from app store to the data query hook. + describe('useSkillAssetTreeData', () => { + it('should request tree data with current app id', () => { + const expectedResult = { data: { children: [] }, isPending: false } + mockUseGetAppAssetTree.mockReturnValue(expectedResult) + + const { result } = renderHook(() => useSkillAssetTreeData()) + + expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('app-1') + expect(result.current).toBe(expectedResult) + }) + + it('should request tree data with empty app id when app detail is missing', () => { + useAppStore.setState({ appDetail: undefined }) + + renderHook(() => useSkillAssetTreeData()) + + expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('') + }) + }) + + // Scenario: should expose a select transform that builds node lookup maps. + describe('useSkillAssetNodeMap', () => { + it('should build a map including nested nodes', () => { + renderHook(() => useSkillAssetNodeMap()) + + const options = mockUseGetAppAssetTree.mock.calls[0][1] as { + select: (data: AppAssetTreeResponse) => Map + } + + const map = options.select({ + children: [ + createTreeNode({ + id: 'folder-1', + node_type: 'folder', + name: 'skill-a', + children: [ + createTreeNode({ + id: 'file-1', + node_type: 'file', + name: 'README.md', + extension: 'md', + }), + ], + }), + ], + }) + + expect(map.get('folder-1')?.name).toBe('skill-a') + expect(map.get('file-1')?.name).toBe('README.md') + expect(map.size).toBe(2) + }) + + it('should return an empty map when tree response has no children', () => { + renderHook(() => useSkillAssetNodeMap()) + + const options = mockUseGetAppAssetTree.mock.calls[0][1] as { + select: (data: AppAssetTreeResponse) => Map + } + + const map = options.select({} as AppAssetTreeResponse) + + expect(map.size).toBe(0) + }) + }) + + // Scenario: should expose root-level existing skill folder names. + describe('useExistingSkillNames', () => { + it('should collect only root folder names', () => { + renderHook(() => useExistingSkillNames()) + + const options = mockUseGetAppAssetTree.mock.calls[0][1] as { + select: (data: AppAssetTreeResponse) => Set + } + + const names = options.select({ + children: [ + createTreeNode({ + id: 'folder-1', + node_type: 'folder', + name: 'skill-a', + children: [ + createTreeNode({ + id: 'folder-2', + node_type: 'folder', + name: 'nested-folder', + }), + ], + }), + createTreeNode({ + id: 'file-1', + node_type: 'file', + name: 'README.md', + extension: 'md', + }), + createTreeNode({ + id: 'folder-3', + node_type: 'folder', + name: 'skill-b', + }), + ], + }) + + expect(names.has('skill-a')).toBe(true) + expect(names.has('skill-b')).toBe(true) + expect(names.has('nested-folder')).toBe(false) + expect(names.size).toBe(2) + }) + + it('should return an empty set when tree response has no children', () => { + renderHook(() => useExistingSkillNames()) + + const options = mockUseGetAppAssetTree.mock.calls[0][1] as { + select: (data: AppAssetTreeResponse) => Set + } + + const names = options.select({} as AppAssetTreeResponse) + + expect(names.size).toBe(0) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-tree-collaboration.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-tree-collaboration.spec.tsx new file mode 100644 index 0000000000..e5e4365e43 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-tree-collaboration.spec.tsx @@ -0,0 +1,168 @@ +import type { ReactNode } from 'react' +import type { App, AppSSO } from '@/types/app' +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { consoleQuery } from '@/service/client' +import { + useSkillTreeCollaboration, + useSkillTreeUpdateEmitter, +} from './use-skill-tree-collaboration' + +const { + mockEmitTreeUpdate, + mockOnTreeUpdate, + mockUnsubscribe, +} = vi.hoisted(() => ({ + mockEmitTreeUpdate: vi.fn(), + mockOnTreeUpdate: vi.fn(), + mockUnsubscribe: vi.fn(), +})) + +vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({ + skillCollaborationManager: { + emitTreeUpdate: mockEmitTreeUpdate, + onTreeUpdate: mockOnTreeUpdate, + }, +})) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useSkillTreeCollaboration', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + + const currentFeatures = useGlobalPublicStore.getState().systemFeatures + useGlobalPublicStore.setState({ + systemFeatures: { + ...currentFeatures, + enable_collaboration_mode: true, + }, + }) + + mockOnTreeUpdate.mockReturnValue(mockUnsubscribe) + }) + + // Scenario: update emitter sends events only when collaboration is enabled and app id exists. + describe('useSkillTreeUpdateEmitter', () => { + it('should emit tree update with app id and payload', () => { + const { result } = renderHook(() => useSkillTreeUpdateEmitter()) + + act(() => { + result.current({ source: 'test' }) + }) + + expect(mockEmitTreeUpdate).toHaveBeenCalledWith('app-1', { source: 'test' }) + }) + + it('should not emit tree update when collaboration is disabled', () => { + const currentFeatures = useGlobalPublicStore.getState().systemFeatures + useGlobalPublicStore.setState({ + systemFeatures: { + ...currentFeatures, + enable_collaboration_mode: false, + }, + }) + + const { result } = renderHook(() => useSkillTreeUpdateEmitter()) + act(() => { + result.current({ source: 'disabled' }) + }) + + expect(mockEmitTreeUpdate).not.toHaveBeenCalled() + }) + + it('should not emit tree update when app id is missing', () => { + useAppStore.setState({ appDetail: undefined }) + + const { result } = renderHook(() => useSkillTreeUpdateEmitter()) + act(() => { + result.current({ source: 'no-app' }) + }) + + expect(mockEmitTreeUpdate).not.toHaveBeenCalled() + }) + }) + + // Scenario: collaboration hook subscribes to updates and invalidates tree query cache. + describe('useSkillTreeCollaboration', () => { + it('should subscribe to tree updates and invalidate app tree query when updates arrive', async () => { + let treeUpdateCallback: ((payload: Record) => void) | null = null + mockOnTreeUpdate.mockImplementation((_appId: string, callback: (payload: Record) => void) => { + treeUpdateCallback = callback + return mockUnsubscribe + }) + + const queryClient = new QueryClient() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + renderHook(() => useSkillTreeCollaboration(), { + wrapper: createWrapper(queryClient), + }) + + expect(mockOnTreeUpdate).toHaveBeenCalledWith('app-1', expect.any(Function)) + + act(() => { + treeUpdateCallback?.({ reason: 'remote' }) + }) + + await waitFor(() => { + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: 'app-1' } } }), + }) + }) + }) + + it('should clean up tree update subscription on unmount', () => { + const queryClient = new QueryClient() + const { unmount } = renderHook(() => useSkillTreeCollaboration(), { + wrapper: createWrapper(queryClient), + }) + + unmount() + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) + + it('should skip subscription when collaboration is disabled', () => { + const currentFeatures = useGlobalPublicStore.getState().systemFeatures + useGlobalPublicStore.setState({ + systemFeatures: { + ...currentFeatures, + enable_collaboration_mode: false, + }, + }) + + const queryClient = new QueryClient() + renderHook(() => useSkillTreeCollaboration(), { + wrapper: createWrapper(queryClient), + }) + + expect(mockOnTreeUpdate).not.toHaveBeenCalled() + }) + + it('should skip subscription when app id is missing', () => { + useAppStore.setState({ appDetail: undefined }) + + const queryClient = new QueryClient() + renderHook(() => useSkillTreeCollaboration(), { + wrapper: createWrapper(queryClient), + }) + + expect(mockOnTreeUpdate).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/dnd/use-file-drop.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-file-drop.spec.tsx new file mode 100644 index 0000000000..a85c05a113 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-file-drop.spec.tsx @@ -0,0 +1,278 @@ +import type { ReactNode } from 'react' +import type { App, AppSSO } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store' +import { ROOT_ID } from '../../../constants' +import { useFileDrop } from './use-file-drop' + +const { + mockUploadMutateAsync, + mockPrepareSkillUploadFile, + mockEmitTreeUpdate, + mockToastNotify, +} = vi.hoisted(() => ({ + mockUploadMutateAsync: vi.fn(), + mockPrepareSkillUploadFile: vi.fn(), + mockEmitTreeUpdate: vi.fn(), + mockToastNotify: vi.fn(), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useUploadFileWithPresignedUrl: () => ({ + mutateAsync: mockUploadMutateAsync, + isPending: false, + }), +})) + +vi.mock('../../../utils/skill-upload-utils', () => ({ + prepareSkillUploadFile: mockPrepareSkillUploadFile, +})) + +vi.mock('../data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mockToastNotify, + }, +})) + +type MockDataTransferItem = { + kind: string + getAsFile: () => File | null + webkitGetAsEntry: () => { isDirectory: boolean } | null +} + +type MockDragEvent = { + preventDefault: ReturnType + stopPropagation: ReturnType + dataTransfer: { + types: string[] + items: DataTransferItem[] + dropEffect: 'none' | 'copy' | 'move' | 'link' + } +} + +const createWrapper = (store: ReturnType) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const createDataTransferItem = (params: { + file?: File | null + kind?: string + isDirectory?: boolean +} = {}): DataTransferItem => { + const { + file = null, + kind = 'file', + isDirectory, + } = params + + const item: MockDataTransferItem = { + kind, + getAsFile: () => file, + webkitGetAsEntry: () => { + if (typeof isDirectory === 'boolean') + return { isDirectory } + return null + }, + } + + return item as unknown as DataTransferItem +} + +const createDragEvent = (params: { + types?: string[] + items?: DataTransferItem[] +} = {}): MockDragEvent => { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + types: params.types ?? ['Files'], + items: params.items ?? [], + dropEffect: 'none', + }, + } +} + +describe('useFileDrop', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + mockPrepareSkillUploadFile.mockImplementation(async (file: File) => file) + mockUploadMutateAsync.mockResolvedValue(undefined) + }) + + // Scenario: drag-over updates upload drag state for valid external file drags. + describe('Drag Over', () => { + it('should set upload drag state when file drag enters root target', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) }) + const event = createDragEvent() + + act(() => { + result.current.handleDragOver(event as unknown as React.DragEvent, { + folderId: null, + isFolder: false, + }) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(event.dataTransfer.dropEffect).toBe('copy') + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe(ROOT_ID) + }) + + it('should ignore drag-over when dragged payload does not contain files', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) }) + const event = createDragEvent({ types: ['text/plain'] }) + + act(() => { + result.current.handleDragOver(event as unknown as React.DragEvent, { + folderId: 'folder-1', + isFolder: true, + }) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + expect(event.dataTransfer.dropEffect).toBe('none') + }) + }) + + // Scenario: directory drops are rejected and do not trigger upload mutations. + describe('Folder Drop Rejection', () => { + it('should reject dropped folders and show an error toast', async () => { + const store = createWorkflowStore({}) + store.getState().setCurrentDragType('upload') + store.getState().setDragOverFolderId('folder-1') + const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) }) + const event = createDragEvent({ + items: [createDataTransferItem({ isDirectory: true })], + }) + + await act(async () => { + await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-1') + }) + + expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled() + expect(mockUploadMutateAsync).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.folderDropNotSupported', + }) + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + + it('should upload valid files while rejecting directories in a mixed drop payload', async () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) }) + const file = new File(['gamma'], 'gamma.md', { type: 'text/markdown' }) + const event = createDragEvent({ + items: [ + createDataTransferItem({ isDirectory: true }), + createDataTransferItem({ file }), + ], + }) + + await act(async () => { + await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-mixed') + }) + + expect(mockPrepareSkillUploadFile).toHaveBeenCalledTimes(1) + expect(mockPrepareSkillUploadFile).toHaveBeenCalledWith(file) + expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1) + expect(mockUploadMutateAsync).toHaveBeenCalledWith({ + appId: 'app-1', + file, + parentId: 'folder-mixed', + }) + expect(mockEmitTreeUpdate).toHaveBeenCalledTimes(1) + expect(mockToastNotify).toHaveBeenNthCalledWith(1, { + type: 'error', + message: 'workflow.skillSidebar.menu.folderDropNotSupported', + }) + expect(mockToastNotify).toHaveBeenNthCalledWith(2, { + type: 'success', + message: 'workflow.skillSidebar.menu.filesUploaded:{"count":1}', + }) + }) + }) + + // Scenario: successful drops upload prepared files and emit collaboration updates. + describe('Upload Success', () => { + it('should upload dropped files and show success toast when upload succeeds', async () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) }) + const firstFile = new File(['alpha'], 'alpha.md', { type: 'text/markdown' }) + const secondFile = new File(['beta'], 'beta.txt', { type: 'text/plain' }) + const event = createDragEvent({ + items: [ + createDataTransferItem({ file: firstFile }), + createDataTransferItem({ file: secondFile }), + ], + }) + + await act(async () => { + await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-9') + }) + + expect(mockPrepareSkillUploadFile).toHaveBeenCalledTimes(2) + expect(mockPrepareSkillUploadFile).toHaveBeenNthCalledWith(1, firstFile) + expect(mockPrepareSkillUploadFile).toHaveBeenNthCalledWith(2, secondFile) + expect(mockUploadMutateAsync).toHaveBeenCalledTimes(2) + expect(mockUploadMutateAsync).toHaveBeenNthCalledWith(1, { + appId: 'app-1', + file: firstFile, + parentId: 'folder-9', + }) + expect(mockUploadMutateAsync).toHaveBeenNthCalledWith(2, { + appId: 'app-1', + file: secondFile, + parentId: 'folder-9', + }) + expect(mockEmitTreeUpdate).toHaveBeenCalledTimes(1) + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.skillSidebar.menu.filesUploaded:{"count":2}', + }) + }) + }) + + // Scenario: failed uploads surface an error toast and skip collaboration updates. + describe('Upload Error', () => { + it('should show error toast when upload fails', async () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) }) + const file = new File(['content'], 'failed.md', { type: 'text/markdown' }) + const event = createDragEvent({ + items: [createDataTransferItem({ file })], + }) + mockUploadMutateAsync.mockRejectedValueOnce(new Error('upload failed')) + + await act(async () => { + await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-err') + }) + + expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1) + expect(mockEmitTreeUpdate).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.uploadError', + }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/dnd/use-folder-file-drop.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-folder-file-drop.spec.tsx new file mode 100644 index 0000000000..263f289924 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-folder-file-drop.spec.tsx @@ -0,0 +1,242 @@ +import type { ReactNode } from 'react' +import type { NodeApi } from 'react-arborist' +import type { TreeNodeData } from '../../../type' +import type { AppAssetTreeView } from '@/types/app-asset' +import { act, renderHook } from '@testing-library/react' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store' +import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants' +import { useFolderFileDrop } from './use-folder-file-drop' + +const { + mockHandleDragOver, + mockHandleDrop, +} = vi.hoisted(() => ({ + mockHandleDragOver: vi.fn(), + mockHandleDrop: vi.fn(), +})) + +vi.mock('./use-unified-drag', () => ({ + useUnifiedDrag: () => ({ + handleDragOver: mockHandleDragOver, + handleDrop: mockHandleDrop, + }), +})) + +const createWrapper = (store: ReturnType) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const createNode = (params: { + id?: string + nodeType: 'file' | 'folder' + isOpen?: boolean +}): NodeApi => { + const node = { + data: { + id: params.id ?? 'node-1', + node_type: params.nodeType, + name: params.nodeType === 'folder' ? 'folder-a' : 'README.md', + path: '/node-1', + extension: params.nodeType === 'folder' ? '' : 'md', + size: 1, + children: [], + }, + isOpen: params.isOpen ?? false, + open: vi.fn(), + } + + return node as unknown as NodeApi +} + +const createDragEvent = (types: string[]): React.DragEvent => { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + types, + items: [], + dropEffect: 'none', + } as unknown as DataTransfer, + } as unknown as React.DragEvent +} + +const EMPTY_TREE_CHILDREN: AppAssetTreeView[] = [] + +describe('useFolderFileDrop', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // Scenario: derive drag-over state from workflow store and folder identity. + describe('isDragOver', () => { + it('should be true when node is folder and dragOverFolderId matches node id', () => { + const store = createWorkflowStore({}) + store.getState().setDragOverFolderId('folder-1') + const node = createNode({ id: 'folder-1', nodeType: 'folder' }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + expect(result.current.isDragOver).toBe(true) + }) + + it('should be false when node is not a folder even if dragOverFolderId matches', () => { + const store = createWorkflowStore({}) + store.getState().setDragOverFolderId('file-1') + const node = createNode({ id: 'file-1', nodeType: 'file' }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + expect(result.current.isDragOver).toBe(false) + }) + }) + + // Scenario: drag handlers delegate only for supported drag events on folder nodes. + describe('drag handlers', () => { + it('should delegate drag over and drop for supported file drag events', () => { + const store = createWorkflowStore({}) + const node = createNode({ id: 'folder-2', nodeType: 'folder' }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + const dragOverEvent = createDragEvent(['Files']) + const dropEvent = createDragEvent(['Files']) + + act(() => { + result.current.dragHandlers.onDragOver(dragOverEvent) + result.current.dragHandlers.onDrop(dropEvent) + }) + + expect(mockHandleDragOver).toHaveBeenCalledWith(dragOverEvent, { + folderId: 'folder-2', + isFolder: true, + }) + expect(mockHandleDrop).toHaveBeenCalledWith(dropEvent, 'folder-2') + }) + + it('should ignore unsupported drag events', () => { + const store = createWorkflowStore({}) + const node = createNode({ id: 'folder-3', nodeType: 'folder' }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + const unsupportedEvent = createDragEvent(['text/plain']) + act(() => { + result.current.dragHandlers.onDragEnter(unsupportedEvent) + result.current.dragHandlers.onDragOver(unsupportedEvent) + result.current.dragHandlers.onDragLeave(unsupportedEvent) + }) + + expect(mockHandleDragOver).not.toHaveBeenCalled() + expect(mockHandleDrop).not.toHaveBeenCalled() + }) + + it('should support internal node drag type in drag over handler', () => { + const store = createWorkflowStore({}) + const node = createNode({ id: 'folder-4', nodeType: 'folder' }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + const internalDragEvent = createDragEvent([INTERNAL_NODE_DRAG_TYPE]) + act(() => { + result.current.dragHandlers.onDragOver(internalDragEvent) + }) + + expect(mockHandleDragOver).toHaveBeenCalledWith(internalDragEvent, { + folderId: 'folder-4', + isFolder: true, + }) + }) + }) + + // Scenario: auto-expand lifecycle should blink first, expand later, and cleanup when drag state changes. + describe('auto expand and blink', () => { + it('should blink after delay and auto-expand folder after longer delay', () => { + const store = createWorkflowStore({}) + store.getState().setDragOverFolderId('folder-5') + const node = createNode({ id: 'folder-5', nodeType: 'folder', isOpen: false }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + expect(result.current.isBlinking).toBe(false) + + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current.isBlinking).toBe(true) + + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current.isBlinking).toBe(false) + expect(node.open).toHaveBeenCalledTimes(1) + }) + + it('should cancel auto-expand when drag over state is cleared before expand delay', () => { + const store = createWorkflowStore({}) + store.getState().setDragOverFolderId('folder-6') + const node = createNode({ id: 'folder-6', nodeType: 'folder', isOpen: false }) + + const { result } = renderHook(() => useFolderFileDrop({ + node, + treeChildren: EMPTY_TREE_CHILDREN, + }), { + wrapper: createWrapper(store), + }) + + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current.isBlinking).toBe(true) + + act(() => { + store.getState().setDragOverFolderId(null) + }) + expect(result.current.isBlinking).toBe(false) + + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(node.open).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/dnd/use-root-file-drop.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-root-file-drop.spec.tsx new file mode 100644 index 0000000000..b4356f3064 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-root-file-drop.spec.tsx @@ -0,0 +1,237 @@ +import type { ReactNode } from 'react' +import type { App, AppSSO } from '@/types/app' +import type { AppAssetTreeView } from '@/types/app-asset' +import { act, renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store' +import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../../../constants' +import { useRootFileDrop } from './use-root-file-drop' + +const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({ + mockUploadMutateAsync: vi.fn(), + uploadHookState: { isPending: false }, +})) + +vi.mock('@/service/use-app-asset', () => ({ + useUploadFileWithPresignedUrl: () => ({ + mutateAsync: mockUploadMutateAsync, + isPending: uploadHookState.isPending, + }), +})) + +type DragEventOptions = { + types: string[] + items?: DataTransferItem[] +} + +const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEvent => { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + types, + items, + dropEffect: 'none', + } as unknown as DataTransfer, + } as unknown as React.DragEvent +} + +const createWrapper = (store: ReturnType) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const EMPTY_TREE_CHILDREN: AppAssetTreeView[] = [] + +describe('useRootFileDrop', () => { + beforeEach(() => { + vi.clearAllMocks() + uploadHookState.isPending = false + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + }) + + describe('handleRootDragOver', () => { + it('should set root upload drag state when files are dragged over root', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: ['Files'] }) + + act(() => { + result.current.handleRootDragOver(dragEvent) + }) + + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe(ROOT_ID) + }) + + it('should skip dragOver handling when drag source is not files', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] }) + + act(() => { + result.current.handleRootDragOver(dragEvent) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + }) + + describe('drag counter behavior', () => { + it('should keep drag state until nested drag leaves reach zero', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const fileDragEvent = createDragEvent({ types: ['Files'] }) + + act(() => { + result.current.handleRootDragOver(fileDragEvent) + }) + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe(ROOT_ID) + + act(() => { + result.current.handleRootDragEnter(fileDragEvent) + result.current.handleRootDragEnter(fileDragEvent) + }) + + act(() => { + result.current.handleRootDragLeave(fileDragEvent) + }) + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe(ROOT_ID) + + act(() => { + result.current.handleRootDragLeave(fileDragEvent) + }) + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + + it('should not increment counter when dragEnter is not a supported drag event', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const fileDragEvent = createDragEvent({ types: ['Files'] }) + const unsupportedDragEvent = createDragEvent({ types: ['text/plain'] }) + + act(() => { + result.current.handleRootDragOver(fileDragEvent) + }) + + act(() => { + result.current.handleRootDragEnter(unsupportedDragEvent) + }) + + act(() => { + result.current.handleRootDragLeave(fileDragEvent) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + + it('should not decrement counter when dragLeave is not a supported drag event', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const fileDragEvent = createDragEvent({ types: ['Files'] }) + const unsupportedDragEvent = createDragEvent({ types: ['text/plain'] }) + + act(() => { + result.current.handleRootDragOver(fileDragEvent) + result.current.handleRootDragEnter(fileDragEvent) + result.current.handleRootDragEnter(fileDragEvent) + }) + + act(() => { + result.current.handleRootDragLeave(unsupportedDragEvent) + }) + + act(() => { + result.current.handleRootDragLeave(fileDragEvent) + }) + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe(ROOT_ID) + + act(() => { + result.current.handleRootDragLeave(fileDragEvent) + }) + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + }) + + describe('counter reset', () => { + it('should clear counter when resetRootDragCounter is called', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const fileDragEvent = createDragEvent({ types: ['Files'] }) + + act(() => { + result.current.handleRootDragOver(fileDragEvent) + result.current.handleRootDragEnter(fileDragEvent) + result.current.handleRootDragEnter(fileDragEvent) + }) + + act(() => { + result.current.resetRootDragCounter() + }) + + act(() => { + result.current.handleRootDragLeave(fileDragEvent) + }) + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + + it('should reset counter after drop and clear drag state', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), { + wrapper: createWrapper(store), + }) + const beforeDropEvent = createDragEvent({ types: ['Files'], items: [] }) + const afterDropEvent = createDragEvent({ types: ['Files'], items: [] }) + + act(() => { + result.current.handleRootDragOver(beforeDropEvent) + result.current.handleRootDragEnter(beforeDropEvent) + result.current.handleRootDragEnter(beforeDropEvent) + result.current.handleRootDrop(beforeDropEvent) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + expect(beforeDropEvent.preventDefault).toHaveBeenCalled() + expect(beforeDropEvent.stopPropagation).toHaveBeenCalled() + + act(() => { + result.current.handleRootDragOver(afterDropEvent) + }) + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe(ROOT_ID) + + act(() => { + result.current.handleRootDragLeave(afterDropEvent) + }) + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/dnd/use-unified-drag.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-unified-drag.spec.tsx new file mode 100644 index 0000000000..fc84285cb0 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/dnd/use-unified-drag.spec.tsx @@ -0,0 +1,187 @@ +import type { ReactNode } from 'react' +import type { App, AppSSO } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store' +import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants' +import { useUnifiedDrag } from './use-unified-drag' + +const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({ + mockUploadMutateAsync: vi.fn(), + uploadHookState: { isPending: false }, +})) + +vi.mock('@/service/use-app-asset', () => ({ + useUploadFileWithPresignedUrl: () => ({ + mutateAsync: mockUploadMutateAsync, + isPending: uploadHookState.isPending, + }), +})) + +type DragEventOptions = { + types: string[] + items?: DataTransferItem[] +} + +const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEvent => { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + types, + items, + dropEffect: 'none', + } as unknown as DataTransfer, + } as unknown as React.DragEvent +} + +const createWrapper = (store: ReturnType) => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useUnifiedDrag', () => { + beforeEach(() => { + vi.clearAllMocks() + uploadHookState.isPending = false + useAppStore.setState({ + appDetail: { id: 'app-1' } as App & Partial, + }) + }) + + describe('handleDragOver', () => { + it('should update drag state when drag source contains files', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: ['Files'] }) + + act(() => { + result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true }) + }) + + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe('folder-1') + expect(dragEvent.dataTransfer.dropEffect).toBe('copy') + }) + + it('should ignore dragOver when drag source does not contain files', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] }) + + act(() => { + result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true }) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + expect(dragEvent.dataTransfer.dropEffect).toBe('none') + }) + }) + + describe('handleDragLeave', () => { + it('should clear drag state when drag source contains files', () => { + const store = createWorkflowStore({}) + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: ['Files'] }) + + act(() => { + result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true }) + }) + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe('folder-1') + + act(() => { + result.current.handleDragLeave(dragEvent) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + }) + + it('should ignore dragLeave when drag source does not contain files', () => { + const store = createWorkflowStore({}) + store.getState().setCurrentDragType('upload') + store.getState().setDragOverFolderId('folder-1') + + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] }) + + act(() => { + result.current.handleDragLeave(dragEvent) + }) + + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe('folder-1') + }) + }) + + describe('handleDrop', () => { + it('should delegate drop handling when drag source contains files', async () => { + const store = createWorkflowStore({}) + store.getState().setCurrentDragType('upload') + store.getState().setDragOverFolderId('folder-1') + + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: ['Files'], items: [] }) + + await act(async () => { + await result.current.handleDrop(dragEvent, null) + }) + + expect(store.getState().currentDragType).toBeNull() + expect(store.getState().dragOverFolderId).toBeNull() + expect(dragEvent.preventDefault).toHaveBeenCalledTimes(1) + expect(dragEvent.stopPropagation).toHaveBeenCalledTimes(1) + }) + + it('should return undefined and skip drop handling when drag source does not contain files', async () => { + const store = createWorkflowStore({}) + store.getState().setCurrentDragType('upload') + store.getState().setDragOverFolderId('folder-1') + + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE], items: [] }) + + let dropResult: Promise | undefined + await act(async () => { + dropResult = result.current.handleDrop(dragEvent, null) + }) + + expect(dropResult).toBeUndefined() + expect(store.getState().currentDragType).toBe('upload') + expect(store.getState().dragOverFolderId).toBe('folder-1') + expect(dragEvent.preventDefault).not.toHaveBeenCalled() + expect(dragEvent.stopPropagation).not.toHaveBeenCalled() + }) + }) + + describe('isUploading', () => { + it('should expose uploading state from file drop hook', () => { + uploadHookState.isPending = true + const store = createWorkflowStore({}) + + const { result } = renderHook(() => useUnifiedDrag(), { + wrapper: createWrapper(store), + }) + + expect(result.current.isUploading).toBe(true) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-delayed-click.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/interaction/use-delayed-click.spec.tsx new file mode 100644 index 0000000000..f92ac17563 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/interaction/use-delayed-click.spec.tsx @@ -0,0 +1,145 @@ +import { act, renderHook } from '@testing-library/react' +import { useDelayedClick } from './use-delayed-click' + +describe('useDelayedClick', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Single Click', () => { + it('should call onSingleClick after the delay when clicked once', () => { + const onSingleClick = vi.fn() + const onDoubleClick = vi.fn() + const { result } = renderHook(() => useDelayedClick({ + delay: 200, + onSingleClick, + onDoubleClick, + })) + + act(() => { + result.current.handleClick() + }) + + act(() => { + vi.advanceTimersByTime(199) + }) + expect(onSingleClick).not.toHaveBeenCalled() + expect(onDoubleClick).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(onSingleClick).toHaveBeenCalledTimes(1) + expect(onDoubleClick).not.toHaveBeenCalled() + }) + + it('should schedule only one single click when clicked twice before delay ends', () => { + const onSingleClick = vi.fn() + const onDoubleClick = vi.fn() + const { result } = renderHook(() => useDelayedClick({ + delay: 200, + onSingleClick, + onDoubleClick, + })) + + act(() => { + result.current.handleClick() + }) + act(() => { + vi.advanceTimersByTime(100) + }) + + act(() => { + result.current.handleClick() + }) + + act(() => { + vi.advanceTimersByTime(199) + }) + expect(onSingleClick).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(onSingleClick).toHaveBeenCalledTimes(1) + expect(onDoubleClick).not.toHaveBeenCalled() + }) + }) + + describe('Double Click', () => { + it('should cancel pending single click and call onDoubleClick when double-clicked', () => { + const onSingleClick = vi.fn() + const onDoubleClick = vi.fn() + const { result } = renderHook(() => useDelayedClick({ + delay: 200, + onSingleClick, + onDoubleClick, + })) + + act(() => { + result.current.handleClick() + }) + act(() => { + vi.advanceTimersByTime(50) + }) + + act(() => { + result.current.handleDoubleClick() + }) + + expect(onDoubleClick).toHaveBeenCalledTimes(1) + expect(onSingleClick).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(300) + }) + expect(onSingleClick).not.toHaveBeenCalled() + }) + + it('should call onDoubleClick when no single-click timeout is pending', () => { + const onSingleClick = vi.fn() + const onDoubleClick = vi.fn() + const { result } = renderHook(() => useDelayedClick({ + onSingleClick, + onDoubleClick, + })) + + act(() => { + result.current.handleDoubleClick() + }) + + expect(onDoubleClick).toHaveBeenCalledTimes(1) + expect(onSingleClick).not.toHaveBeenCalled() + }) + }) + + describe('Cleanup', () => { + it('should clear pending timeout on unmount', () => { + const onSingleClick = vi.fn() + const onDoubleClick = vi.fn() + const { result, unmount } = renderHook(() => useDelayedClick({ + delay: 200, + onSingleClick, + onDoubleClick, + })) + + act(() => { + result.current.handleClick() + }) + + unmount() + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(onSingleClick).not.toHaveBeenCalled() + expect(onDoubleClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-skill-shortcuts.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/interaction/use-skill-shortcuts.spec.tsx new file mode 100644 index 0000000000..d596a979f0 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/interaction/use-skill-shortcuts.spec.tsx @@ -0,0 +1,190 @@ +import type { RefObject } from 'react' +import type { TreeApi } from 'react-arborist' +import type { TreeNodeData } from '../../../type' +import { act, renderHook } from '@testing-library/react' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils/common' +import { useSkillShortcuts } from './use-skill-shortcuts' + +const { + mockUseKeyPress, + mockCutNodes, + mockHasClipboard, + registeredShortcutHandlers, +} = vi.hoisted(() => ({ + mockUseKeyPress: vi.fn(), + mockCutNodes: vi.fn(), + mockHasClipboard: vi.fn(() => false), + registeredShortcutHandlers: {} as Record void>, +})) + +vi.mock('ahooks', () => ({ + useKeyPress: (hotkey: string, callback: (event: KeyboardEvent) => void) => { + mockUseKeyPress(hotkey, callback) + registeredShortcutHandlers[hotkey] = callback + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + cutNodes: mockCutNodes, + hasClipboard: mockHasClipboard, + }), + }), +})) + +const createTreeRef = (selectedIds: string[]): RefObject | null> => { + return { + current: { + selectedNodes: selectedIds.map(id => ({ id })), + } as unknown as TreeApi, + } +} + +const createShortcutEvent = (target: HTMLElement): KeyboardEvent => { + return { + target, + preventDefault: vi.fn(), + } as unknown as KeyboardEvent +} + +describe('useSkillShortcuts', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.keys(registeredShortcutHandlers).forEach((shortcut) => { + delete registeredShortcutHandlers[shortcut] + }) + mockHasClipboard.mockReturnValue(false) + }) + + // Scenario: register platform-aware cut and paste shortcuts on mount. + describe('shortcut registration', () => { + it('should register cut and paste key combinations', () => { + const treeRef = createTreeRef([]) + renderHook(() => useSkillShortcuts({ treeRef })) + + const ctrlKey = getKeyboardKeyCodeBySystem('ctrl') + expect(mockUseKeyPress).toHaveBeenCalledTimes(2) + expect(registeredShortcutHandlers[`${ctrlKey}.x`]).toBeTypeOf('function') + expect(registeredShortcutHandlers[`${ctrlKey}.v`]).toBeTypeOf('function') + }) + }) + + // Scenario: cut shortcut depends on target context, selection state, and enabled state. + describe('cut shortcut', () => { + it('should cut selected nodes when keyboard event originates in tree container', () => { + const treeRef = createTreeRef(['file-1', 'file-2']) + renderHook(() => useSkillShortcuts({ treeRef })) + + const container = document.createElement('div') + container.setAttribute('data-skill-tree-container', '') + const target = document.createElement('button') + container.appendChild(target) + const event = createShortcutEvent(target) + + const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x` + act(() => { + registeredShortcutHandlers[cutShortcut](event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(mockCutNodes).toHaveBeenCalledWith(['file-1', 'file-2']) + }) + + it('should cut selected nodes even when event target is outside tree container', () => { + const treeRef = createTreeRef(['file-3']) + renderHook(() => useSkillShortcuts({ treeRef })) + + const outsideTarget = document.createElement('button') + const event = createShortcutEvent(outsideTarget) + + const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x` + act(() => { + registeredShortcutHandlers[cutShortcut](event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(mockCutNodes).toHaveBeenCalledWith(['file-3']) + }) + + it('should ignore cut shortcut when target is an input area', () => { + const treeRef = createTreeRef(['file-1']) + renderHook(() => useSkillShortcuts({ treeRef })) + + const input = document.createElement('input') + const event = createShortcutEvent(input) + + const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x` + act(() => { + registeredShortcutHandlers[cutShortcut](event) + }) + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(mockCutNodes).not.toHaveBeenCalled() + }) + + it('should ignore cut shortcut when shortcuts are disabled', () => { + const treeRef = createTreeRef(['file-1']) + const { rerender } = renderHook( + ({ enabled }) => useSkillShortcuts({ treeRef, enabled }), + { initialProps: { enabled: true } }, + ) + + rerender({ enabled: false }) + + const container = document.createElement('div') + container.setAttribute('data-skill-tree-container', '') + const target = document.createElement('button') + container.appendChild(target) + const event = createShortcutEvent(target) + + const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x` + act(() => { + registeredShortcutHandlers[cutShortcut](event) + }) + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(mockCutNodes).not.toHaveBeenCalled() + }) + }) + + // Scenario: paste shortcut dispatches global paste event only when clipboard has content. + describe('paste shortcut', () => { + it('should dispatch paste event when clipboard has content and shortcut should be handled', () => { + mockHasClipboard.mockReturnValue(true) + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent') + const treeRef = createTreeRef(['file-1']) + renderHook(() => useSkillShortcuts({ treeRef })) + + const target = document.createElement('button') + const event = createShortcutEvent(target) + + const pasteShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.v` + act(() => { + registeredShortcutHandlers[pasteShortcut](event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(dispatchEventSpy).toHaveBeenCalledTimes(1) + expect(dispatchEventSpy.mock.calls[0][0].type).toBe('skill:paste') + }) + + it('should ignore paste shortcut when clipboard is empty', () => { + mockHasClipboard.mockReturnValue(false) + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent') + const treeRef = createTreeRef(['file-1']) + renderHook(() => useSkillShortcuts({ treeRef })) + + const target = document.createElement('button') + const event = createShortcutEvent(target) + + const pasteShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.v` + act(() => { + registeredShortcutHandlers[pasteShortcut](event) + }) + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(dispatchEventSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-tree-node-handlers.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/interaction/use-tree-node-handlers.spec.tsx new file mode 100644 index 0000000000..fbb0ce08b1 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/interaction/use-tree-node-handlers.spec.tsx @@ -0,0 +1,237 @@ +import type { NodeApi } from 'react-arborist' +import type { TreeNodeData } from '../../../type' +import { act, renderHook } from '@testing-library/react' +import { useTreeNodeHandlers } from './use-tree-node-handlers' + +const { + mockClearArtifactSelection, + mockOpenTab, + mockSetContextMenu, +} = vi.hoisted(() => ({ + mockClearArtifactSelection: vi.fn(), + mockOpenTab: vi.fn(), + mockSetContextMenu: vi.fn(), +})) + +vi.mock('es-toolkit/function', () => ({ + throttle: (fn: () => void) => fn, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ + clearArtifactSelection: mockClearArtifactSelection, + openTab: mockOpenTab, + setContextMenu: mockSetContextMenu, + }), + }), +})) + +const createNode = (params: { + id?: string + nodeType: 'file' | 'folder' +}) => { + const id = params.id ?? 'node-1' + return { + data: { + id, + node_type: params.nodeType, + name: params.nodeType === 'folder' ? 'folder-a' : 'README.md', + path: `/${id}`, + extension: params.nodeType === 'folder' ? '' : 'md', + size: 1, + children: [], + }, + toggle: vi.fn(), + select: vi.fn(), + selectMulti: vi.fn(), + selectContiguous: vi.fn(), + isOpen: false, + } as unknown as NodeApi +} + +const createMouseEvent = (params: { + shiftKey?: boolean + ctrlKey?: boolean + metaKey?: boolean + clientX?: number + clientY?: number +} = {}) => { + return { + stopPropagation: vi.fn(), + preventDefault: vi.fn(), + shiftKey: params.shiftKey ?? false, + ctrlKey: params.ctrlKey ?? false, + metaKey: params.metaKey ?? false, + clientX: params.clientX ?? 0, + clientY: params.clientY ?? 0, + } as unknown as React.MouseEvent +} + +const createKeyboardEvent = (key: string) => { + return { + key, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent +} + +describe('useTreeNodeHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // Scenario: click behavior differs for folders/files and modifier keys. + describe('handleClick', () => { + it('should select contiguous node and toggle folder on shift-click', () => { + const node = createNode({ nodeType: 'folder' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent({ shiftKey: true }) + + act(() => { + result.current.handleClick(event) + }) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(node.selectContiguous).toHaveBeenCalledTimes(1) + expect(node.toggle).toHaveBeenCalledTimes(1) + expect(node.select).not.toHaveBeenCalled() + expect(node.selectMulti).not.toHaveBeenCalled() + }) + + it('should open file preview tab on plain click after delayed click timeout', () => { + const node = createNode({ id: 'file-1', nodeType: 'file' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent() + + act(() => { + result.current.handleClick(event) + }) + + expect(node.select).toHaveBeenCalledTimes(1) + expect(mockOpenTab).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(200) + }) + + expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mockOpenTab).toHaveBeenCalledWith('file-1', { pinned: false }) + }) + + it('should not trigger file preview tab on ctrl-click', () => { + const node = createNode({ id: 'file-2', nodeType: 'file' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent({ ctrlKey: true }) + + act(() => { + result.current.handleClick(event) + vi.advanceTimersByTime(250) + }) + + expect(node.selectMulti).toHaveBeenCalledTimes(1) + expect(mockOpenTab).not.toHaveBeenCalled() + expect(mockClearArtifactSelection).not.toHaveBeenCalled() + }) + }) + + // Scenario: double-click and toggle handlers route to folder toggle or pinned file open. + describe('double click and toggle', () => { + it('should toggle folder on double click', () => { + const node = createNode({ nodeType: 'folder' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent() + + act(() => { + result.current.handleDoubleClick(event) + }) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(node.toggle).toHaveBeenCalledTimes(1) + expect(mockOpenTab).not.toHaveBeenCalled() + }) + + it('should open file as pinned tab on double click', () => { + const node = createNode({ id: 'file-3', nodeType: 'file' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent() + + act(() => { + result.current.handleDoubleClick(event) + }) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mockOpenTab).toHaveBeenCalledWith('file-3', { pinned: true }) + }) + + it('should toggle node when toggle handler is invoked', () => { + const node = createNode({ nodeType: 'folder' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent() + + act(() => { + result.current.handleToggle(event) + }) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(node.toggle).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: context menu and keyboard handlers update menu state and open/toggle actions. + describe('context menu and keyboard', () => { + it('should select node and set context menu payload on right click', () => { + const node = createNode({ id: 'folder-1', nodeType: 'folder' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createMouseEvent({ clientX: 120, clientY: 45 }) + + act(() => { + result.current.handleContextMenu(event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(node.select).toHaveBeenCalledTimes(1) + expect(mockSetContextMenu).toHaveBeenCalledWith({ + top: 45, + left: 120, + type: 'node', + nodeId: 'folder-1', + isFolder: true, + }) + }) + + it('should toggle folder on Enter key', () => { + const node = createNode({ nodeType: 'folder' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createKeyboardEvent('Enter') + + act(() => { + result.current.handleKeyDown(event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(node.toggle).toHaveBeenCalledTimes(1) + expect(mockOpenTab).not.toHaveBeenCalled() + }) + + it('should open file as pinned tab on Space key', () => { + const node = createNode({ id: 'file-4', nodeType: 'file' }) + const { result } = renderHook(() => useTreeNodeHandlers({ node })) + const event = createKeyboardEvent(' ') + + act(() => { + result.current.handleKeyDown(event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mockOpenTab).toHaveBeenCalledWith('file-4', { pinned: true }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx new file mode 100644 index 0000000000..b377360f36 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx @@ -0,0 +1,427 @@ +import type { StoreApi } from 'zustand' +import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types' +import type { BatchUploadNodeInput } from '@/types/app-asset' +import { act, renderHook } from '@testing-library/react' +import { useCreateOperations } from './use-create-operations' + +type UploadMutationPayload = { + appId: string + file: File + parentId?: string | null +} + +type BatchUploadMutationPayload = { + appId: string + tree: BatchUploadNodeInput[] + files: Map + parentId?: string | null + onProgress?: (uploaded: number, total: number) => void +} + +type UploadProgress = { + uploaded: number + total: number + failed: number +} + +const mocks = vi.hoisted(() => ({ + createFolderPending: false, + uploadPending: false, + batchPending: false, + uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise>(), + batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise>(), + prepareSkillUploadFile: vi.fn<(file: File) => Promise>(), + emitTreeUpdate: vi.fn<() => void>(), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useCreateAppAssetFolder: () => ({ + isPending: mocks.createFolderPending, + }), + useUploadFileWithPresignedUrl: () => ({ + mutateAsync: mocks.uploadMutateAsync, + isPending: mocks.uploadPending, + }), + useBatchUpload: () => ({ + mutateAsync: mocks.batchMutateAsync, + isPending: mocks.batchPending, + }), +})) + +vi.mock('../../../utils/skill-upload-utils', () => ({ + prepareSkillUploadFile: mocks.prepareSkillUploadFile, +})) + +vi.mock('../data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, +})) + +const createStoreApi = () => { + const startCreateNode = vi.fn<(nodeType: 'file' | 'folder', parentId: string | null) => void>() + const setUploadStatus = vi.fn<(status: UploadStatus) => void>() + const setUploadProgress = vi.fn<(progress: UploadProgress) => void>() + + const state = { + startCreateNode, + setUploadStatus, + setUploadProgress, + } as Pick + + const storeApi = { + getState: () => state, + } as unknown as StoreApi + + return { + storeApi, + startCreateNode, + setUploadStatus, + setUploadProgress, + } +} + +const createInputChangeEvent = (files: File[] | null) => { + return { + target: { + files, + value: 'selected', + }, + } as unknown as React.ChangeEvent +} + +const withRelativePath = (file: File, relativePath: string): File => { + Object.defineProperty(file, 'webkitRelativePath', { + value: relativePath, + configurable: true, + }) + return file +} + +describe('useCreateOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createFolderPending = false + mocks.uploadPending = false + mocks.batchPending = false + mocks.prepareSkillUploadFile.mockImplementation(async file => file) + mocks.uploadMutateAsync.mockResolvedValue(undefined) + mocks.batchMutateAsync.mockResolvedValue([]) + }) + + // Scenario: loading state should combine all create-related pending flags. + describe('State', () => { + it('should expose isCreating false when no mutation is pending', () => { + const { storeApi } = createStoreApi() + + const { result } = renderHook(() => useCreateOperations({ + parentId: 'folder-1', + appId: 'app-1', + storeApi, + onClose: vi.fn(), + })) + + expect(result.current.isCreating).toBe(false) + expect(result.current.fileInputRef.current).toBeNull() + expect(result.current.folderInputRef.current).toBeNull() + }) + + it('should expose isCreating true when any mutation is pending', () => { + const { storeApi } = createStoreApi() + mocks.createFolderPending = true + + const { result } = renderHook(() => useCreateOperations({ + parentId: 'folder-1', + appId: 'app-1', + storeApi, + onClose: vi.fn(), + })) + + expect(result.current.isCreating).toBe(true) + }) + }) + + // Scenario: new node handlers should initialize create mode and close menu. + describe('New node handlers', () => { + it('should start inline file creation when handleNewFile is called', () => { + const { storeApi, startCreateNode } = createStoreApi() + const onClose = vi.fn() + const { result } = renderHook(() => useCreateOperations({ + parentId: 'parent-1', + appId: 'app-1', + storeApi, + onClose, + })) + + act(() => { + result.current.handleNewFile() + }) + + expect(startCreateNode).toHaveBeenCalledWith('file', 'parent-1') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should start inline folder creation when handleNewFolder is called', () => { + const { storeApi, startCreateNode } = createStoreApi() + const onClose = vi.fn() + const { result } = renderHook(() => useCreateOperations({ + parentId: null, + appId: 'app-1', + storeApi, + onClose, + })) + + act(() => { + result.current.handleNewFolder() + }) + + expect(startCreateNode).toHaveBeenCalledWith('folder', null) + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: file upload handler should process empty, success, partial-failure, and preparation-failure branches. + describe('handleFileChange', () => { + it('should close menu and no-op when no files are selected', async () => { + const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi() + const onClose = vi.fn() + const event = createInputChangeEvent([]) + const { result } = renderHook(() => useCreateOperations({ + parentId: 'parent-empty', + appId: 'app-empty', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFileChange(event) + }) + + expect(setUploadStatus).not.toHaveBeenCalled() + expect(setUploadProgress).not.toHaveBeenCalled() + expect(mocks.uploadMutateAsync).not.toHaveBeenCalled() + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledTimes(1) + expect(event.target.value).toBe('selected') + }) + + it('should upload all files and set success status when all uploads succeed', async () => { + const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi() + const onClose = vi.fn() + const first = new File(['first'], 'first.md', { type: 'text/markdown' }) + const second = new File(['second'], 'second.txt', { type: 'text/plain' }) + const event = createInputChangeEvent([first, second]) + const { result } = renderHook(() => useCreateOperations({ + parentId: 'folder-success', + appId: 'app-success', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFileChange(event) + }) + + expect(mocks.prepareSkillUploadFile).toHaveBeenNthCalledWith(1, first) + expect(mocks.prepareSkillUploadFile).toHaveBeenNthCalledWith(2, second) + expect(mocks.uploadMutateAsync).toHaveBeenCalledTimes(2) + expect(mocks.uploadMutateAsync).toHaveBeenNthCalledWith(1, { + appId: 'app-success', + file: first, + parentId: 'folder-success', + }) + expect(mocks.uploadMutateAsync).toHaveBeenNthCalledWith(2, { + appId: 'app-success', + file: second, + parentId: 'folder-success', + }) + + expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') + expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'success') + expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 2, failed: 0 }) + expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 2, failed: 0 }) + expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 2, total: 2, failed: 0 }) + + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(event.target.value).toBe('') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should set partial_error when some file uploads fail but still emit updates for uploaded files', async () => { + const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi() + const onClose = vi.fn() + const okFile = new File(['ok'], 'ok.md', { type: 'text/markdown' }) + const failedFile = new File(['nope'], 'nope.md', { type: 'text/markdown' }) + const event = createInputChangeEvent([okFile, failedFile]) + mocks.uploadMutateAsync + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('upload failed')) + + const { result } = renderHook(() => useCreateOperations({ + parentId: 'folder-partial', + appId: 'app-partial', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFileChange(event) + }) + + expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') + expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error') + expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 2, failed: 1 }) + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(event.target.value).toBe('') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should set partial_error and skip API upload when file preparation fails', async () => { + const { storeApi, setUploadStatus } = createStoreApi() + const onClose = vi.fn() + const file = new File(['broken'], 'broken.md', { type: 'text/markdown' }) + const event = createInputChangeEvent([file]) + mocks.prepareSkillUploadFile.mockRejectedValueOnce(new Error('prepare failed')) + + const { result } = renderHook(() => useCreateOperations({ + parentId: null, + appId: 'app-prepare-error', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFileChange(event) + }) + + expect(mocks.uploadMutateAsync).not.toHaveBeenCalled() + expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') + expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error') + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(event.target.value).toBe('') + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: folder upload handler should build nested tree payload and handle success/failure branches. + describe('handleFolderChange', () => { + it('should close menu and no-op when no folder files are selected', async () => { + const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi() + const onClose = vi.fn() + const event = createInputChangeEvent([]) + const { result } = renderHook(() => useCreateOperations({ + parentId: 'parent-empty-folder', + appId: 'app-empty-folder', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFolderChange(event) + }) + + expect(setUploadStatus).not.toHaveBeenCalled() + expect(setUploadProgress).not.toHaveBeenCalled() + expect(mocks.batchMutateAsync).not.toHaveBeenCalled() + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledTimes(1) + expect(event.target.value).toBe('selected') + }) + + it('should batch upload folder files, update progress callback, and emit success update', async () => { + const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi() + const onClose = vi.fn() + const fileA = withRelativePath(new File(['a'], 'a.md', { type: 'text/markdown' }), 'docs/a.md') + const fileB = withRelativePath(new File(['b'], 'b.txt', { type: 'text/plain' }), 'docs/nested/b.txt') + const rootFile = new File(['root'], 'root.md', { type: 'text/markdown' }) + const event = createInputChangeEvent([fileA, fileB, rootFile]) + + mocks.batchMutateAsync.mockImplementationOnce(async ({ onProgress }) => { + onProgress?.(1, 3) + onProgress?.(3, 3) + return [] + }) + + const { result } = renderHook(() => useCreateOperations({ + parentId: 'folder-parent', + appId: 'app-folder', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFolderChange(event) + }) + + expect(mocks.batchMutateAsync).toHaveBeenCalledTimes(1) + const batchPayload = mocks.batchMutateAsync.mock.calls[0][0] + + expect(batchPayload.appId).toBe('app-folder') + expect(batchPayload.parentId).toBe('folder-parent') + expect(batchPayload.tree).toEqual([ + { + name: 'docs', + node_type: 'folder', + children: [ + { + name: 'a.md', + node_type: 'file', + size: fileA.size, + }, + { + name: 'nested', + node_type: 'folder', + children: [ + { + name: 'b.txt', + node_type: 'file', + size: fileB.size, + }, + ], + }, + ], + }, + { + name: 'root.md', + node_type: 'file', + size: rootFile.size, + }, + ]) + expect([...batchPayload.files.keys()]).toEqual(['docs/a.md', 'docs/nested/b.txt', 'root.md']) + expect(batchPayload.files.get('docs/a.md')).toBe(fileA) + expect(batchPayload.files.get('docs/nested/b.txt')).toBe(fileB) + expect(batchPayload.files.get('root.md')).toBe(rootFile) + + expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') + expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'success') + expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 3, failed: 0 }) + expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 3, total: 3, failed: 0 }) + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(event.target.value).toBe('') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should set partial_error when batch upload fails', async () => { + const { storeApi, setUploadStatus } = createStoreApi() + const onClose = vi.fn() + const file = withRelativePath(new File(['f'], 'f.md', { type: 'text/markdown' }), 'folder/f.md') + const event = createInputChangeEvent([file]) + mocks.batchMutateAsync.mockRejectedValueOnce(new Error('batch failed')) + + const { result } = renderHook(() => useCreateOperations({ + parentId: null, + appId: 'app-folder-error', + storeApi, + onClose, + })) + + await act(async () => { + await result.current.handleFolderChange(event) + }) + + expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading') + expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error') + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(event.target.value).toBe('') + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.spec.tsx new file mode 100644 index 0000000000..c30fc222d5 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.spec.tsx @@ -0,0 +1,173 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { useDownloadOperation } from './use-download-operation' + +type DownloadRequest = { + params: { + appId: string + nodeId: string + } +} + +type DownloadResponse = { + download_url: string +} + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const { + mockGetFileDownloadUrl, + mockDownloadUrl, + mockToastNotify, +} = vi.hoisted(() => ({ + mockGetFileDownloadUrl: vi.fn<(request: DownloadRequest) => Promise>(), + mockDownloadUrl: vi.fn<(payload: { url: string, fileName?: string }) => void>(), + mockToastNotify: vi.fn<(payload: { type: string, message: string }) => void>(), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + appAsset: { + getFileDownloadUrl: mockGetFileDownloadUrl, + }, + }, +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: mockDownloadUrl, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mockToastNotify, + }, +})) + +describe('useDownloadOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetFileDownloadUrl.mockResolvedValue({ download_url: 'https://example.com/file.txt' }) + }) + + // Scenario: hook should no-op when required identifiers are missing. + describe('Guards', () => { + it('should not call download API when appId or nodeId is missing', async () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDownloadOperation({ + appId: '', + nodeId: '', + onClose, + })) + + await act(async () => { + await result.current.handleDownload() + }) + + expect(onClose).not.toHaveBeenCalled() + expect(mockGetFileDownloadUrl).not.toHaveBeenCalled() + expect(mockDownloadUrl).not.toHaveBeenCalled() + expect(result.current.isDownloading).toBe(false) + }) + }) + + // Scenario: successful downloads should fetch URL and trigger browser download. + describe('Success', () => { + it('should download file when API call succeeds', async () => { + const onClose = vi.fn() + const { result } = renderHook(() => useDownloadOperation({ + appId: 'app-1', + nodeId: 'node-1', + fileName: 'notes.md', + onClose, + })) + + await act(async () => { + await result.current.handleDownload() + }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockGetFileDownloadUrl).toHaveBeenCalledWith({ + params: { + appId: 'app-1', + nodeId: 'node-1', + }, + }) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/file.txt', + fileName: 'notes.md', + }) + expect(mockToastNotify).not.toHaveBeenCalled() + expect(result.current.isDownloading).toBe(false) + }) + + it('should set isDownloading true while download request is pending', async () => { + const deferred = createDeferred() + mockGetFileDownloadUrl.mockReturnValueOnce(deferred.promise) + const onClose = vi.fn() + + const { result } = renderHook(() => useDownloadOperation({ + appId: 'app-2', + nodeId: 'node-2', + onClose, + })) + + act(() => { + void result.current.handleDownload() + }) + + await waitFor(() => { + expect(result.current.isDownloading).toBe(true) + }) + + await act(async () => { + deferred.resolve({ download_url: 'https://example.com/slow.txt' }) + await deferred.promise + }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockDownloadUrl).toHaveBeenCalledWith({ + url: 'https://example.com/slow.txt', + fileName: undefined, + }) + expect(result.current.isDownloading).toBe(false) + }) + }) + + // Scenario: failed downloads should notify users and reset loading state. + describe('Error handling', () => { + it('should show error toast when download API fails', async () => { + mockGetFileDownloadUrl.mockRejectedValueOnce(new Error('network failure')) + const onClose = vi.fn() + const { result } = renderHook(() => useDownloadOperation({ + appId: 'app-3', + nodeId: 'node-3', + onClose, + })) + + await act(async () => { + await result.current.handleDownload() + }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockDownloadUrl).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.downloadError', + }) + expect(result.current.isDownloading).toBe(false) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-file-operations.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-file-operations.spec.tsx new file mode 100644 index 0000000000..68a77bde5d --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-file-operations.spec.tsx @@ -0,0 +1,335 @@ +import type { RefObject } from 'react' +import type { NodeApi, TreeApi } from 'react-arborist' +import type { StoreApi } from 'zustand' +import type { TreeNodeData } from '../../../type' +import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types' +import type { AppAssetTreeResponse } from '@/types/app-asset' +import { renderHook } from '@testing-library/react' +import { useFileOperations } from './use-file-operations' + +type AppStoreState = { + appDetail?: { + id: string + } | null +} + +type CreateOpsResult = { + fileInputRef: React.RefObject + folderInputRef: React.RefObject + handleNewFile: () => void + handleNewFolder: () => void + handleFileChange: (e: React.ChangeEvent) => Promise + handleFolderChange: (e: React.ChangeEvent) => Promise + isCreating: boolean +} + +type ModifyOpsResult = { + showDeleteConfirm: boolean + handleRename: () => void + handleDeleteClick: () => void + handleDeleteConfirm: () => Promise + handleDeleteCancel: () => void + isDeleting: boolean +} + +type DownloadOpsResult = { + handleDownload: () => Promise + isDownloading: boolean +} + +const createDefaultCreateOps = (): CreateOpsResult => ({ + fileInputRef: { current: null } as React.RefObject, + folderInputRef: { current: null } as React.RefObject, + handleNewFile: vi.fn(), + handleNewFolder: vi.fn(), + handleFileChange: vi.fn(async () => undefined), + handleFolderChange: vi.fn(async () => undefined), + isCreating: false, +}) + +const createDefaultModifyOps = (): ModifyOpsResult => ({ + showDeleteConfirm: false, + handleRename: vi.fn(), + handleDeleteClick: vi.fn(), + handleDeleteConfirm: vi.fn(async () => undefined), + handleDeleteCancel: vi.fn(), + isDeleting: false, +}) + +const createDefaultDownloadOps = (): DownloadOpsResult => ({ + handleDownload: vi.fn(async () => undefined), + isDownloading: false, +}) + +const mocks = vi.hoisted(() => { + const workflowStore = {} as StoreApi + const fileInputRef = { current: null } as React.RefObject + const folderInputRef = { current: null } as React.RefObject + return { + appStoreState: { + appDetail: { id: 'app-1' }, + } as AppStoreState, + workflowStore, + treeData: { + children: [], + } as AppAssetTreeResponse, + toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(), + createOpsHook: vi.fn<(options: { + parentId: string | null + appId: string + storeApi: StoreApi + onClose: () => void + }) => CreateOpsResult>(), + modifyOpsHook: vi.fn<(options: { + nodeId: string + node?: NodeApi + treeRef?: RefObject | null> + appId: string + storeApi: StoreApi + treeData?: AppAssetTreeResponse + onClose: () => void + }) => ModifyOpsResult>(), + downloadOpsHook: vi.fn<(options: { + appId: string + nodeId: string + fileName?: string + onClose: () => void + }) => DownloadOpsResult>(), + createOpsResult: { + fileInputRef, + folderInputRef, + handleNewFile: vi.fn<() => void>(), + handleNewFolder: vi.fn<() => void>(), + handleFileChange: vi.fn<(e: React.ChangeEvent) => Promise>(async () => undefined), + handleFolderChange: vi.fn<(e: React.ChangeEvent) => Promise>(async () => undefined), + isCreating: false, + } as CreateOpsResult, + modifyOpsResult: { + showDeleteConfirm: false, + handleRename: vi.fn<() => void>(), + handleDeleteClick: vi.fn<() => void>(), + handleDeleteConfirm: vi.fn<() => Promise>(async () => undefined), + handleDeleteCancel: vi.fn<() => void>(), + isDeleting: false, + } as ModifyOpsResult, + downloadOpsResult: { + handleDownload: vi.fn<() => Promise>(async () => undefined), + isDownloading: false, + } as DownloadOpsResult, + } +}) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => mocks.workflowStore, +})) + +vi.mock('../data/use-skill-asset-tree', () => ({ + useSkillAssetTreeData: () => ({ + data: mocks.treeData, + }), +})) + +vi.mock('../../../utils/tree-utils', () => ({ + toApiParentId: mocks.toApiParentId, +})) + +vi.mock('./use-create-operations', () => ({ + useCreateOperations: (options: { + parentId: string | null + appId: string + storeApi: StoreApi + onClose: () => void + }) => mocks.createOpsHook(options), +})) + +vi.mock('./use-modify-operations', () => ({ + useModifyOperations: (options: { + nodeId: string + node?: NodeApi + treeRef?: RefObject | null> + appId: string + storeApi: StoreApi + treeData?: AppAssetTreeResponse + onClose: () => void + }) => mocks.modifyOpsHook(options), +})) + +vi.mock('./use-download-operation', () => ({ + useDownloadOperation: (options: { + appId: string + nodeId: string + fileName?: string + onClose: () => void + }) => mocks.downloadOpsHook(options), +})) + +const createNodeApi = (id: string, name: string): NodeApi => { + return { + data: { + id, + node_type: 'file', + name, + path: `/${id}`, + extension: 'md', + size: 1, + children: [], + }, + } as unknown as NodeApi +} + +describe('useFileOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.appStoreState.appDetail = { id: 'app-1' } + mocks.treeData = { children: [] } + mocks.toApiParentId.mockReturnValue('parent-api-id') + mocks.createOpsResult = createDefaultCreateOps() + mocks.modifyOpsResult = createDefaultModifyOps() + mocks.downloadOpsResult = createDefaultDownloadOps() + mocks.createOpsHook.mockImplementation(() => mocks.createOpsResult) + mocks.modifyOpsHook.mockImplementation(() => mocks.modifyOpsResult) + mocks.downloadOpsHook.mockImplementation(() => mocks.downloadOpsResult) + }) + + // Scenario: node id and wiring should prioritize selected node over explicit id. + describe('Hook wiring', () => { + it('should use node data id and pass expected options to child operation hooks', () => { + const node = createNodeApi('node-from-node', 'from-node.md') + const treeRef = { current: null } as RefObject | null> + const onClose = vi.fn() + + const { result } = renderHook(() => useFileOperations({ + nodeId: 'explicit-node', + node, + treeRef, + onClose, + })) + + expect(mocks.toApiParentId).toHaveBeenCalledWith('node-from-node') + expect(mocks.createOpsHook).toHaveBeenCalledWith({ + parentId: 'parent-api-id', + appId: 'app-1', + storeApi: mocks.workflowStore, + onClose, + }) + expect(mocks.modifyOpsHook).toHaveBeenCalledWith({ + nodeId: 'node-from-node', + node, + treeRef, + appId: 'app-1', + storeApi: mocks.workflowStore, + treeData: mocks.treeData, + onClose, + }) + expect(mocks.downloadOpsHook).toHaveBeenCalledWith({ + appId: 'app-1', + nodeId: 'node-from-node', + fileName: 'from-node.md', + onClose, + }) + + expect(result.current.handleNewFile).toBe(mocks.createOpsResult.handleNewFile) + expect(result.current.handleRename).toBe(mocks.modifyOpsResult.handleRename) + expect(result.current.handleDownload).toBe(mocks.downloadOpsResult.handleDownload) + }) + + it('should fallback to explicit nodeId when node is not provided', () => { + const onClose = vi.fn() + + renderHook(() => useFileOperations({ + nodeId: 'explicit-only', + onClose, + })) + + expect(mocks.toApiParentId).toHaveBeenCalledWith('explicit-only') + expect(mocks.downloadOpsHook).toHaveBeenCalledWith({ + appId: 'app-1', + nodeId: 'explicit-only', + fileName: undefined, + onClose, + }) + }) + + it('should fallback to empty nodeId when both node and explicit nodeId are missing', () => { + const onClose = vi.fn() + + renderHook(() => useFileOperations({ + onClose, + })) + + expect(mocks.toApiParentId).toHaveBeenCalledWith('') + expect(mocks.modifyOpsHook).toHaveBeenCalledWith({ + nodeId: '', + node: undefined, + treeRef: undefined, + appId: 'app-1', + storeApi: mocks.workflowStore, + treeData: mocks.treeData, + onClose, + }) + }) + }) + + // Scenario: returned values should pass through child hook outputs and aggregate loading state. + describe('Return shape', () => { + it('should expose all operation handlers and refs from composed hooks', () => { + const onClose = vi.fn() + + const { result } = renderHook(() => useFileOperations({ onClose })) + + expect(result.current.fileInputRef).toBe(mocks.createOpsResult.fileInputRef) + expect(result.current.folderInputRef).toBe(mocks.createOpsResult.folderInputRef) + expect(result.current.handleNewFile).toBe(mocks.createOpsResult.handleNewFile) + expect(result.current.handleNewFolder).toBe(mocks.createOpsResult.handleNewFolder) + expect(result.current.handleFileChange).toBe(mocks.createOpsResult.handleFileChange) + expect(result.current.handleFolderChange).toBe(mocks.createOpsResult.handleFolderChange) + expect(result.current.showDeleteConfirm).toBe(mocks.modifyOpsResult.showDeleteConfirm) + expect(result.current.handleRename).toBe(mocks.modifyOpsResult.handleRename) + expect(result.current.handleDeleteClick).toBe(mocks.modifyOpsResult.handleDeleteClick) + expect(result.current.handleDeleteConfirm).toBe(mocks.modifyOpsResult.handleDeleteConfirm) + expect(result.current.handleDeleteCancel).toBe(mocks.modifyOpsResult.handleDeleteCancel) + expect(result.current.handleDownload).toBe(mocks.downloadOpsResult.handleDownload) + expect(result.current.isDeleting).toBe(mocks.modifyOpsResult.isDeleting) + expect(result.current.isDownloading).toBe(mocks.downloadOpsResult.isDownloading) + }) + + it('should compute isLoading as false when all child hooks are idle', () => { + const { result } = renderHook(() => useFileOperations({ onClose: vi.fn() })) + + expect(result.current.isLoading).toBe(false) + }) + + it.each([ + { + name: 'create operation is pending', + isCreating: true, + isDeleting: false, + isDownloading: false, + }, + { + name: 'delete operation is pending', + isCreating: false, + isDeleting: true, + isDownloading: false, + }, + { + name: 'download operation is pending', + isCreating: false, + isDeleting: false, + isDownloading: true, + }, + ])('should compute isLoading as true when $name', ({ isCreating, isDeleting, isDownloading }) => { + mocks.createOpsResult.isCreating = isCreating + mocks.modifyOpsResult.isDeleting = isDeleting + mocks.downloadOpsResult.isDownloading = isDownloading + + const { result } = renderHook(() => useFileOperations({ onClose: vi.fn() })) + + expect(result.current.isLoading).toBe(true) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-modify-operations.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-modify-operations.spec.tsx new file mode 100644 index 0000000000..612dcd2cb5 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-modify-operations.spec.tsx @@ -0,0 +1,338 @@ +import type { RefObject } from 'react' +import type { NodeApi, TreeApi } from 'react-arborist' +import type { StoreApi } from 'zustand' +import type { TreeNodeData } from '../../../type' +import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types' +import type { AppAssetTreeResponse } from '@/types/app-asset' +import { act, renderHook } from '@testing-library/react' +import { useModifyOperations } from './use-modify-operations' + +type DeleteMutationPayload = { + appId: string + nodeId: string +} + +const mocks = vi.hoisted(() => ({ + deletePending: false, + deleteMutateAsync: vi.fn<(payload: DeleteMutationPayload) => Promise>(), + emitTreeUpdate: vi.fn<() => void>(), + toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(), + getAllDescendantFileIds: vi.fn<(nodeId: string, nodes: TreeNodeData[]) => string[]>(), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useDeleteAppAssetNode: () => ({ + mutateAsync: mocks.deleteMutateAsync, + isPending: mocks.deletePending, + }), +})) + +vi.mock('../data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, +})) + +vi.mock('../../../utils/tree-utils', () => ({ + getAllDescendantFileIds: mocks.getAllDescendantFileIds, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mocks.toastNotify, + }, +})) + +const createTreeNodeData = (id: string, nodeType: 'file' | 'folder', children: TreeNodeData[] = []): TreeNodeData => ({ + id, + node_type: nodeType, + name: nodeType === 'folder' ? `folder-${id}` : `${id}.md`, + path: `/${id}`, + extension: nodeType === 'folder' ? '' : 'md', + size: 1, + children, +}) + +const createNodeApi = (nodeType: 'file' | 'folder', id = 'node-1') => { + const edit = vi.fn() + const node = { + data: createTreeNodeData(id, nodeType), + edit, + } as unknown as NodeApi + return { node, edit } +} + +const createTreeRef = (targetNode: NodeApi | null) => { + const get = vi.fn<(nodeId: string) => NodeApi | null>().mockReturnValue(targetNode) + const treeRef = { + current: { + get, + }, + } as unknown as RefObject | null> + return { treeRef, get } +} + +const createStoreApi = () => { + const closeTab = vi.fn<(fileId: string) => void>() + const clearDraftContent = vi.fn<(fileId: string) => void>() + const state = { + closeTab, + clearDraftContent, + } as Pick + + const storeApi = { + getState: () => state, + } as unknown as StoreApi + + return { + storeApi, + closeTab, + clearDraftContent, + } +} + +describe('useModifyOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.deletePending = false + mocks.deleteMutateAsync.mockResolvedValue(undefined) + mocks.getAllDescendantFileIds.mockReturnValue([]) + }) + + // Scenario: loading state should match mutation pending status. + describe('State', () => { + it('should expose mutation pending state as isDeleting', () => { + mocks.deletePending = true + const { storeApi } = createStoreApi() + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'node-1', + appId: 'app-1', + storeApi, + onClose: vi.fn(), + })) + + expect(result.current.isDeleting).toBe(true) + }) + }) + + // Scenario: rename action should prefer treeRef editing and fallback to node editing. + describe('Rename', () => { + it('should edit node from treeRef when treeRef is available', () => { + const { storeApi } = createStoreApi() + const onClose = vi.fn() + const { node: treeNode, edit: treeNodeEdit } = createNodeApi('file', 'tree-node') + const { treeRef, get } = createTreeRef(treeNode) + const { node: fallbackNode, edit: fallbackEdit } = createNodeApi('file', 'fallback-node') + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'tree-node', + node: fallbackNode, + treeRef, + appId: 'app-1', + storeApi, + onClose, + })) + + act(() => { + result.current.handleRename() + }) + + expect(get).toHaveBeenCalledWith('tree-node') + expect(treeNodeEdit).toHaveBeenCalledTimes(1) + expect(fallbackEdit).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should fallback to provided node edit when treeRef is absent', () => { + const { storeApi } = createStoreApi() + const onClose = vi.fn() + const { node, edit } = createNodeApi('folder', 'folder-2') + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'folder-2', + node, + appId: 'app-1', + storeApi, + onClose, + })) + + act(() => { + result.current.handleRename() + }) + + expect(edit).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: delete confirm dialog toggles with click/cancel handlers. + describe('Delete dialog state', () => { + it('should open and close delete confirmation dialog', () => { + const { storeApi } = createStoreApi() + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'node-1', + appId: 'app-1', + storeApi, + onClose: vi.fn(), + })) + + expect(result.current.showDeleteConfirm).toBe(false) + + act(() => { + result.current.handleDeleteClick() + }) + + expect(result.current.showDeleteConfirm).toBe(true) + + act(() => { + result.current.handleDeleteCancel() + }) + + expect(result.current.showDeleteConfirm).toBe(false) + }) + }) + + // Scenario: successful deletes should close tabs/drafts and emit collaboration updates. + describe('Delete success', () => { + it('should delete file node, clear descendants and current file tabs, and show file success toast', async () => { + const { storeApi, closeTab, clearDraftContent } = createStoreApi() + const onClose = vi.fn() + const { node } = createNodeApi('file', 'file-7') + const treeData: AppAssetTreeResponse = { + children: [createTreeNodeData('root-folder', 'folder')], + } + mocks.getAllDescendantFileIds.mockReturnValue(['desc-1', 'desc-2']) + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'file-7', + node, + appId: 'app-77', + storeApi, + treeData, + onClose, + })) + + act(() => { + result.current.handleDeleteClick() + }) + + await act(async () => { + await result.current.handleDeleteConfirm() + }) + + expect(mocks.getAllDescendantFileIds).toHaveBeenCalledWith('file-7', treeData.children) + expect(mocks.deleteMutateAsync).toHaveBeenCalledWith({ + appId: 'app-77', + nodeId: 'file-7', + }) + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + + expect(closeTab).toHaveBeenNthCalledWith(1, 'desc-1') + expect(closeTab).toHaveBeenNthCalledWith(2, 'desc-2') + expect(closeTab).toHaveBeenNthCalledWith(3, 'file-7') + expect(clearDraftContent).toHaveBeenNthCalledWith(1, 'desc-1') + expect(clearDraftContent).toHaveBeenNthCalledWith(2, 'desc-2') + expect(clearDraftContent).toHaveBeenNthCalledWith(3, 'file-7') + + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.skillSidebar.menu.fileDeleted', + }) + expect(result.current.showDeleteConfirm).toBe(false) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should delete folder node and skip closing the folder tab itself', async () => { + const { storeApi, closeTab, clearDraftContent } = createStoreApi() + const { node } = createNodeApi('folder', 'folder-9') + const treeData: AppAssetTreeResponse = { + children: [createTreeNodeData('root-folder', 'folder')], + } + mocks.getAllDescendantFileIds.mockReturnValue(['file-in-folder']) + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'folder-9', + node, + appId: 'app-9', + storeApi, + treeData, + onClose: vi.fn(), + })) + + await act(async () => { + await result.current.handleDeleteConfirm() + }) + + expect(closeTab).toHaveBeenCalledTimes(1) + expect(closeTab).toHaveBeenCalledWith('file-in-folder') + expect(clearDraftContent).toHaveBeenCalledTimes(1) + expect(clearDraftContent).toHaveBeenCalledWith('file-in-folder') + expect(closeTab).not.toHaveBeenCalledWith('folder-9') + expect(clearDraftContent).not.toHaveBeenCalledWith('folder-9') + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.skillSidebar.menu.deleted', + }) + }) + }) + + // Scenario: failed deletes should surface proper error toasts and always close dialog. + describe('Delete errors', () => { + it('should show folder delete error toast on failure', async () => { + mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed')) + const { storeApi, closeTab, clearDraftContent } = createStoreApi() + const onClose = vi.fn() + const { node } = createNodeApi('folder', 'folder-err') + const treeData: AppAssetTreeResponse = { + children: [createTreeNodeData('top', 'folder')], + } + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'folder-err', + node, + appId: 'app-err', + storeApi, + treeData, + onClose, + })) + + await act(async () => { + await result.current.handleDeleteConfirm() + }) + + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + expect(clearDraftContent).not.toHaveBeenCalled() + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.deleteError', + }) + expect(result.current.showDeleteConfirm).toBe(false) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should show file delete error toast and skip descendant lookup when treeData is missing', async () => { + mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed')) + const { storeApi } = createStoreApi() + const { node } = createNodeApi('file', 'file-err') + + const { result } = renderHook(() => useModifyOperations({ + nodeId: 'file-err', + node, + appId: 'app-err', + storeApi, + onClose: vi.fn(), + })) + + await act(async () => { + await result.current.handleDeleteConfirm() + }) + + expect(mocks.getAllDescendantFileIds).not.toHaveBeenCalled() + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.fileDeleteError', + }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-node-move.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-node-move.spec.tsx new file mode 100644 index 0000000000..40c6c4ee64 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-node-move.spec.tsx @@ -0,0 +1,135 @@ +import { act, renderHook } from '@testing-library/react' +import { useNodeMove } from './use-node-move' + +type AppStoreState = { + appDetail?: { + id: string + } | null +} + +type MoveMutationPayload = { + appId: string + nodeId: string + payload: { + parent_id: string | null + } +} + +const mocks = vi.hoisted(() => ({ + appStoreState: { + appDetail: { id: 'app-1' }, + } as AppStoreState, + movePending: false, + moveMutateAsync: vi.fn<(payload: MoveMutationPayload) => Promise>(), + emitTreeUpdate: vi.fn<() => void>(), + toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(), + toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useMoveAppAssetNode: () => ({ + mutateAsync: mocks.moveMutateAsync, + isPending: mocks.movePending, + }), +})) + +vi.mock('../data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, +})) + +vi.mock('../../../utils/tree-utils', () => ({ + toApiParentId: mocks.toApiParentId, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mocks.toastNotify, + }, +})) + +describe('useNodeMove', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.appStoreState.appDetail = { id: 'app-1' } + mocks.movePending = false + mocks.moveMutateAsync.mockResolvedValue(undefined) + mocks.toApiParentId.mockImplementation(folderId => folderId ?? null) + }) + + // Scenario: loading state should mirror mutation pending state. + describe('State', () => { + it('should expose mutation pending state as isMoving', () => { + mocks.movePending = true + + const { result } = renderHook(() => useNodeMove()) + + expect(result.current.isMoving).toBe(true) + }) + }) + + // Scenario: successful move should call API, emit update, and show success toast. + describe('Success', () => { + it('should move node and emit collaboration update when API succeeds', async () => { + mocks.toApiParentId.mockReturnValueOnce('parent-api-id') + const { result } = renderHook(() => useNodeMove()) + + await act(async () => { + await result.current.executeMoveNode('node-11', 'folder-22') + }) + + expect(mocks.toApiParentId).toHaveBeenCalledWith('folder-22') + expect(mocks.moveMutateAsync).toHaveBeenCalledWith({ + appId: 'app-1', + nodeId: 'node-11', + payload: { + parent_id: 'parent-api-id', + }, + }) + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.skillSidebar.menu.moved', + }) + }) + + it('should use empty appId when app detail is unavailable', async () => { + mocks.appStoreState.appDetail = undefined + mocks.toApiParentId.mockReturnValueOnce(null) + const { result } = renderHook(() => useNodeMove()) + + await act(async () => { + await result.current.executeMoveNode('node-99', null) + }) + + expect(mocks.moveMutateAsync).toHaveBeenCalledWith({ + appId: '', + nodeId: 'node-99', + payload: { + parent_id: null, + }, + }) + }) + }) + + // Scenario: failed move should surface an error toast and skip update emission. + describe('Error handling', () => { + it('should show error toast when move fails', async () => { + mocks.moveMutateAsync.mockRejectedValueOnce(new Error('move failed')) + const { result } = renderHook(() => useNodeMove()) + + await act(async () => { + await result.current.executeMoveNode('node-7', 'folder-7') + }) + + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.moveError', + }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-node-reorder.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-node-reorder.spec.tsx new file mode 100644 index 0000000000..e42a7069f1 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-node-reorder.spec.tsx @@ -0,0 +1,126 @@ +import { act, renderHook } from '@testing-library/react' +import { useNodeReorder } from './use-node-reorder' + +type AppStoreState = { + appDetail?: { + id: string + } | null +} + +type ReorderMutationPayload = { + appId: string + nodeId: string + payload: { + after_node_id: string | null + } +} + +const mocks = vi.hoisted(() => ({ + appStoreState: { + appDetail: { id: 'app-10' }, + } as AppStoreState, + reorderPending: false, + reorderMutateAsync: vi.fn<(payload: ReorderMutationPayload) => Promise>(), + emitTreeUpdate: vi.fn<() => void>(), + toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useReorderAppAssetNode: () => ({ + mutateAsync: mocks.reorderMutateAsync, + isPending: mocks.reorderPending, + }), +})) + +vi.mock('../data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mocks.toastNotify, + }, +})) + +describe('useNodeReorder', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.appStoreState.appDetail = { id: 'app-10' } + mocks.reorderPending = false + mocks.reorderMutateAsync.mockResolvedValue(undefined) + }) + + // Scenario: loading state should mirror reorder mutation status. + describe('State', () => { + it('should expose mutation pending state as isReordering', () => { + mocks.reorderPending = true + + const { result } = renderHook(() => useNodeReorder()) + + expect(result.current.isReordering).toBe(true) + }) + }) + + // Scenario: successful reorder should call API, emit update, and notify success. + describe('Success', () => { + it('should reorder node with provided afterNodeId', async () => { + const { result } = renderHook(() => useNodeReorder()) + + await act(async () => { + await result.current.executeReorderNode('node-1', 'node-0') + }) + + expect(mocks.reorderMutateAsync).toHaveBeenCalledWith({ + appId: 'app-10', + nodeId: 'node-1', + payload: { + after_node_id: 'node-0', + }, + }) + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.skillSidebar.menu.moved', + }) + }) + + it('should use empty appId when app detail is missing', async () => { + mocks.appStoreState.appDetail = null + const { result } = renderHook(() => useNodeReorder()) + + await act(async () => { + await result.current.executeReorderNode('node-2', null) + }) + + expect(mocks.reorderMutateAsync).toHaveBeenCalledWith({ + appId: '', + nodeId: 'node-2', + payload: { + after_node_id: null, + }, + }) + }) + }) + + // Scenario: failed reorder should not emit update and should show error toast. + describe('Error handling', () => { + it('should show error toast when reorder fails', async () => { + mocks.reorderMutateAsync.mockRejectedValueOnce(new Error('reorder failed')) + const { result } = renderHook(() => useNodeReorder()) + + await act(async () => { + await result.current.executeReorderNode('node-3', 'node-1') + }) + + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.moveError', + }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-paste-operation.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-paste-operation.spec.tsx new file mode 100644 index 0000000000..7b1a4f4d60 --- /dev/null +++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-paste-operation.spec.tsx @@ -0,0 +1,381 @@ +import type { RefObject } from 'react' +import type { TreeApi } from 'react-arborist' +import type { TreeNodeData } from '../../../type' +import type { AppAssetTreeResponse } from '@/types/app-asset' +import { act, renderHook, waitFor } from '@testing-library/react' +import { usePasteOperation } from './use-paste-operation' + +type MoveMutationPayload = { + appId: string + nodeId: string + payload: { + parent_id: string | null + } +} + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +type WorkflowStoreState = { + clipboard: { + operation: 'cut' + nodeIds: Set + } | null + selectedTreeNodeId: string | null + clearClipboard: () => void +} + +type AppStoreState = { + appDetail?: { + id: string + } | null +} + +const mocks = vi.hoisted(() => ({ + workflowState: { + clipboard: null, + selectedTreeNodeId: null, + clearClipboard: vi.fn<() => void>(), + } as WorkflowStoreState, + appStoreState: { + appDetail: { id: 'app-1' }, + } as AppStoreState, + movePending: false, + moveMutateAsync: vi.fn<(payload: MoveMutationPayload) => Promise>(), + emitTreeUpdate: vi.fn<() => void>(), + toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(), + getTargetFolderIdFromSelection: vi.fn<(selectedId: string | null, nodes: TreeNodeData[]) => string>(), + toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(), + findNodeById: vi.fn<(nodes: TreeNodeData[], nodeId: string) => TreeNodeData | null>(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => mocks.workflowState, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState), +})) + +vi.mock('@/service/use-app-asset', () => ({ + useMoveAppAssetNode: () => ({ + mutateAsync: mocks.moveMutateAsync, + isPending: mocks.movePending, + }), +})) + +vi.mock('../data/use-skill-tree-collaboration', () => ({ + useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate, +})) + +vi.mock('../../../utils/tree-utils', () => ({ + getTargetFolderIdFromSelection: mocks.getTargetFolderIdFromSelection, + toApiParentId: mocks.toApiParentId, + findNodeById: mocks.findNodeById, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mocks.toastNotify, + }, +})) + +const createTreeNode = (id: string, nodeType: 'file' | 'folder'): TreeNodeData => ({ + id, + node_type: nodeType, + name: nodeType === 'folder' ? `folder-${id}` : `${id}.md`, + path: `/${id}`, + extension: nodeType === 'folder' ? '' : 'md', + size: 1, + children: [], +}) + +const createTreeRef = (selectedId?: string): RefObject | null> => { + const selectedNodes = selectedId ? [{ id: selectedId }] : [] + return { + current: { + selectedNodes, + }, + } as unknown as RefObject | null> +} + +describe('usePasteOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.workflowState.clipboard = null + mocks.workflowState.selectedTreeNodeId = null + mocks.appStoreState.appDetail = { id: 'app-1' } + mocks.movePending = false + mocks.moveMutateAsync.mockResolvedValue(undefined) + mocks.getTargetFolderIdFromSelection.mockReturnValue('target-folder') + mocks.toApiParentId.mockReturnValue('target-parent') + mocks.findNodeById.mockReturnValue(null) + }) + + // Scenario: isPasting output should reflect mutation pending state. + describe('State', () => { + it('should expose mutation pending state as isPasting', () => { + mocks.movePending = true + const treeRef = createTreeRef('selected') + + const { result } = renderHook(() => usePasteOperation({ treeRef })) + + expect(result.current.isPasting).toBe(true) + }) + }) + + // Scenario: guard clauses should skip paste work when clipboard is unavailable. + describe('Guards', () => { + it('should no-op when clipboard is empty', async () => { + const treeRef = createTreeRef('selected') + const { result } = renderHook(() => usePasteOperation({ treeRef })) + + await act(async () => { + await result.current.handlePaste() + }) + + expect(mocks.getTargetFolderIdFromSelection).not.toHaveBeenCalled() + expect(mocks.moveMutateAsync).not.toHaveBeenCalled() + expect(mocks.toastNotify).not.toHaveBeenCalled() + }) + + it('should no-op when clipboard has no node ids', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(), + } + const treeRef = createTreeRef('selected') + const { result } = renderHook(() => usePasteOperation({ treeRef })) + + await act(async () => { + await result.current.handlePaste() + }) + + expect(mocks.getTargetFolderIdFromSelection).not.toHaveBeenCalled() + expect(mocks.moveMutateAsync).not.toHaveBeenCalled() + }) + + it('should reject moving folder into itself and show error toast', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['folder-1']), + } + mocks.getTargetFolderIdFromSelection.mockReturnValueOnce('folder-1') + mocks.findNodeById.mockReturnValueOnce(createTreeNode('folder-1', 'folder')) + const treeRef = createTreeRef('folder-1') + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('folder-1', 'folder')], + } + const { result } = renderHook(() => usePasteOperation({ treeRef, treeData })) + + await act(async () => { + await result.current.handlePaste() + }) + + expect(mocks.moveMutateAsync).not.toHaveBeenCalled() + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.cannotMoveToSelf', + }) + }) + }) + + // Scenario: successful cut-paste should move all nodes and clear clipboard. + describe('Success', () => { + it('should move cut nodes, clear clipboard, and emit update', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['node-1', 'node-2']), + } + const treeRef = createTreeRef('selected-node') + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('node-1', 'file'), createTreeNode('node-2', 'file')], + } + + const { result } = renderHook(() => usePasteOperation({ treeRef, treeData })) + + await act(async () => { + await result.current.handlePaste() + }) + + expect(mocks.getTargetFolderIdFromSelection).toHaveBeenCalledWith('selected-node', treeData.children) + expect(mocks.toApiParentId).toHaveBeenCalledWith('target-folder') + expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(2) + expect(mocks.moveMutateAsync).toHaveBeenNthCalledWith(1, { + appId: 'app-1', + nodeId: 'node-1', + payload: { + parent_id: 'target-parent', + }, + }) + expect(mocks.moveMutateAsync).toHaveBeenNthCalledWith(2, { + appId: 'app-1', + nodeId: 'node-2', + payload: { + parent_id: 'target-parent', + }, + }) + expect(mocks.workflowState.clearClipboard).toHaveBeenCalledTimes(1) + expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1) + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.skillSidebar.menu.moved', + }) + }) + + it('should fallback to selectedTreeNodeId when tree has no selected node', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['node-store']), + } + mocks.workflowState.selectedTreeNodeId = 'store-selected' + const treeRef = createTreeRef() + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('node-store', 'file')], + } + const { result } = renderHook(() => usePasteOperation({ treeRef, treeData })) + + await act(async () => { + await result.current.handlePaste() + }) + + expect(mocks.getTargetFolderIdFromSelection).toHaveBeenCalledWith('store-selected', treeData.children) + expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: failed paste should keep clipboard and show error toast. + describe('Error handling', () => { + it('should show move error toast when API call fails', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['node-error']), + } + mocks.moveMutateAsync.mockRejectedValueOnce(new Error('move failed')) + const treeRef = createTreeRef('target') + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('node-error', 'file')], + } + + const { result } = renderHook(() => usePasteOperation({ treeRef, treeData })) + + await act(async () => { + await result.current.handlePaste() + }) + + expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled() + expect(mocks.emitTreeUpdate).not.toHaveBeenCalled() + expect(mocks.toastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.skillSidebar.menu.moveError', + }) + }) + + it('should prevent re-entrant paste while a paste is in progress', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['node-slow']), + } + const deferred = createDeferred() + mocks.moveMutateAsync.mockReturnValueOnce(deferred.promise) + const treeRef = createTreeRef('target') + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('node-slow', 'file')], + } + + const { result } = renderHook(() => usePasteOperation({ treeRef, treeData })) + + act(() => { + void result.current.handlePaste() + void result.current.handlePaste() + }) + + expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1) + + await act(async () => { + deferred.resolve(undefined) + await deferred.promise + }) + }) + }) + + // Scenario: enabled flag should control window event listener lifecycle. + describe('Window event integration', () => { + it('should register and cleanup paste listener when enabled', () => { + const addListenerSpy = vi.spyOn(window, 'addEventListener') + const removeListenerSpy = vi.spyOn(window, 'removeEventListener') + const treeRef = createTreeRef('selected') + + const { unmount } = renderHook(() => usePasteOperation({ treeRef, enabled: true })) + + const addCall = addListenerSpy.mock.calls.find(call => String(call[0]) === 'skill:paste') + expect(addCall).toBeDefined() + + unmount() + + const removeCall = removeListenerSpy.mock.calls.find(call => String(call[0]) === 'skill:paste') + expect(removeCall).toBeDefined() + expect(removeCall?.[1]).toBe(addCall?.[1]) + + addListenerSpy.mockRestore() + removeListenerSpy.mockRestore() + }) + + it('should trigger paste handler when skill:paste event is dispatched and enabled', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['node-event']), + } + const treeRef = createTreeRef('selected') + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('node-event', 'file')], + } + + renderHook(() => usePasteOperation({ treeRef, treeData, enabled: true })) + + act(() => { + window.dispatchEvent(new Event('skill:paste')) + }) + + await waitFor(() => { + expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1) + }) + }) + + it('should ignore skill:paste event when disabled', async () => { + mocks.workflowState.clipboard = { + operation: 'cut', + nodeIds: new Set(['node-disabled']), + } + const treeRef = createTreeRef('selected') + const treeData: AppAssetTreeResponse = { + children: [createTreeNode('node-disabled', 'file')], + } + + renderHook(() => usePasteOperation({ treeRef, treeData, enabled: false })) + + act(() => { + window.dispatchEvent(new Event('skill:paste')) + }) + + await waitFor(() => { + expect(mocks.moveMutateAsync).not.toHaveBeenCalled() + }) + }) + }) +}) diff --git a/web/app/components/workflow/skill/skill-body/tabs/file-tab-item.spec.tsx b/web/app/components/workflow/skill/skill-body/tabs/file-tab-item.spec.tsx new file mode 100644 index 0000000000..078a99f5b1 --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/tabs/file-tab-item.spec.tsx @@ -0,0 +1,100 @@ +import type { ComponentProps } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import FileTabItem from './file-tab-item' + +type FileTabItemProps = ComponentProps + +const createProps = (overrides: Partial = {}) => { + const onClick = vi.fn() + const onClose = vi.fn() + const onDoubleClick = vi.fn() + + const props: FileTabItemProps = { + fileId: 'file-1', + name: 'readme.md', + extension: 'md', + isActive: false, + isDirty: false, + isPreview: false, + onClick, + onClose, + onDoubleClick, + ...overrides, + } + + return { props, onClick, onClose, onDoubleClick } +} + +describe('FileTabItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior for the tab label and close action. + describe('Rendering', () => { + it('should render the file tab button and close button', () => { + const { props } = createProps() + + render() + + expect(screen.getByRole('button', { name: /readme\.md/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.operation\.close/i })).toBeInTheDocument() + }) + + it('should style the file name as preview when isPreview is true', () => { + const { props } = createProps({ isPreview: true }) + + render() + + expect(screen.getByText('readme.md')).toHaveClass('italic') + }) + }) + + // Pointer interactions should trigger the corresponding callbacks. + describe('Interactions', () => { + it('should call onClick with file id when the tab is clicked', () => { + const { props, onClick } = createProps() + + render() + fireEvent.click(screen.getByRole('button', { name: /readme\.md/i })) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onClick).toHaveBeenCalledWith('file-1') + }) + + it('should call onDoubleClick with file id when preview tab is double clicked', () => { + const { props, onDoubleClick } = createProps({ isPreview: true }) + + render() + fireEvent.doubleClick(screen.getByRole('button', { name: /readme\.md/i })) + + expect(onDoubleClick).toHaveBeenCalledTimes(1) + expect(onDoubleClick).toHaveBeenCalledWith('file-1') + }) + + it('should not call onDoubleClick when tab is not in preview mode', () => { + const { props, onDoubleClick } = createProps({ isPreview: false }) + + render() + fireEvent.doubleClick(screen.getByRole('button', { name: /readme\.md/i })) + + expect(onDoubleClick).not.toHaveBeenCalled() + }) + + it('should call onClose and stop propagation when close button is clicked', () => { + const parentClick = vi.fn() + const { props, onClose } = createProps() + + render( +
+ +
, + ) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledWith('file-1') + expect(parentClick).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx b/web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx new file mode 100644 index 0000000000..f6e1bd2c87 --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx @@ -0,0 +1,239 @@ +import type { AppAssetTreeView } from '@/types/app-asset' +import { fireEvent, render, screen } from '@testing-library/react' +import { makeArtifactTabId, START_TAB_ID } from '../../constants' +import FileTabs from './file-tabs' + +type MockWorkflowState = { + openTabIds: string[] + activeTabId: string | null + previewTabId: string | null + dirtyContents: Set + dirtyMetadataIds: Set +} + +const mocks = vi.hoisted(() => ({ + storeState: { + openTabIds: [], + activeTabId: '__start__', + previewTabId: null, + dirtyContents: new Set(), + dirtyMetadataIds: new Set(), + } as MockWorkflowState, + nodeMap: undefined as Map | undefined, + activateTab: vi.fn(), + pinTab: vi.fn(), + closeTab: vi.fn(), + clearDraftContent: vi.fn(), + clearFileMetadata: vi.fn(), + clearArtifactSelection: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState), + useWorkflowStore: () => ({ + getState: () => ({ + activateTab: mocks.activateTab, + pinTab: mocks.pinTab, + closeTab: mocks.closeTab, + clearDraftContent: mocks.clearDraftContent, + clearFileMetadata: mocks.clearFileMetadata, + clearArtifactSelection: mocks.clearArtifactSelection, + }), + }), +})) + +vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({ + useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }), +})) + +const createNode = (overrides: Partial = {}): AppAssetTreeView => ({ + id: 'file-1', + node_type: 'file', + name: 'guide.md', + path: '/guide.md', + extension: 'md', + size: 10, + children: [], + ...overrides, +}) + +const setMockState = (overrides: Partial = {}) => { + mocks.storeState.openTabIds = overrides.openTabIds ?? [] + mocks.storeState.activeTabId = overrides.activeTabId ?? START_TAB_ID + mocks.storeState.previewTabId = overrides.previewTabId ?? null + mocks.storeState.dirtyContents = overrides.dirtyContents ?? new Set() + mocks.storeState.dirtyMetadataIds = overrides.dirtyMetadataIds ?? new Set() +} + +const setMockNodeMap = (nodes: AppAssetTreeView[] = []) => { + mocks.nodeMap = new Map(nodes.map(node => [node.id, node])) +} + +describe('FileTabs', () => { + beforeEach(() => { + vi.clearAllMocks() + setMockState() + setMockNodeMap([]) + }) + + // Rendering behavior for start tab, file tabs, and fallback naming. + describe('Rendering', () => { + it('should render start tab and tabs for regular and artifact files', () => { + const artifactTabId = makeArtifactTabId('/assets/logo.png') + setMockState({ + openTabIds: ['file-1', artifactTabId], + activeTabId: 'file-1', + }) + setMockNodeMap([ + createNode({ id: 'file-1', name: 'guide.md' }), + ]) + + render() + + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /guide\.md/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /logo\.png/i })).toBeInTheDocument() + }) + + it('should fall back to file id when node is missing from node map', () => { + setMockState({ + openTabIds: ['missing-file-id'], + activeTabId: 'missing-file-id', + }) + setMockNodeMap([]) + + render() + + expect(screen.getByRole('button', { name: /missing-file-id/i })).toBeInTheDocument() + }) + }) + + // Tab interactions should dispatch store actions. + describe('Tab actions', () => { + it('should activate the start tab when start tab is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })) + + expect(mocks.activateTab).toHaveBeenCalledTimes(1) + expect(mocks.activateTab).toHaveBeenCalledWith(START_TAB_ID) + }) + + it('should activate a file tab when a file tab is clicked', () => { + setMockState({ + openTabIds: ['file-1'], + activeTabId: START_TAB_ID, + }) + setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })]) + + render() + fireEvent.click(screen.getByRole('button', { name: /guide\.md/i })) + + expect(mocks.activateTab).toHaveBeenCalledTimes(1) + expect(mocks.activateTab).toHaveBeenCalledWith('file-1') + }) + + it('should pin a preview tab when it is double clicked', () => { + setMockState({ + openTabIds: ['file-1'], + activeTabId: 'file-1', + previewTabId: 'file-1', + }) + setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })]) + + render() + fireEvent.doubleClick(screen.getByRole('button', { name: /guide\.md/i })) + + expect(mocks.pinTab).toHaveBeenCalledTimes(1) + expect(mocks.pinTab).toHaveBeenCalledWith('file-1') + }) + + it('should close a clean file tab and clear draft and metadata', () => { + setMockState({ + openTabIds: ['file-1'], + activeTabId: 'file-1', + }) + setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })]) + + render() + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + + expect(mocks.closeTab).toHaveBeenCalledTimes(1) + expect(mocks.closeTab).toHaveBeenCalledWith('file-1') + expect(mocks.clearDraftContent).toHaveBeenCalledWith('file-1') + expect(mocks.clearFileMetadata).toHaveBeenCalledWith('file-1') + expect(mocks.clearArtifactSelection).not.toHaveBeenCalled() + }) + + it('should clear artifact selection before closing artifact tab', () => { + const artifactTabId = makeArtifactTabId('/assets/logo.png') + setMockState({ + openTabIds: [artifactTabId], + activeTabId: artifactTabId, + }) + + render() + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + + expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1) + expect(mocks.closeTab).toHaveBeenCalledWith(artifactTabId) + expect(mocks.clearDraftContent).toHaveBeenCalledWith(artifactTabId) + expect(mocks.clearFileMetadata).toHaveBeenCalledWith(artifactTabId) + }) + }) + + // Dirty tabs must show confirmation before closing. + describe('Unsaved changes confirmation', () => { + it('should show confirmation dialog instead of closing immediately for dirty tab', () => { + setMockState({ + openTabIds: ['file-1'], + activeTabId: 'file-1', + dirtyContents: new Set(['file-1']), + }) + setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })]) + + render() + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + + expect(mocks.closeTab).not.toHaveBeenCalled() + expect(screen.getByText('workflow.skillSidebar.unsavedChanges.title')).toBeInTheDocument() + }) + + it('should close the dirty tab when user confirms', () => { + setMockState({ + openTabIds: ['file-1'], + activeTabId: 'file-1', + dirtyMetadataIds: new Set(['file-1']), + }) + setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })]) + + render() + + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.unsavedChanges\.confirmClose/i })) + + expect(mocks.closeTab).toHaveBeenCalledTimes(1) + expect(mocks.closeTab).toHaveBeenCalledWith('file-1') + expect(mocks.clearDraftContent).toHaveBeenCalledWith('file-1') + expect(mocks.clearFileMetadata).toHaveBeenCalledWith('file-1') + }) + + it('should keep the tab open when user cancels the close confirmation', () => { + setMockState({ + openTabIds: ['file-1'], + activeTabId: 'file-1', + dirtyContents: new Set(['file-1']), + }) + setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })]) + + render() + + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i })) + + expect(mocks.closeTab).not.toHaveBeenCalled() + expect(mocks.clearDraftContent).not.toHaveBeenCalled() + expect(mocks.clearFileMetadata).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/skill/skill-body/tabs/start-tab-item.spec.tsx b/web/app/components/workflow/skill/skill-body/tabs/start-tab-item.spec.tsx new file mode 100644 index 0000000000..dab6de90cf --- /dev/null +++ b/web/app/components/workflow/skill/skill-body/tabs/start-tab-item.spec.tsx @@ -0,0 +1,61 @@ +import type { ComponentProps } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import StartTabItem from './start-tab-item' + +type StartTabItemProps = ComponentProps + +const createProps = (overrides: Partial = {}) => { + const onClick = vi.fn() + const props: StartTabItemProps = { + isActive: false, + onClick, + ...overrides, + } + + return { props, onClick } +} + +describe('StartTabItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior for the start tab button and label. + describe('Rendering', () => { + it('should render the start tab button with translated label', () => { + const { props } = createProps() + + render() + + expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })).toBeInTheDocument() + }) + + it('should style the start label as active when isActive is true', () => { + const { props } = createProps({ isActive: true }) + + render() + + expect(screen.getByText('workflow.skillSidebar.startTab')).toHaveClass('text-text-primary') + }) + + it('should style the start label as inactive when isActive is false', () => { + const { props } = createProps({ isActive: false }) + + render() + + expect(screen.getByText('workflow.skillSidebar.startTab')).toHaveClass('text-text-tertiary') + }) + }) + + // Clicking the tab should delegate to the callback. + describe('Interactions', () => { + it('should call onClick when start tab is clicked', () => { + const { props, onClick } = createProps() + + render() + fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + }) +})