([['file-1', {
+ id: 'file-1',
+ node_type: 'file',
+ name: 'main.ts',
+ path: '/main.ts',
+ extension: 'ts',
+ size: 120,
+ children: [],
+ }]]),
+ nodeMapStatus: {
+ isLoading: false,
+ isFetching: false,
+ isFetched: true,
+ },
+ fileNodeViewState: 'ready' as FileNodeViewState,
+ fileTypeInfo: {
+ isMarkdown: false,
+ isCodeOrText: true,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: true,
+ isPreviewable: true,
+ } as FileTypeInfo,
+ fileData: {
+ fileContent: {
+ content: 'console.log("hello")',
+ metadata: {},
+ },
+ downloadUrlData: {
+ download_url: 'https://example.com/file',
+ },
+ isLoading: false,
+ error: null,
+ } as SkillFileDataResult,
+ appTheme: 'light' as Theme,
+ saveFile: vi.fn(),
+ registerFallback: vi.fn(),
+ unregisterFallback: vi.fn(),
+ useSkillFileData: vi.fn(),
+ useSkillMarkdownCollaboration: vi.fn(),
+ useSkillCodeCollaboration: vi.fn(),
+ getFileLanguage: vi.fn<(name: string) => string>(() => 'typescript'),
+}))
+
+vi.mock('@monaco-editor/react', () => ({
+ loader: {
+ config: (...args: unknown[]) => mocks.monacoLoaderConfig(...args),
+ },
+}))
+
+vi.mock('next/dynamic', () => ({
+ default: () => {
+ return ({ downloadUrl }: { downloadUrl: string }) => (
+ {downloadUrl}
+ )
+ },
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appState),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: WorkflowStoreState) => unknown) => selector(mocks.workflowState),
+ useWorkflowStore: () => ({
+ getState: () => mocks.workflowActions,
+ }),
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({ theme: mocks.appTheme }),
+}))
+
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useSkillAssetNodeMap: () => ({
+ data: mocks.nodeMapData,
+ isLoading: mocks.nodeMapStatus.isLoading,
+ isFetching: mocks.nodeMapStatus.isFetching,
+ isFetched: mocks.nodeMapStatus.isFetched,
+ }),
+}))
+
+vi.mock('../../hooks/use-file-node-view-state', () => ({
+ useFileNodeViewState: () => mocks.fileNodeViewState,
+}))
+
+vi.mock('../../hooks/use-file-type-info', () => ({
+ useFileTypeInfo: () => mocks.fileTypeInfo,
+}))
+
+vi.mock('../../hooks/use-skill-file-data', () => ({
+ useSkillFileData: (appId: string, fileId: string | null, mode: UseSkillFileDataMode) => {
+ mocks.useSkillFileData(appId, fileId, mode)
+ return mocks.fileData
+ },
+}))
+
+vi.mock('../../hooks/skill-save-context', () => ({
+ useSkillSaveManager: () => ({
+ saveFile: mocks.saveFile,
+ registerFallback: mocks.registerFallback,
+ unregisterFallback: mocks.unregisterFallback,
+ }),
+}))
+
+vi.mock('../../../collaboration/skills/use-skill-markdown-collaboration', () => ({
+ useSkillMarkdownCollaboration: (args: UseSkillMarkdownCollaborationArgs) => {
+ mocks.useSkillMarkdownCollaboration(args)
+ return {
+ handleCollaborativeChange: (value: string) => args.onLocalChange(value),
+ }
+ },
+}))
+
+vi.mock('../../../collaboration/skills/use-skill-code-collaboration', () => ({
+ useSkillCodeCollaboration: (args: UseSkillCodeCollaborationArgs) => {
+ mocks.useSkillCodeCollaboration(args)
+ return {
+ handleCollaborativeChange: (value: string | undefined) => args.onLocalChange(value ?? ''),
+ }
+ },
+}))
+
+vi.mock('../../start-tab', () => ({
+ default: () => ,
+}))
+
+vi.mock('../../editor/markdown-file-editor', () => ({
+ default: ({
+ value,
+ onChange,
+ autoFocus,
+ onAutoFocus,
+ collaborationEnabled,
+ }: {
+ value: string
+ onChange: (value: string) => void
+ autoFocus?: boolean
+ onAutoFocus?: () => void
+ collaborationEnabled?: boolean
+ }) => (
+
+ {`value:${value}`}
+ {`autoFocus:${String(Boolean(autoFocus))}`}
+ {`collaboration:${String(Boolean(collaborationEnabled))}`}
+
+
+
+
+ ),
+}))
+
+vi.mock('../../editor/code-file-editor', () => ({
+ default: ({
+ value,
+ onChange,
+ onMount,
+ onAutoFocus,
+ theme,
+ language,
+ }: {
+ value: string
+ onChange: (value: string | undefined) => void
+ onMount: OnMount
+ onAutoFocus?: () => void
+ theme: string
+ language: string
+ }) => (
+
+ {`value:${value}`}
+ {`theme:${theme}`}
+ {`language:${language}`}
+
+
+
+
+
+ ),
+}))
+
+vi.mock('../../viewer/media-file-preview', () => ({
+ default: ({ type, src }: { type: 'image' | 'video', src: string }) => (
+ {`${type}|${src}`}
+ ),
+}))
+
+vi.mock('../../viewer/unsupported-file-download', () => ({
+ default: ({ name, size, downloadUrl }: { name: string, size?: number, downloadUrl: string }) => (
+ {`${name}|${String(size)}|${downloadUrl}`}
+ ),
+}))
+
+vi.mock('../../utils/file-utils', async () => {
+ const actual = await vi.importActual('../../utils/file-utils')
+ return {
+ ...actual,
+ getFileLanguage: (name: string) => mocks.getFileLanguage(name),
+ }
+})
+
+describe('FileContentPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.appState.appDetail = { id: 'app-1' }
+ mocks.workflowState.activeTabId = 'file-1'
+ mocks.workflowState.editorAutoFocusFileId = null
+ mocks.workflowState.dirtyContents = new Map()
+ mocks.workflowState.fileMetadata = new Map>()
+ mocks.workflowState.dirtyMetadataIds = new Set()
+ mocks.workflowActions = createDefaultActions()
+ mocks.nodeMapData = new Map([['file-1', createNode()]])
+ mocks.nodeMapStatus = {
+ isLoading: false,
+ isFetching: false,
+ isFetched: true,
+ }
+ mocks.fileNodeViewState = 'ready'
+ mocks.fileTypeInfo = createDefaultFileTypeInfo()
+ mocks.fileData = createDefaultFileData()
+ mocks.appTheme = Theme.light
+ })
+
+ describe('Rendering states', () => {
+ it('should render start tab content when active tab is start tab', () => {
+ // Arrange
+ mocks.workflowState.activeTabId = START_TAB_ID
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('start-tab-content')).toBeInTheDocument()
+ expect(mocks.useSkillFileData).toHaveBeenCalledWith('app-1', null, 'none')
+ })
+
+ it('should render empty state when no file tab is selected', () => {
+ // Arrange
+ mocks.workflowState.activeTabId = null
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('workflow.skillSidebar.empty')).toBeInTheDocument()
+ })
+
+ it('should render loading indicator when file node is resolving', () => {
+ // Arrange
+ mocks.fileNodeViewState = 'resolving'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render load error when selected file is missing', () => {
+ // Arrange
+ mocks.fileNodeViewState = 'missing'
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('workflow.skillSidebar.loadError')).toBeInTheDocument()
+ })
+
+ it('should render loading indicator when file data is loading', () => {
+ // Arrange
+ mocks.fileData.isLoading = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render load error when file data query fails', () => {
+ // Arrange
+ mocks.fileData.error = new Error('failed')
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('workflow.skillSidebar.loadError')).toBeInTheDocument()
+ })
+ })
+
+ describe('Editor interactions', () => {
+ it('should render markdown editor and update draft metadata when content references files', async () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: true,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ mocks.workflowState.fileMetadata = new Map>([
+ ['file-1', {}],
+ ])
+ mocks.workflowState.editorAutoFocusFileId = 'file-1'
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
+ [FILE_REFERENCE_ID, createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' })],
+ ])
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'markdown-change' }))
+ fireEvent.click(screen.getByRole('button', { name: 'markdown-autofocus' }))
+
+ // Assert
+ expect(screen.getByTestId('markdown-editor')).toBeInTheDocument()
+ expect(mocks.workflowActions.setDraftContent).toHaveBeenCalledTimes(1)
+ expect(mocks.workflowActions.pinTab).toHaveBeenCalledWith('file-1')
+ expect(mocks.workflowActions.setDraftMetadata).toHaveBeenCalledWith(
+ 'file-1',
+ expect.objectContaining({
+ files: expect.objectContaining({
+ [FILE_REFERENCE_ID]: expect.objectContaining({ id: FILE_REFERENCE_ID }),
+ }),
+ }),
+ )
+ expect(mocks.workflowActions.clearEditorAutoFocus).toHaveBeenCalledWith('file-1')
+ })
+
+ it('should clear draft content when code editor value matches original content', () => {
+ // Arrange
+ mocks.fileData.fileContent = {
+ content: '',
+ metadata: {},
+ }
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'code-clear' }))
+
+ // Assert
+ expect(screen.getByTestId('code-editor')).toBeInTheDocument()
+ expect(mocks.workflowActions.clearDraftContent).toHaveBeenCalledWith('file-1')
+ expect(mocks.workflowActions.pinTab).toHaveBeenCalledWith('file-1')
+ })
+
+ it('should switch editor theme after monaco mount callback runs', async () => {
+ // Arrange
+ mocks.appTheme = Theme.light
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'code-mount' }))
+
+ // Assert
+ expect(mocks.setMonacoTheme).toHaveBeenCalledWith('light')
+ await waitFor(() => {
+ expect(screen.getByText('theme:light')).toBeInTheDocument()
+ })
+ })
+
+ it('should call save manager when collaboration leader sync is requested', () => {
+ // Arrange
+ render()
+ const firstCall = mocks.useSkillCodeCollaboration.mock.calls[0]
+ const args = firstCall?.[0] as { onLeaderSync: () => void } | undefined
+
+ // Act
+ args?.onLeaderSync()
+
+ // Assert
+ expect(mocks.saveFile).toHaveBeenCalledWith('file-1')
+ })
+
+ it('should ignore editor content updates when file is not editable', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: true,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isPreviewable: true,
+ }
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'code-change' }))
+
+ // Assert
+ expect(mocks.workflowActions.setDraftContent).not.toHaveBeenCalled()
+ expect(mocks.workflowActions.clearDraftContent).not.toHaveBeenCalled()
+ expect(mocks.workflowActions.pinTab).not.toHaveBeenCalled()
+ })
+
+ it('should skip leader sync save when file is not editable', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: true,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isPreviewable: true,
+ }
+ render()
+ const firstCall = mocks.useSkillCodeCollaboration.mock.calls[0]
+ const args = firstCall?.[0] as { onLeaderSync: () => void } | undefined
+
+ // Act
+ args?.onLeaderSync()
+
+ // Assert
+ expect(mocks.saveFile).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Preview modes', () => {
+ it('should render media preview and request download mode for image files', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: true,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isPreviewable: true,
+ }
+ mocks.fileData.downloadUrlData = { download_url: 'https://example.com/image.png' }
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'image.png', extension: 'png' })],
+ ])
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('media-preview')).toHaveTextContent('image|https://example.com/image.png')
+ expect(mocks.useSkillFileData).toHaveBeenCalledWith('app-1', 'file-1', 'download')
+ })
+
+ it('should render unsupported download panel for non-previewable files', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isPreviewable: false,
+ }
+ mocks.fileData.downloadUrlData = { download_url: 'https://example.com/archive.bin' }
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'archive.bin', extension: 'bin', size: 99 })],
+ ])
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('unsupported-preview')).toHaveTextContent('archive.bin|99|https://example.com/archive.bin')
+ })
+ })
+
+ describe('Metadata and save lifecycle', () => {
+ it('should sync metadata from file content when metadata is not dirty', async () => {
+ // Arrange
+ mocks.fileData.fileContent = {
+ content: 'markdown',
+ metadata: '{"source":"api"}',
+ }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(mocks.workflowActions.setFileMetadata).toHaveBeenCalledWith(
+ 'file-1',
+ expect.objectContaining({ source: 'api' }),
+ )
+ })
+ expect(mocks.workflowActions.clearDraftMetadata).toHaveBeenCalledWith('file-1')
+ })
+
+ it('should fallback to empty metadata when metadata json is invalid', async () => {
+ // Arrange
+ mocks.fileData.fileContent = {
+ content: 'markdown',
+ metadata: '{invalid-json}',
+ }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(mocks.workflowActions.setFileMetadata).toHaveBeenCalledWith('file-1', {})
+ })
+ expect(mocks.workflowActions.clearDraftMetadata).toHaveBeenCalledWith('file-1')
+ })
+
+ it('should skip metadata sync when current metadata is marked dirty', async () => {
+ // Arrange
+ mocks.workflowState.dirtyMetadataIds = new Set(['file-1'])
+ mocks.fileData.fileContent = {
+ content: 'markdown',
+ metadata: '{"source":"api"}',
+ }
+
+ // Act
+ render()
+
+ // Assert
+ await waitFor(() => {
+ expect(mocks.workflowActions.setFileMetadata).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should remove file references from draft metadata when markdown no longer contains references', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: true,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ mocks.workflowState.fileMetadata = new Map>([
+ ['file-1', {
+ files: {
+ [FILE_REFERENCE_ID]: createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' }),
+ },
+ }],
+ ])
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
+ ])
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'markdown-no-ref' }))
+
+ // Assert
+ expect(mocks.workflowActions.setDraftMetadata).toHaveBeenCalledWith('file-1', {})
+ })
+
+ it('should keep draft metadata unchanged when referenced files match existing metadata', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: true,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ const referencedNode = createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' })
+ mocks.workflowState.fileMetadata = new Map>([
+ ['file-1', {
+ files: {
+ [FILE_REFERENCE_ID]: referencedNode,
+ },
+ }],
+ ])
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
+ [FILE_REFERENCE_ID, referencedNode],
+ ])
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'markdown-change' }))
+
+ // Assert
+ expect(mocks.workflowActions.setDraftMetadata).not.toHaveBeenCalled()
+ })
+
+ it('should keep metadata unchanged when reference can be resolved from existing metadata only', () => {
+ // Arrange
+ mocks.fileTypeInfo = {
+ isMarkdown: true,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ const existingReferencedNode = createNode({
+ id: FILE_REFERENCE_ID,
+ name: 'persisted.txt',
+ extension: 'txt',
+ })
+ mocks.workflowState.fileMetadata = new Map>([
+ ['file-1', {
+ files: {
+ [FILE_REFERENCE_ID]: existingReferencedNode,
+ },
+ }],
+ ])
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
+ ])
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'markdown-change' }))
+
+ // Assert
+ expect(mocks.workflowActions.setDraftContent).toHaveBeenCalledWith(
+ 'file-1',
+ `linked §[file].[app].[${FILE_REFERENCE_ID}]§`,
+ )
+ expect(mocks.workflowActions.setDraftMetadata).not.toHaveBeenCalled()
+ })
+
+ it('should register fallback on mount and persist fallback on unmount for editable file', () => {
+ // Arrange
+ mocks.workflowState.fileMetadata = new Map>([
+ ['file-1', { language: 'ts' }],
+ ])
+ mocks.fileData.fileContent = {
+ content: 'draft-base',
+ metadata: { language: 'ts' },
+ }
+
+ // Act
+ const { unmount } = render()
+
+ // Assert
+ expect(mocks.registerFallback).toHaveBeenCalledWith(
+ 'file-1',
+ expect.objectContaining({
+ content: 'draft-base',
+ metadata: expect.objectContaining({ language: 'ts' }),
+ }),
+ )
+
+ // Act
+ unmount()
+
+ // Assert
+ expect(mocks.unregisterFallback).toHaveBeenCalledWith('file-1')
+ expect(mocks.saveFile).toHaveBeenCalledWith(
+ 'file-1',
+ expect.objectContaining({
+ fallbackContent: 'draft-base',
+ fallbackMetadata: expect.objectContaining({ language: 'ts' }),
+ }),
+ )
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/skill-body/sidebar-search-add.spec.tsx b/web/app/components/workflow/skill/skill-body/sidebar-search-add.spec.tsx
new file mode 100644
index 0000000000..49ea0ef159
--- /dev/null
+++ b/web/app/components/workflow/skill/skill-body/sidebar-search-add.spec.tsx
@@ -0,0 +1,238 @@
+import type { AppAssetTreeResponse, AppAssetTreeView } from '@/types/app-asset'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ROOT_ID } from '../constants'
+import SidebarSearchAdd from './sidebar-search-add'
+
+type WorkflowStoreState = {
+ fileTreeSearchTerm: string
+ selectedTreeNodeId: string | null
+}
+
+type MockFileOperations = {
+ fileInputRef: React.RefObject
+ folderInputRef: React.RefObject
+ isLoading: boolean
+ handleNewFile: () => void
+ handleNewFolder: () => void
+ handleFileChange: () => void
+ handleFolderChange: () => void
+}
+
+const createFileOperations = (): MockFileOperations => ({
+ fileInputRef: { current: null },
+ folderInputRef: { current: null },
+ isLoading: false,
+ handleNewFile: vi.fn(),
+ handleNewFolder: vi.fn(),
+ handleFileChange: vi.fn(),
+ handleFolderChange: vi.fn(),
+})
+
+const createNode = (overrides: Partial): AppAssetTreeView => ({
+ id: 'folder-1',
+ node_type: 'folder',
+ name: 'folder',
+ path: '/folder',
+ extension: '',
+ size: 0,
+ children: [],
+ ...overrides,
+})
+
+const mocks = vi.hoisted(() => ({
+ storeState: {
+ fileTreeSearchTerm: '',
+ selectedTreeNodeId: null,
+ } as WorkflowStoreState,
+ setFileTreeSearchTerm: vi.fn(),
+ treeData: undefined as AppAssetTreeResponse | undefined,
+ fileOperations: {
+ fileInputRef: { current: null },
+ folderInputRef: { current: null },
+ isLoading: false,
+ handleNewFile: vi.fn(),
+ handleNewFolder: vi.fn(),
+ handleFileChange: vi.fn(),
+ handleFolderChange: vi.fn(),
+ } as MockFileOperations,
+ useFileOperations: vi.fn(),
+}))
+
+vi.mock('next/dynamic', () => ({
+ default: () => {
+ const MockImportSkillModal = ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => {
+ if (!isOpen)
+ return null
+
+ return (
+
+
+
+ )
+ }
+
+ return MockImportSkillModal
+ },
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: WorkflowStoreState) => unknown) => selector(mocks.storeState),
+ useWorkflowStore: () => ({
+ getState: () => ({
+ setFileTreeSearchTerm: mocks.setFileTreeSearchTerm,
+ }),
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useSkillAssetTreeData: () => ({ data: mocks.treeData }),
+}))
+
+vi.mock('../hooks/file-tree/operations/use-file-operations', () => ({
+ useFileOperations: (options: unknown) => {
+ mocks.useFileOperations(options)
+ return mocks.fileOperations
+ },
+}))
+
+describe('SidebarSearchAdd', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.storeState.fileTreeSearchTerm = ''
+ mocks.storeState.selectedTreeNodeId = null
+ mocks.treeData = undefined
+ mocks.fileOperations = createFileOperations()
+ })
+
+ describe('Rendering', () => {
+ it('should render search input and add trigger when component mounts', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByPlaceholderText('workflow.skillSidebar.searchPlaceholder')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.add/i })).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should update store search term when typing in search input', () => {
+ // Arrange
+ render()
+ const searchInput = screen.getByPlaceholderText('workflow.skillSidebar.searchPlaceholder')
+
+ // Act
+ fireEvent.change(searchInput, { target: { value: 'agent' } })
+
+ // Assert
+ expect(mocks.setFileTreeSearchTerm).toHaveBeenCalledTimes(1)
+ expect(mocks.setFileTreeSearchTerm).toHaveBeenCalledWith('agent')
+ })
+
+ it('should call create handlers when clicking new file and new folder actions', () => {
+ // Arrange
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
+
+ // Act
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i }))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
+
+ // Assert
+ expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
+ expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
+ })
+
+ it('should trigger hidden file and folder input click when upload actions are clicked', () => {
+ // Arrange
+ const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click')
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
+
+ // Act
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i }))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i }))
+
+ // Assert
+ expect(clickSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('should open and close import modal when import skills action is used', () => {
+ // Arrange
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
+
+ // Act
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.importSkills/i }))
+
+ // Assert
+ expect(screen.getByTestId('import-skill-modal')).toBeInTheDocument()
+
+ // Act
+ fireEvent.click(screen.getByRole('button', { name: /close-import-modal/i }))
+
+ // Assert
+ expect(screen.queryByTestId('import-skill-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Data flow', () => {
+ it('should pass root id to file operations when tree data is unavailable', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(mocks.useFileOperations).toHaveBeenCalledWith(expect.objectContaining({
+ nodeId: ROOT_ID,
+ }))
+ })
+
+ it('should pass selected parent folder id to file operations when selected node is a file', () => {
+ // Arrange
+ mocks.storeState.selectedTreeNodeId = 'file-1'
+ mocks.treeData = {
+ children: [
+ createNode({
+ id: 'folder-1',
+ children: [
+ createNode({
+ id: 'file-1',
+ node_type: 'file',
+ name: 'readme.md',
+ path: '/folder/readme.md',
+ extension: 'md',
+ size: 12,
+ }),
+ ],
+ }),
+ ],
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(mocks.useFileOperations).toHaveBeenCalledWith(expect.objectContaining({
+ nodeId: 'folder-1',
+ }))
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should disable menu actions when file operations are loading', () => {
+ // Arrange
+ mocks.fileOperations.isLoading = true
+ render()
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.add/i }))
+
+ // Assert
+ 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()
+ expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.importSkills/i })).toBeDisabled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/action-card.spec.tsx b/web/app/components/workflow/skill/start-tab/action-card.spec.tsx
new file mode 100644
index 0000000000..7bf532cfa0
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/action-card.spec.tsx
@@ -0,0 +1,58 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import ActionCard from './action-card'
+
+describe('ActionCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render icon, title, and description when props are provided', () => {
+ render(
+ i}
+ title="Create skill"
+ description="Create a new skill from scratch"
+ />,
+ )
+
+ expect(screen.getByRole('button', { name: /create skill/i })).toBeInTheDocument()
+ expect(screen.getByText('Create a new skill from scratch')).toBeInTheDocument()
+ expect(screen.getByTestId('action-card-icon')).toBeInTheDocument()
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should call onClick when the card is clicked', () => {
+ const onClick = vi.fn()
+ render(
+ i}
+ title="Import skill"
+ description="Import from zip"
+ onClick={onClick}
+ />,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /import skill/i }))
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should stay enabled when onClick is not provided', () => {
+ render(
+ i}
+ title="No handler"
+ description="Card without click handler"
+ />,
+ )
+
+ const button = screen.getByRole('button', { name: /no handler/i })
+ expect(button).toBeEnabled()
+ expect(() => fireEvent.click(button)).not.toThrow()
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx
new file mode 100644
index 0000000000..db9409d8a7
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx
@@ -0,0 +1,175 @@
+import type { App, AppSSO } from '@/types/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import CreateBlankSkillModal from './create-blank-skill-modal'
+
+type MockWorkflowState = {
+ setUploadStatus: ReturnType
+ setUploadProgress: ReturnType
+ openTab: ReturnType
+}
+
+const mocks = vi.hoisted(() => ({
+ mutateAsync: vi.fn(),
+ emitTreeUpdate: vi.fn(),
+ prepareSkillUploadFile: vi.fn(),
+ toastNotify: vi.fn(),
+ existingNames: new Set(),
+ workflowState: {
+ setUploadStatus: vi.fn(),
+ setUploadProgress: vi.fn(),
+ openTab: vi.fn(),
+ } as MockWorkflowState,
+}))
+
+vi.mock('@/service/use-app-asset', () => ({
+ useBatchUpload: () => ({
+ mutateAsync: mocks.mutateAsync,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useExistingSkillNames: () => ({
+ data: mocks.existingNames,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+ useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
+}))
+
+vi.mock('../utils/skill-upload-utils', () => ({
+ prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => mocks.workflowState,
+ }),
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: (...args: unknown[]) => mocks.toastNotify(...args),
+ },
+}))
+
+describe('CreateBlankSkillModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.existingNames = new Set()
+ useAppStore.setState({
+ appDetail: { id: 'app-1' } as App & Partial,
+ })
+ mocks.prepareSkillUploadFile.mockImplementation(async (file: File) => file)
+ })
+
+ describe('Rendering', () => {
+ it('should render modal title and disable create button when skill name is empty', () => {
+ render()
+
+ expect(screen.getByText('workflow.skill.startTab.createModal.title')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.create/i })).toBeDisabled()
+ })
+
+ it('should clear input and call onClose when cancel button is clicked', () => {
+ const onClose = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'to-be-cleared' } })
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ expect(input).toHaveValue('')
+ })
+ })
+
+ describe('Validation', () => {
+ it('should show duplicate error and disable create when skill name already exists', () => {
+ mocks.existingNames = new Set(['existing-skill'])
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'existing-skill' } })
+
+ expect(screen.getByText('workflow.skill.startTab.createModal.nameDuplicate')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.create/i })).toBeDisabled()
+ })
+ })
+
+ describe('Create Flow', () => {
+ it('should upload skill template and notify success when creation succeeds', async () => {
+ const onClose = vi.fn()
+ mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => {
+ onProgress?.(1, 1)
+ return [{
+ children: [{ id: 'skill-md-id' }],
+ }]
+ })
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
+
+ await waitFor(() => {
+ expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
+ })
+
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 })
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 })
+ expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
+ expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true })
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'workflow.skill.startTab.createSuccess:{"name":"new-skill"}',
+ })
+ expect(onClose).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+
+ it('should set partial error and show error toast when upload fails', async () => {
+ const onClose = vi.fn()
+ mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed'))
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
+
+ await waitFor(() => {
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
+
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.skill.startTab.createError',
+ })
+ expect(onClose).not.toHaveBeenCalled()
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+
+ it('should not start upload when app id is missing', () => {
+ useAppStore.setState({ appDetail: undefined })
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
+
+ expect(mocks.mutateAsync).not.toHaveBeenCalled()
+ })
+
+ it('should trigger create flow when Enter key is pressed and form is valid', async () => {
+ mocks.mutateAsync.mockResolvedValueOnce([])
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'new-skill' } })
+ fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
+
+ await waitFor(() => {
+ expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/create-import-section.spec.tsx b/web/app/components/workflow/skill/start-tab/create-import-section.spec.tsx
new file mode 100644
index 0000000000..f75a9d6dc1
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/create-import-section.spec.tsx
@@ -0,0 +1,94 @@
+import type { App, AppSSO } from '@/types/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import CreateImportSection from './create-import-section'
+
+type MockWorkflowState = {
+ setUploadStatus: ReturnType
+ setUploadProgress: ReturnType
+ openTab: ReturnType
+}
+
+const mocks = vi.hoisted(() => ({
+ mutateAsync: vi.fn(),
+ existingNames: new Set(),
+ emitTreeUpdate: vi.fn(),
+ workflowState: {
+ setUploadStatus: vi.fn(),
+ setUploadProgress: vi.fn(),
+ openTab: vi.fn(),
+ } as MockWorkflowState,
+}))
+
+vi.mock('@/service/use-app-asset', () => ({
+ useBatchUpload: () => ({
+ mutateAsync: mocks.mutateAsync,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useExistingSkillNames: () => ({
+ data: mocks.existingNames,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+ useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => mocks.workflowState,
+ }),
+}))
+
+describe('CreateImportSection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.existingNames = new Set()
+ useAppStore.setState({
+ appDetail: { id: 'app-1' } as App & Partial,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render create and import action cards when section is mounted', () => {
+ render()
+
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.createBlankSkill/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importSkill/i })).toBeInTheDocument()
+ expect(screen.queryByText('workflow.skill.startTab.createModal.title')).not.toBeInTheDocument()
+ expect(screen.queryByText('workflow.skill.startTab.importModal.title')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open and close create modal when create action card is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.createBlankSkill/i }))
+ await waitFor(() => {
+ expect(screen.getByText('workflow.skill.startTab.createModal.title')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
+ await waitFor(() => {
+ expect(screen.queryByText('workflow.skill.startTab.createModal.title')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should open and close import modal when import action card is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importSkill/i }))
+ await waitFor(() => {
+ expect(screen.getByText('workflow.skill.startTab.importModal.title')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
+ await waitFor(() => {
+ expect(screen.queryByText('workflow.skill.startTab.importModal.title')).not.toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/import-skill-modal.spec.tsx b/web/app/components/workflow/skill/start-tab/import-skill-modal.spec.tsx
new file mode 100644
index 0000000000..ab0532bb50
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/import-skill-modal.spec.tsx
@@ -0,0 +1,309 @@
+import type { App, AppSSO } from '@/types/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import { ZipValidationError } from '../utils/zip-extract'
+import ImportSkillModal from './import-skill-modal'
+
+type MockWorkflowState = {
+ setUploadStatus: ReturnType
+ setUploadProgress: ReturnType
+ openTab: ReturnType
+}
+
+const mocks = vi.hoisted(() => ({
+ extractAndValidateZip: vi.fn(),
+ buildUploadDataFromZip: vi.fn(),
+ mutateAsync: vi.fn(),
+ emitTreeUpdate: vi.fn(),
+ toastNotify: vi.fn(),
+ existingNames: new Set(),
+ workflowState: {
+ setUploadStatus: vi.fn(),
+ setUploadProgress: vi.fn(),
+ openTab: vi.fn(),
+ } as MockWorkflowState,
+}))
+
+vi.mock('../utils/zip-extract', () => {
+ class MockZipValidationError extends Error {
+ code: string
+
+ constructor(code: string, message: string) {
+ super(message)
+ this.name = 'ZipValidationError'
+ this.code = code
+ }
+ }
+
+ return {
+ ZipValidationError: MockZipValidationError,
+ extractAndValidateZip: (...args: unknown[]) => mocks.extractAndValidateZip(...args),
+ }
+})
+
+vi.mock('../utils/zip-to-upload-tree', () => ({
+ buildUploadDataFromZip: (...args: unknown[]) => mocks.buildUploadDataFromZip(...args),
+}))
+
+vi.mock('@/service/use-app-asset', () => ({
+ useBatchUpload: () => ({
+ mutateAsync: mocks.mutateAsync,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useExistingSkillNames: () => ({
+ data: mocks.existingNames,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+ useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => mocks.workflowState,
+ }),
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: (...args: unknown[]) => mocks.toastNotify(...args),
+ },
+}))
+
+const createZipFile = (name = 'new-skill.zip', size = 1536) => {
+ const binary = new Uint8Array(size)
+ const file = new File([binary], name, { type: 'application/zip' })
+ Object.defineProperty(file, 'arrayBuffer', {
+ value: vi.fn().mockResolvedValue(binary.buffer),
+ configurable: true,
+ })
+ return file
+}
+
+const selectFile = (file: File) => {
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement | null
+ if (!input)
+ throw new Error('file input should be available')
+ fireEvent.change(input, {
+ target: { files: [file] },
+ })
+}
+
+describe('ImportSkillModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.existingNames = new Set()
+ useAppStore.setState({
+ appDetail: { id: 'app-1' } as App & Partial,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render drop zone and keep import button disabled when no file is selected', () => {
+ render()
+
+ expect(screen.getByText('workflow.skill.startTab.importModal.dropHint')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })).toBeDisabled()
+ })
+ })
+
+ describe('File Validation', () => {
+ it('should reject non-zip file selection and show error toast', () => {
+ render()
+
+ selectFile(new File(['readme'], 'README.md', { type: 'text/markdown' }))
+
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.skill.startTab.importModal.invalidFileType',
+ })
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })).toBeDisabled()
+ })
+
+ it('should show selected zip filename and formatted size after file is chosen', () => {
+ render()
+
+ selectFile(createZipFile('sample.zip', 1536))
+
+ expect(screen.getByText('sample.zip')).toBeInTheDocument()
+ expect(screen.getByText('1.5 KB')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })).not.toBeDisabled()
+ })
+
+ it('should select a zip file when it is dropped on the drop zone', () => {
+ render()
+
+ const dropHint = screen.getByText('workflow.skill.startTab.importModal.dropHint')
+ const dropZone = dropHint.closest('div')
+ expect(dropZone).not.toBeNull()
+
+ fireEvent.dragOver(dropZone as HTMLDivElement, { dataTransfer: { files: [] } })
+ fireEvent.drop(dropZone as HTMLDivElement, {
+ dataTransfer: {
+ files: [createZipFile('dropped.zip', 2048)],
+ },
+ })
+
+ expect(screen.getByText('dropped.zip')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })).not.toBeDisabled()
+ })
+
+ it('should trigger hidden file input click when drop zone is clicked', () => {
+ const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => undefined)
+ render()
+
+ const dropHint = screen.getByText('workflow.skill.startTab.importModal.dropHint')
+ const dropZone = dropHint.closest('div')
+ expect(dropZone).not.toBeNull()
+
+ fireEvent.click(dropZone as HTMLDivElement)
+
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ clickSpy.mockRestore()
+ })
+
+ it('should trigger hidden file input click when change-file button is clicked', () => {
+ const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => undefined)
+ render()
+
+ selectFile(createZipFile('selected.zip', 1024))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.changeFile/i }))
+
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ clickSpy.mockRestore()
+ })
+ })
+
+ describe('Import Flow', () => {
+ it('should import selected zip and open SKILL.md tab when upload succeeds', async () => {
+ const onClose = vi.fn()
+ mocks.extractAndValidateZip.mockResolvedValueOnce({
+ rootFolderName: 'new-skill',
+ files: new Map([['new-skill/SKILL.md', new Uint8Array([1, 2, 3])]]),
+ })
+ mocks.buildUploadDataFromZip.mockResolvedValueOnce({
+ tree: [{ name: 'new-skill', node_type: 'folder', children: [] }],
+ files: new Map([['new-skill/SKILL.md', new File(['content'], 'SKILL.md')]]),
+ })
+ mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => {
+ onProgress?.(1, 1)
+ return [{
+ children: [{ id: 'skill-md-id', name: 'SKILL.md' }],
+ }]
+ })
+
+ render()
+ selectFile(createZipFile('new-skill.zip', 2048))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ await waitFor(() => {
+ expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
+ })
+
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 0, failed: 0 })
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 })
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 })
+ expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
+ expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true })
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'workflow.skill.startTab.importModal.importSuccess:{"name":"new-skill"}',
+ })
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should stop import and notify duplicate folder name when extracted root already exists', async () => {
+ mocks.existingNames = new Set(['existing-skill'])
+ mocks.extractAndValidateZip.mockResolvedValueOnce({
+ rootFolderName: 'existing-skill',
+ files: new Map([['existing-skill/SKILL.md', new Uint8Array([1])]]),
+ })
+ render()
+
+ selectFile(createZipFile('existing-skill.zip'))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ await waitFor(() => {
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
+
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.skill.startTab.importModal.nameDuplicate',
+ })
+ expect(mocks.buildUploadDataFromZip).not.toHaveBeenCalled()
+ expect(mocks.mutateAsync).not.toHaveBeenCalled()
+ })
+
+ it('should not start import when app id is missing', () => {
+ useAppStore.setState({ appDetail: undefined })
+ render()
+
+ selectFile(createZipFile('new-skill.zip', 2048))
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ expect(mocks.extractAndValidateZip).not.toHaveBeenCalled()
+ expect(mocks.buildUploadDataFromZip).not.toHaveBeenCalled()
+ expect(mocks.mutateAsync).not.toHaveBeenCalled()
+ expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalled()
+ })
+
+ it('should map zip validation error code to localized error message', async () => {
+ mocks.extractAndValidateZip.mockRejectedValueOnce(new ZipValidationError('empty_zip', 'empty zip'))
+ render()
+
+ selectFile(createZipFile())
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ await waitFor(() => {
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
+
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.skill.startTab.importModal.errorEmptyZip',
+ })
+ })
+
+ it('should fallback to raw error message when zip validation code is unknown', async () => {
+ const unknownCodeError = new ZipValidationError('invalid_zip', 'custom zip error')
+ ;(unknownCodeError as unknown as { code: string }).code = 'unknown_code'
+ mocks.extractAndValidateZip.mockRejectedValueOnce(unknownCodeError)
+ render()
+
+ selectFile(createZipFile())
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ await waitFor(() => {
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
+
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'custom zip error',
+ })
+ })
+
+ it('should fallback to invalid zip error when import fails with non-validation error', async () => {
+ mocks.extractAndValidateZip.mockRejectedValueOnce(new Error('unknown'))
+ render()
+
+ selectFile(createZipFile())
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ await waitFor(() => {
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
+
+ expect(mocks.toastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.skill.startTab.importModal.errorInvalidZip',
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/index.spec.tsx b/web/app/components/workflow/skill/start-tab/index.spec.tsx
new file mode 100644
index 0000000000..46dea3b738
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/index.spec.tsx
@@ -0,0 +1,66 @@
+import type { App, AppSSO } from '@/types/app'
+import { render, screen } from '@testing-library/react'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import StartTabContent from './index'
+
+type MockWorkflowState = {
+ setUploadStatus: ReturnType
+ setUploadProgress: ReturnType
+ openTab: ReturnType
+}
+
+const mocks = vi.hoisted(() => ({
+ mutateAsync: vi.fn(),
+ existingNames: new Set(),
+ emitTreeUpdate: vi.fn(),
+ workflowState: {
+ setUploadStatus: vi.fn(),
+ setUploadProgress: vi.fn(),
+ openTab: vi.fn(),
+ } as MockWorkflowState,
+}))
+
+vi.mock('@/service/use-app-asset', () => ({
+ useBatchUpload: () => ({
+ mutateAsync: mocks.mutateAsync,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useExistingSkillNames: () => ({
+ data: mocks.existingNames,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+ useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => mocks.workflowState,
+ }),
+}))
+
+describe('StartTabContent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.existingNames = new Set()
+ useAppStore.setState({
+ appDetail: { id: 'app-1' } as App & Partial,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render create/import actions and template list when mounted', () => {
+ const { container } = render()
+
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.createBlankSkill/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importSkill/i })).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByText('workflow.skill.startTab.templatesTitle')).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i }).length).toBeGreaterThan(0)
+ expect(container.firstChild).toHaveClass('flex', 'h-full', 'w-full', 'bg-components-panel-bg')
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/section-header.spec.tsx b/web/app/components/workflow/skill/start-tab/section-header.spec.tsx
new file mode 100644
index 0000000000..ef811070c1
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/section-header.spec.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from '@testing-library/react'
+import SectionHeader from './section-header'
+
+describe('SectionHeader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render title and description text when valid props are provided', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('heading', { level: 2, name: 'Templates' })).toBeInTheDocument()
+ expect(screen.getByText('Choose a template to start quickly')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should apply custom className on the header element when className is provided', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container.querySelector('header')).toHaveClass('mt-1')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render an empty description paragraph when description is empty', () => {
+ const { container } = render(
+ ,
+ )
+
+ const paragraph = container.querySelector('p')
+ expect(paragraph).toBeInTheDocument()
+ expect(paragraph).toBeEmptyDOMElement()
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/skill-templates-section.spec.tsx b/web/app/components/workflow/skill/start-tab/skill-templates-section.spec.tsx
new file mode 100644
index 0000000000..c9950ebc8a
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/skill-templates-section.spec.tsx
@@ -0,0 +1,170 @@
+import type { App, AppSSO } from '@/types/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { useStore as useAppStore } from '@/app/components/app/store'
+import SkillTemplatesSection from './skill-templates-section'
+
+type MockWorkflowState = {
+ setUploadStatus: ReturnType
+ setUploadProgress: ReturnType
+}
+
+type TemplateEntry = {
+ id: string
+ name: string
+ description: string
+ fileCount: number
+ loadContent: ReturnType
+}
+
+const mocks = vi.hoisted(() => ({
+ templates: [] as TemplateEntry[],
+ buildUploadDataFromTemplate: vi.fn(),
+ mutateAsync: vi.fn(),
+ emitTreeUpdate: vi.fn(),
+ existingNames: new Set(),
+ workflowState: {
+ setUploadStatus: vi.fn(),
+ setUploadProgress: vi.fn(),
+ } as MockWorkflowState,
+}))
+
+vi.mock('./templates/registry', () => ({
+ SKILL_TEMPLATES: mocks.templates,
+}))
+
+vi.mock('./templates/template-to-upload', () => ({
+ buildUploadDataFromTemplate: (...args: unknown[]) => mocks.buildUploadDataFromTemplate(...args),
+}))
+
+vi.mock('@/service/use-app-asset', () => ({
+ useBatchUpload: () => ({
+ mutateAsync: mocks.mutateAsync,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+ useExistingSkillNames: () => ({
+ data: mocks.existingNames,
+ }),
+}))
+
+vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+ useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => mocks.workflowState,
+ }),
+}))
+
+const createTemplate = (overrides: Partial = {}): TemplateEntry => ({
+ id: 'alpha',
+ name: 'alpha',
+ description: 'first template',
+ fileCount: 2,
+ loadContent: vi.fn().mockResolvedValue([
+ { name: 'SKILL.md', node_type: 'file', content: '# alpha' },
+ ]),
+ ...overrides,
+})
+
+describe('SkillTemplatesSection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.templates.length = 0
+ mocks.templates.push(
+ createTemplate(),
+ createTemplate({
+ id: 'beta',
+ name: 'beta',
+ description: 'design template',
+ fileCount: 3,
+ }),
+ )
+ mocks.existingNames = new Set()
+ mocks.buildUploadDataFromTemplate.mockResolvedValue({
+ tree: [{ name: 'alpha', node_type: 'folder', children: [] }],
+ files: new Map([['alpha/SKILL.md', new File(['content'], 'SKILL.md')]]),
+ })
+ mocks.mutateAsync.mockResolvedValue([])
+ useAppStore.setState({
+ appDetail: { id: 'app-1' } as App & Partial,
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render all templates from registry', () => {
+ render()
+
+ expect(screen.getByText('alpha')).toBeInTheDocument()
+ expect(screen.getByText('beta')).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })).toHaveLength(2)
+ })
+
+ it('should render empty state when search query has no matches', async () => {
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'unknown-template' } })
+
+ await waitFor(() => {
+ expect(screen.getByText('workflow.skill.startTab.noTemplatesFound')).toBeInTheDocument()
+ }, { timeout: 1500 })
+ })
+ })
+
+ describe('Template States', () => {
+ it('should mark template as added when it exists in current skill names', () => {
+ mocks.existingNames = new Set(['alpha'])
+ render()
+
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.skillAdded/i })).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })).toHaveLength(1)
+ })
+ })
+
+ describe('Use Template Flow', () => {
+ it('should upload template and update workflow status when use action succeeds', async () => {
+ mocks.mutateAsync.mockImplementationOnce(async ({ onProgress }: { onProgress?: (uploaded: number, total: number) => void }) => {
+ onProgress?.(1, 1)
+ return []
+ })
+ render()
+
+ fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0])
+
+ await waitFor(() => {
+ expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
+ })
+
+ expect(mocks.buildUploadDataFromTemplate).toHaveBeenCalledWith('alpha', expect.any(Array))
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 1, failed: 0 })
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 1, failed: 0 })
+ expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
+ })
+
+ it('should set partial error when upload fails', async () => {
+ mocks.mutateAsync.mockRejectedValueOnce(new Error('upload failed'))
+ render()
+
+ fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0])
+
+ await waitFor(() => {
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
+ })
+
+ it('should not start upload when app id is missing', () => {
+ useAppStore.setState({ appDetail: undefined })
+ render()
+
+ fireEvent.click(screen.getAllByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })[0])
+
+ expect(mocks.templates[0].loadContent).not.toHaveBeenCalled()
+ expect(mocks.mutateAsync).not.toHaveBeenCalled()
+ expect(mocks.workflowState.setUploadStatus).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/template-card.spec.tsx b/web/app/components/workflow/skill/start-tab/template-card.spec.tsx
new file mode 100644
index 0000000000..db4e78797a
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/template-card.spec.tsx
@@ -0,0 +1,86 @@
+import type { SkillTemplateSummary } from './templates/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import TemplateCard from './template-card'
+
+const createTemplate = (overrides: Partial = {}): SkillTemplateSummary => ({
+ id: 'docx',
+ name: 'docx',
+ description: 'Word document skill',
+ fileCount: 60,
+ ...overrides,
+})
+
+describe('TemplateCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render template metadata when a template is provided', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('docx')).toBeInTheDocument()
+ expect(screen.getByText('Word document skill')).toBeInTheDocument()
+ expect(screen.getByText('workflow.skill.startTab.filesIncluded:{"count":60}')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onUse with template when use button is clicked', () => {
+ const template = createTemplate()
+ const onUse = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i }))
+
+ expect(onUse).toHaveBeenCalledTimes(1)
+ expect(onUse).toHaveBeenCalledWith(template)
+ })
+ })
+
+ describe('Props', () => {
+ it('should render added state and hide use action when added is true', () => {
+ const onUse = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.skillAdded/i })).toBeDisabled()
+ expect(screen.queryByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })).not.toBeInTheDocument()
+ })
+
+ it('should disable use button when disabled is true', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })).toBeDisabled()
+ })
+
+ it('should render loading status when loading is true', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/template-search.spec.tsx b/web/app/components/workflow/skill/start-tab/template-search.spec.tsx
new file mode 100644
index 0000000000..f0cb3fb0a0
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/template-search.spec.tsx
@@ -0,0 +1,58 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import TemplateSearch from './template-search'
+
+describe('TemplateSearch', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ describe('Rendering', () => {
+ it('should render search input with translated placeholder', () => {
+ render()
+
+ expect(screen.getByPlaceholderText('workflow.skill.startTab.searchPlaceholder')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onChange once with the latest value when typing quickly', () => {
+ const onChange = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'a' } })
+ fireEvent.change(input, { target: { value: 'ab' } })
+ fireEvent.change(input, { target: { value: 'abc' } })
+
+ expect(input).toHaveValue('abc')
+ expect(onChange).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(299)
+ expect(onChange).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(1)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('abc')
+ })
+
+ it('should call onChange with an empty string when the input is cleared', () => {
+ const onChange = vi.fn()
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'alpha' } })
+ vi.advanceTimersByTime(300)
+ fireEvent.change(input, { target: { value: '' } })
+ vi.advanceTimersByTime(300)
+
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenNthCalledWith(1, 'alpha')
+ expect(onChange).toHaveBeenNthCalledWith(2, '')
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/templates/registry.spec.ts b/web/app/components/workflow/skill/start-tab/templates/registry.spec.ts
new file mode 100644
index 0000000000..0bc4605d5d
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/templates/registry.spec.ts
@@ -0,0 +1,51 @@
+import type { SkillTemplateNode } from './types'
+import { SKILL_TEMPLATES } from './registry'
+
+const countFiles = (nodes: SkillTemplateNode[]): number => {
+ return nodes.reduce((acc, node) => {
+ if (node.node_type === 'file')
+ return acc + 1
+ return acc + countFiles(node.children)
+ }, 0)
+}
+
+const hasFileNamed = (nodes: SkillTemplateNode[], fileName: string): boolean => {
+ return nodes.some((node) => {
+ if (node.node_type === 'file')
+ return node.name === fileName
+ return hasFileNamed(node.children, fileName)
+ })
+}
+
+describe('SKILL_TEMPLATES registry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Registry Structure', () => {
+ it('should keep template ids unique', () => {
+ const ids = SKILL_TEMPLATES.map(template => template.id)
+ const uniqueIds = new Set(ids)
+
+ expect(uniqueIds.size).toBe(ids.length)
+ })
+ })
+
+ describe('Template Content', () => {
+ it('should load each template and keep fileCount in sync with actual content', async () => {
+ const mismatches: string[] = []
+
+ for (const template of SKILL_TEMPLATES) {
+ const content = await template.loadContent()
+ const actualCount = countFiles(content)
+
+ expect(content.length).toBeGreaterThan(0)
+ expect(hasFileNamed(content, 'SKILL.md')).toBe(true)
+ if (actualCount !== template.fileCount)
+ mismatches.push(`${template.id}:${template.fileCount}->${actualCount}`)
+ }
+
+ expect(mismatches).toEqual([])
+ }, 20000)
+ })
+})
diff --git a/web/app/components/workflow/skill/start-tab/templates/template-to-upload.spec.ts b/web/app/components/workflow/skill/start-tab/templates/template-to-upload.spec.ts
new file mode 100644
index 0000000000..fd57d6d433
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/templates/template-to-upload.spec.ts
@@ -0,0 +1,79 @@
+import type { SkillTemplateNode } from './types'
+import { buildUploadDataFromTemplate } from './template-to-upload'
+
+const mocks = vi.hoisted(() => ({
+ prepareSkillUploadFile: vi.fn(),
+}))
+
+vi.mock('../../utils/skill-upload-utils', () => ({
+ prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args),
+}))
+
+describe('buildUploadDataFromTemplate', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.prepareSkillUploadFile.mockImplementation(async (file: File) => file)
+ })
+
+ describe('Tree Conversion', () => {
+ it('should convert template nodes into upload tree and files map', async () => {
+ const children: SkillTemplateNode[] = [
+ {
+ name: 'SKILL.md',
+ node_type: 'file',
+ content: '# Skill',
+ },
+ {
+ name: 'assets',
+ node_type: 'folder',
+ children: [
+ {
+ name: 'logo.txt',
+ node_type: 'file',
+ content: btoa('PNG'),
+ encoding: 'base64',
+ },
+ ],
+ },
+ ]
+
+ const result = await buildUploadDataFromTemplate('my-skill', children)
+ const skillFile = result.files.get('my-skill/SKILL.md')
+ const logoFile = result.files.get('my-skill/assets/logo.txt')
+
+ expect(result.tree).toHaveLength(1)
+ expect(result.tree[0].name).toBe('my-skill')
+ expect(result.tree[0].node_type).toBe('folder')
+ expect(result.tree[0].children).toEqual([
+ { name: 'SKILL.md', node_type: 'file', size: skillFile?.size ?? 0 },
+ {
+ name: 'assets',
+ node_type: 'folder',
+ children: [{ name: 'logo.txt', node_type: 'file', size: logoFile?.size ?? 0 }],
+ },
+ ])
+ expect(result.files.size).toBe(2)
+ expect(skillFile).toBeInstanceOf(File)
+ expect(logoFile).toBeInstanceOf(File)
+
+ expect(logoFile?.size).toBe(3)
+ expect(mocks.prepareSkillUploadFile).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should return empty root folder when template has no children', async () => {
+ const result = await buildUploadDataFromTemplate('empty-skill', [])
+
+ expect(result.tree).toEqual([
+ {
+ name: 'empty-skill',
+ node_type: 'folder',
+ children: [],
+ },
+ ])
+ expect(result.files.size).toBe(0)
+ expect(mocks.prepareSkillUploadFile).not.toHaveBeenCalled()
+ })
+ })
+})