diff --git a/web/app/components/workflow/skill/__tests__/main.spec.tsx b/web/app/components/workflow/skill/__tests__/main.spec.tsx
new file mode 100644
index 00000000000..14e8d50fadf
--- /dev/null
+++ b/web/app/components/workflow/skill/__tests__/main.spec.tsx
@@ -0,0 +1,134 @@
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+
+import SkillMain from '../main'
+
+const mocks = vi.hoisted(() => ({
+ queryFileId: null as string | null,
+ appId: 'app-1',
+ activeTabId: 'file-tab',
+ openTab: vi.fn(),
+ useSkillAutoSave: vi.fn(),
+}))
+
+vi.mock('nuqs', () => ({
+ parseAsString: 'parseAsString',
+ useQueryState: () => [mocks.queryFileId, vi.fn()],
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: { appDetail: { id: string } | null }) => unknown) => selector({
+ appDetail: mocks.appId ? { id: mocks.appId } : null,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { activeTabId: string }) => unknown) => selector({
+ activeTabId: mocks.activeTabId,
+ }),
+ useWorkflowStore: () => ({
+ getState: () => ({
+ openTab: mocks.openTab,
+ }),
+ }),
+}))
+
+vi.mock('../hooks/use-skill-auto-save', () => ({
+ useSkillAutoSave: () => mocks.useSkillAutoSave(),
+}))
+
+vi.mock('../hooks/use-skill-save-manager', () => ({
+ SkillSaveProvider: ({ appId, children }: { appId: string, children: ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('../constants', () => ({
+ isArtifactTab: (tabId: string | null | undefined) => tabId === 'artifact-tab',
+}))
+
+vi.mock('../file-tree/artifacts/artifacts-section', () => ({
+ default: () => ,
+}))
+
+vi.mock('../file-tree/tree/file-tree', () => ({
+ default: () => ,
+}))
+
+vi.mock('../skill-body/layout/content-area', () => ({
+ default: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+vi.mock('../skill-body/layout/content-body', () => ({
+ default: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+vi.mock('../skill-body/layout/sidebar', () => ({
+ default: ({ children }: { children: ReactNode }) => ,
+}))
+
+vi.mock('../skill-body/layout/skill-page-layout', () => ({
+ default: ({ children }: { children: ReactNode }) => ,
+}))
+
+vi.mock('../skill-body/panels/artifact-content-panel', () => ({
+ default: () => ,
+}))
+
+vi.mock('../skill-body/panels/file-content-panel', () => ({
+ default: () => ,
+}))
+
+vi.mock('../skill-body/sidebar-search-add', () => ({
+ default: () => ,
+}))
+
+vi.mock('../skill-body/tabs/file-tabs', () => ({
+ default: () => ,
+}))
+
+describe('SkillMain', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.queryFileId = null
+ mocks.appId = 'app-1'
+ mocks.activeTabId = 'file-tab'
+ })
+
+ it('should render the skill layout and autosave manager', () => {
+ render()
+
+ expect(screen.getByTestId('skill-save-provider')).toHaveAttribute('data-app-id', 'app-1')
+ expect(screen.getByTestId('sidebar')).toBeInTheDocument()
+ expect(screen.getByTestId('content-area')).toBeInTheDocument()
+ expect(screen.getByTestId('file-content-panel')).toBeInTheDocument()
+ expect(mocks.useSkillAutoSave).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render the artifact content panel when the active tab is an artifact', () => {
+ mocks.activeTabId = 'artifact-tab'
+
+ render()
+
+ expect(screen.getByTestId('artifact-content-panel')).toBeInTheDocument()
+ expect(screen.queryByTestId('file-content-panel')).not.toBeInTheDocument()
+ })
+
+ it('should open the query-selected file as a pinned tab', () => {
+ mocks.queryFileId = 'file-42'
+
+ render()
+
+ expect(mocks.openTab).toHaveBeenCalledWith('file-42', { pinned: true })
+ })
+
+ it('should fall back to an empty app id when app detail is missing', () => {
+ mocks.appId = ''
+
+ render()
+
+ expect(screen.getByTestId('skill-save-provider')).toHaveAttribute('data-app-id', '')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/__tests__/code-file-editor.spec.tsx b/web/app/components/workflow/skill/editor/__tests__/code-file-editor.spec.tsx
new file mode 100644
index 00000000000..b17f834ad83
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/__tests__/code-file-editor.spec.tsx
@@ -0,0 +1,136 @@
+import type { ReactNode } from 'react'
+import { act, render, screen } from '@testing-library/react'
+
+import CodeFileEditor from '../code-file-editor'
+
+const mocks = vi.hoisted(() => {
+ const editor = {
+ focus: vi.fn(),
+ }
+
+ return {
+ editor,
+ onChange: vi.fn(),
+ onMount: vi.fn(),
+ onAutoFocus: vi.fn(),
+ overlay: null as ReactNode,
+ useSkillCodeCursors: vi.fn(),
+ }
+})
+
+vi.mock('@monaco-editor/react', () => ({
+ default: ({
+ onMount,
+ onChange,
+ loading,
+ }: {
+ onMount: (editor: typeof mocks.editor, monaco: { editor: { setTheme: (theme: string) => void } }) => void
+ onChange: (value: string | undefined) => void
+ loading: ReactNode
+ }) => (
+
+
+
+
{loading}
+
+ ),
+}))
+
+vi.mock('../code-editor/plugins/remote-cursors', () => ({
+ useSkillCodeCursors: (props: unknown) => {
+ mocks.useSkillCodeCursors(props)
+ return { overlay: mocks.overlay }
+ },
+}))
+
+describe('CodeFileEditor', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.overlay = null
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ callback(0)
+ return 1
+ })
+ })
+
+ it('should wire Monaco changes and cursor overlay state', () => {
+ mocks.overlay = overlay
+
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByRole('button', { name: 'change-editor' }).click()
+ })
+
+ expect(mocks.onChange).toHaveBeenCalledWith('next value')
+ expect(screen.getByTestId('cursor-overlay')).toBeInTheDocument()
+ expect(mocks.useSkillCodeCursors).toHaveBeenCalledWith({
+ editor: null,
+ fileId: 'file-1',
+ enabled: true,
+ })
+ })
+
+ it('should focus the editor after mount when auto focus is enabled', () => {
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByRole('button', { name: 'mount-editor' }).click()
+ })
+
+ expect(mocks.onMount).toHaveBeenCalled()
+ expect(mocks.editor.focus).toHaveBeenCalledTimes(1)
+ expect(mocks.onAutoFocus).toHaveBeenCalledTimes(1)
+ })
+
+ it('should skip auto focus and collaboration overlay in read only mode', () => {
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByRole('button', { name: 'mount-editor' }).click()
+ })
+
+ expect(mocks.editor.focus).not.toHaveBeenCalled()
+ expect(mocks.useSkillCodeCursors).toHaveBeenCalledWith({
+ editor: null,
+ fileId: 'file-1',
+ enabled: false,
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/__tests__/markdown-file-editor.spec.tsx b/web/app/components/workflow/skill/editor/__tests__/markdown-file-editor.spec.tsx
new file mode 100644
index 00000000000..f642be7fdb4
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/__tests__/markdown-file-editor.spec.tsx
@@ -0,0 +1,100 @@
+import { act, render, screen } from '@testing-library/react'
+
+import MarkdownFileEditor from '../markdown-file-editor'
+
+const mocks = vi.hoisted(() => ({
+ onChange: vi.fn(),
+ onAutoFocus: vi.fn(),
+ skillEditorProps: [] as Array>,
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('../skill-editor', () => ({
+ default: (props: Record) => {
+ mocks.skillEditorProps.push(props)
+ return (
+
+
+
+
+
+ )
+ },
+}))
+
+describe('MarkdownFileEditor', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.skillEditorProps.length = 0
+ })
+
+ it('should pass editable collaboration props and only emit changed values', () => {
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByRole('button', { name: 'emit-change' }).click()
+ })
+
+ expect(mocks.onChange).toHaveBeenCalledWith('updated')
+ expect(mocks.skillEditorProps[0]).toMatchObject({
+ instanceId: 'file-1',
+ value: 'hello',
+ editable: true,
+ autoFocus: true,
+ collaborationEnabled: true,
+ showLineNumbers: true,
+ })
+ })
+
+ it('should disable editing features and placeholder in read only mode', () => {
+ render(
+ ,
+ )
+
+ expect(mocks.skillEditorProps[0]).toMatchObject({
+ editable: false,
+ autoFocus: false,
+ collaborationEnabled: false,
+ placeholder: undefined,
+ })
+ })
+
+ it('should ignore editor updates that do not change the value', () => {
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByRole('button', { name: 'emit-same-value' }).click()
+ })
+
+ expect(mocks.onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/code-editor/plugins/__tests__/remote-cursors.spec.tsx b/web/app/components/workflow/skill/editor/code-editor/plugins/__tests__/remote-cursors.spec.tsx
new file mode 100644
index 00000000000..f9c2ca7dab8
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/code-editor/plugins/__tests__/remote-cursors.spec.tsx
@@ -0,0 +1,35 @@
+import { renderHook } from '@testing-library/react'
+import { useSkillCodeCursors } from '../remote-cursors'
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: {
+ id: 'user-1',
+ },
+ }),
+}))
+
+vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
+ collaborationManager: {
+ onOnlineUsersUpdate: () => vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
+ skillCollaborationManager: {
+ onCursorUpdate: () => vi.fn(),
+ emitCursorUpdate: vi.fn(),
+ },
+}))
+
+describe('useSkillCodeCursors', () => {
+ it('should return a null overlay when the hook is disabled', () => {
+ const { result } = renderHook(() => useSkillCodeCursors({
+ editor: null,
+ fileId: 'file-1',
+ enabled: false,
+ }))
+
+ expect(result.current.overlay).toBeNull()
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/__tests__/index.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..f68e01d10d7
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/__tests__/index.spec.tsx
@@ -0,0 +1,220 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+import SkillEditor from '../index'
+
+const mocks = vi.hoisted(() => {
+ const rootElement = document.createElement('div')
+ rootElement.focus = vi.fn()
+
+ return {
+ initialConfig: null as Record | null,
+ filePreviewValues: [] as Array>,
+ onBlurProps: [] as Array>,
+ updateBlockProps: [] as Array>,
+ localCursorProps: [] as Array>,
+ remoteCursorProps: [] as Array>,
+ toolPickerScopes: [] as string[],
+ onChangeCalls: 0,
+ rootElement,
+ editor: {
+ focus: (callback: () => void) => callback(),
+ getRootElement: () => rootElement,
+ },
+ }
+})
+
+vi.mock('@lexical/react/LexicalComposer', () => ({
+ LexicalComposer: ({ initialConfig, children }: { initialConfig: Record, children: React.ReactNode }) => {
+ mocks.initialConfig = initialConfig
+ return {children}
+ },
+}))
+
+vi.mock('@lexical/react/LexicalComposerContext', () => ({
+ useLexicalComposerContext: () => [mocks.editor],
+}))
+
+vi.mock('@lexical/react/LexicalContentEditable', () => ({
+ ContentEditable: (props: Record) => {JSON.stringify(props)}
,
+}))
+
+vi.mock('@lexical/react/LexicalRichTextPlugin', () => ({
+ RichTextPlugin: ({ contentEditable, placeholder }: { contentEditable: React.ReactNode, placeholder: React.ReactNode }) => (
+
+ {contentEditable}
+ {placeholder}
+
+ ),
+}))
+
+vi.mock('@lexical/react/LexicalOnChangePlugin', () => ({
+ OnChangePlugin: ({ onChange }: { onChange: (editorState: { read: (reader: () => unknown) => unknown }) => void }) => {
+ React.useEffect(() => {
+ if (mocks.onChangeCalls === 0) {
+ mocks.onChangeCalls += 1
+ onChange({
+ read: reader => reader(),
+ })
+ }
+ }, [onChange])
+ return
+ },
+}))
+
+vi.mock('@lexical/react/LexicalErrorBoundary', () => ({
+ LexicalErrorBoundary: () => ,
+}))
+
+vi.mock('@lexical/react/LexicalHistoryPlugin', () => ({
+ HistoryPlugin: () => ,
+}))
+
+vi.mock('lexical', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ $getRoot: () => ({
+ getChildren: () => [
+ { getTextContent: () => 'first line' },
+ { getTextContent: () => 'second line' },
+ ],
+ }),
+ }
+})
+
+vi.mock('@/app/components/base/prompt-editor/plugins/placeholder', () => ({
+ default: ({ value }: { value: React.ReactNode }) => {value}
,
+}))
+
+vi.mock('@/app/components/base/prompt-editor/plugins/on-blur-or-focus-block', () => ({
+ default: (props: Record) => {
+ mocks.onBlurProps.push(props)
+ return
+ },
+}))
+
+vi.mock('@/app/components/base/prompt-editor/plugins/update-block', () => ({
+ default: (props: Record) => {
+ mocks.updateBlockProps.push(props)
+ return
+ },
+}))
+
+vi.mock('@/app/components/base/prompt-editor/utils', () => ({
+ textToEditorState: (value: string) => `editor-state:${value}`,
+}))
+
+vi.mock('../plugins/file-picker-block', () => ({
+ default: () => ,
+}))
+
+vi.mock('../plugins/file-reference-block/preview-context', () => ({
+ FilePreviewContextProvider: ({ value, children }: { value: Record, children: React.ReactNode }) => {
+ mocks.filePreviewValues.push(value)
+ return {children}
+ },
+}))
+
+vi.mock('../plugins/file-reference-block/replacement-block', () => ({
+ default: () => ,
+}))
+
+vi.mock('../plugins/remote-cursors', () => ({
+ LocalCursorPlugin: (props: Record) => {
+ mocks.localCursorProps.push(props)
+ return
+ },
+ SkillRemoteCursors: (props: Record) => {
+ mocks.remoteCursorProps.push(props)
+ return
+ },
+}))
+
+vi.mock('../plugins/tool-block', () => ({
+ ToolBlock: () => ,
+ ToolBlockNode: class MockToolBlockNode {},
+ ToolBlockReplacementBlock: () => ,
+ ToolGroupBlockNode: class MockToolGroupBlockNode {},
+ ToolGroupBlockReplacementBlock: () => ,
+}))
+
+vi.mock('../plugins/tool-block/tool-picker-block', () => ({
+ default: ({ scope }: { scope: string }) => {
+ mocks.toolPickerScopes.push(scope)
+ return {scope}
+ },
+}))
+
+beforeEach(() => {
+ mocks.initialConfig = null
+ mocks.filePreviewValues.length = 0
+ mocks.onBlurProps.length = 0
+ mocks.updateBlockProps.length = 0
+ mocks.localCursorProps.length = 0
+ mocks.remoteCursorProps.length = 0
+ mocks.toolPickerScopes.length = 0
+ mocks.onChangeCalls = 0
+ vi.mocked(mocks.rootElement.focus).mockClear()
+})
+
+describe('SkillEditor', () => {
+ it('should build the lexical config and render editable plugins', async () => {
+ const onChange = vi.fn()
+ const onAutoFocus = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(mocks.initialConfig).toMatchObject({
+ namespace: 'skill-editor',
+ editable: true,
+ editorState: 'editor-state:hello',
+ })
+ expect(mocks.filePreviewValues[0]).toEqual({ enabled: false })
+ expect(screen.getByTestId('file-picker-block')).toBeInTheDocument()
+ expect(screen.getByTestId('tool-picker-block')).toHaveTextContent('selection')
+ expect(mocks.toolPickerScopes).toEqual(['selection'])
+ expect(mocks.updateBlockProps[0]).toMatchObject({ instanceId: 'file-1' })
+ expect(mocks.localCursorProps[0]).toMatchObject({ fileId: 'file-1', enabled: true })
+ expect(mocks.remoteCursorProps[0]).toMatchObject({ fileId: 'file-1', enabled: true })
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith('first line\nsecond line')
+ })
+ await waitFor(() => {
+ expect(onAutoFocus).toHaveBeenCalledTimes(1)
+ })
+ expect(mocks.rootElement.focus).toHaveBeenCalledWith({ preventScroll: true })
+ })
+
+ it('should skip editable-only plugins in readonly mode', () => {
+ render(
+ ,
+ )
+
+ expect(mocks.initialConfig).toMatchObject({
+ editable: false,
+ editorState: 'editor-state:readonly',
+ })
+ expect(screen.queryByTestId('file-picker-block')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('tool-picker-block')).not.toBeInTheDocument()
+ expect(mocks.localCursorProps[0]).toMatchObject({ fileId: 'file-2', enabled: false })
+ expect(mocks.remoteCursorProps[0]).toMatchObject({ fileId: 'file-2', enabled: false })
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/node.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/node.spec.tsx
new file mode 100644
index 00000000000..27fbb19ab13
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/node.spec.tsx
@@ -0,0 +1,111 @@
+import { act, render, screen } from '@testing-library/react'
+import { $createParagraphNode, $getRoot } from 'lexical'
+import {
+ createLexicalTestEditor,
+} from '@/app/components/base/prompt-editor/plugins/test-helpers'
+import {
+ $createFileReferenceNode,
+ $isFileReferenceNode,
+ FileReferenceNode,
+} from '../node'
+import { buildFileReferenceToken } from '../utils'
+
+vi.mock('../component', () => ({
+ default: ({ nodeKey, resourceId }: { nodeKey: string, resourceId: string }) => (
+ {`${nodeKey}:${resourceId}`}
+ ),
+}))
+
+const firstResourceId = '11111111-1111-4111-8111-111111111111'
+const secondResourceId = '22222222-2222-4222-8222-222222222222'
+
+describe('FileReferenceNode', () => {
+ it('should expose lexical metadata and serialize its payload', () => {
+ const editor = createLexicalTestEditor('file-reference-node-metadata-test', [FileReferenceNode])
+ let node!: FileReferenceNode
+
+ act(() => {
+ editor.update(() => {
+ node = $createFileReferenceNode({ resourceId: firstResourceId })
+ })
+ })
+
+ const dom = node.createDOM()
+
+ expect(FileReferenceNode.getType()).toBe('file-reference-block')
+ expect(node.isInline()).toBe(true)
+ expect(node.updateDOM()).toBe(false)
+ expect(node.exportJSON()).toEqual({
+ type: 'file-reference-block',
+ version: 1,
+ resourceId: firstResourceId,
+ })
+ expect(node.getTextContent()).toBe(buildFileReferenceToken(firstResourceId))
+ expect($isFileReferenceNode(node)).toBe(true)
+ expect($isFileReferenceNode(null)).toBe(false)
+ expect($isFileReferenceNode(undefined)).toBe(false)
+ expect(dom.tagName).toBe('SPAN')
+ expect(dom).toHaveClass('inline-flex', 'items-center', 'align-middle')
+ })
+
+ it('should clone and import serialized nodes', () => {
+ const editor = createLexicalTestEditor('file-reference-node-clone-test', [FileReferenceNode])
+ let original!: FileReferenceNode
+ let cloned!: FileReferenceNode
+ let imported!: FileReferenceNode
+
+ act(() => {
+ editor.update(() => {
+ original = $createFileReferenceNode({ resourceId: firstResourceId })
+ cloned = FileReferenceNode.clone(original)
+ imported = FileReferenceNode.importJSON({
+ type: 'file-reference-block',
+ version: 1,
+ resourceId: secondResourceId,
+ })
+ })
+ })
+
+ expect(cloned).not.toBe(original)
+ expect(cloned.exportJSON()).toEqual(original.exportJSON())
+ expect(imported.exportJSON()).toEqual({
+ type: 'file-reference-block',
+ version: 1,
+ resourceId: secondResourceId,
+ })
+ })
+
+ it('should decorate and update its resource id inside editor state', () => {
+ const editor = createLexicalTestEditor('file-reference-node-test', [FileReferenceNode])
+ let node!: FileReferenceNode
+ let updatedText = ''
+ let updatedResourceId = ''
+
+ act(() => {
+ editor.update(() => {
+ node = $createFileReferenceNode({ resourceId: firstResourceId })
+ })
+ })
+
+ render(node.decorate())
+
+ expect(screen.getByTestId('file-reference-block')).toHaveTextContent(firstResourceId)
+
+ act(() => {
+ editor.update(() => {
+ const root = $getRoot()
+ const paragraph = $createParagraphNode()
+ const lexicalNode = $createFileReferenceNode({ resourceId: firstResourceId })
+
+ paragraph.append(lexicalNode)
+ root.append(paragraph)
+ lexicalNode.setResourceId(secondResourceId)
+ updatedText = lexicalNode.getTextContent()
+ updatedResourceId = lexicalNode.exportJSON().resourceId
+ })
+ })
+
+ expect(updatedText).toBe(buildFileReferenceToken(secondResourceId))
+ expect(updatedResourceId).toBe(secondResourceId)
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/preview-context.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/preview-context.spec.tsx
new file mode 100644
index 00000000000..1a8147a5a31
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/preview-context.spec.tsx
@@ -0,0 +1,50 @@
+import type { PropsWithChildren } from 'react'
+import { renderHook } from '@testing-library/react'
+import {
+ FilePreviewContextProvider,
+ useFilePreviewContext,
+} from '../preview-context'
+
+describe('FilePreviewContextProvider', () => {
+ it('should fall back to the default disabled state without a provider', () => {
+ const { result } = renderHook(() => useFilePreviewContext(context => context.enabled))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should expose the full context value and update subscribers', () => {
+ let enabled = true
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ )
+
+ const { result, rerender } = renderHook(
+ () => useFilePreviewContext(context => context.enabled),
+ { wrapper },
+ )
+
+ expect(result.current).toBe(true)
+
+ enabled = false
+ rerender()
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should treat an undefined provider value as disabled', () => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(
+ () => useFilePreviewContext(context => context.enabled),
+ { wrapper },
+ )
+
+ expect(result.current).toBe(false)
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/replacement-block.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/replacement-block.spec.tsx
new file mode 100644
index 00000000000..bbd2a8fed4d
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/replacement-block.spec.tsx
@@ -0,0 +1,74 @@
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { render, waitFor } from '@testing-library/react'
+import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
+import {
+ getNodeCount,
+ renderLexicalEditor,
+ setEditorRootText,
+ waitForEditorReady,
+} from '@/app/components/base/prompt-editor/plugins/test-helpers'
+import {
+ FileReferenceNode,
+} from '../node'
+import FileReferenceReplacementBlock from '../replacement-block'
+import { buildFileReferenceToken } from '../utils'
+
+vi.mock('../component', () => ({
+ default: ({ resourceId }: { resourceId: string }) => {resourceId}
,
+}))
+
+const resourceId = '11111111-1111-4111-8111-111111111111'
+
+const renderReplacementPlugin = () => {
+ return renderLexicalEditor({
+ namespace: 'file-reference-replacement-plugin-test',
+ nodes: [CustomTextNode, FileReferenceNode],
+ children: ,
+ })
+}
+
+describe('FileReferenceReplacementBlock', () => {
+ it('should replace serialized file reference tokens with file reference nodes', async () => {
+ const { getEditor } = renderReplacementPlugin()
+ const editor = await waitForEditorReady(getEditor)
+
+ setEditorRootText(
+ editor,
+ `prefix ${buildFileReferenceToken(resourceId)} suffix`,
+ text => new CustomTextNode(text),
+ )
+
+ await waitFor(() => {
+ expect(getNodeCount(editor, FileReferenceNode)).toBe(1)
+ })
+ })
+
+ it('should leave plain text untouched when no token is present', async () => {
+ const { getEditor } = renderReplacementPlugin()
+ const editor = await waitForEditorReady(getEditor)
+
+ setEditorRootText(editor, 'plain text without tokens', text => new CustomTextNode(text))
+
+ await waitFor(() => {
+ expect(getNodeCount(editor, FileReferenceNode)).toBe(0)
+ })
+ })
+
+ it('should throw when the file reference node is not registered', () => {
+ expect(() => {
+ render(
+ {
+ throw error
+ },
+ nodes: [CustomTextNode],
+ }}
+ >
+
+ ,
+ )
+ }).toThrow('FileReferenceReplacementBlock: FileReferenceNode not registered on editor')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/utils.spec.ts b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/utils.spec.ts
new file mode 100644
index 00000000000..5a5cae88f20
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/__tests__/utils.spec.ts
@@ -0,0 +1,28 @@
+import {
+ buildFileReferenceToken,
+ getFileReferenceTokenRegexString,
+ parseFileReferenceToken,
+} from '../utils'
+
+const resourceId = '11111111-1111-4111-8111-111111111111'
+
+describe('file-reference utils', () => {
+ it('should expose a regex that matches serialized file tokens', () => {
+ const regex = new RegExp(`^${getFileReferenceTokenRegexString()}$`)
+
+ expect(regex.test(buildFileReferenceToken(resourceId))).toBe(true)
+ expect(regex.test('§[file].[app].[invalid]§')).toBe(false)
+ })
+
+ it('should parse valid file tokens and reject invalid content', () => {
+ expect(parseFileReferenceToken(buildFileReferenceToken(resourceId))).toEqual({
+ resourceId,
+ })
+ expect(parseFileReferenceToken('plain-text')).toBeNull()
+ expect(parseFileReferenceToken('§[file].[app].[short-id]§')).toBeNull()
+ })
+
+ it('should build file reference tokens from resource ids', () => {
+ expect(buildFileReferenceToken(resourceId)).toBe(`§[file].[app].[${resourceId}]§`)
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/hooks/__tests__/use-editor-blur.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/hooks/__tests__/use-editor-blur.spec.tsx
new file mode 100644
index 00000000000..f7438416c96
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/hooks/__tests__/use-editor-blur.spec.tsx
@@ -0,0 +1,73 @@
+import type { LexicalEditor } from 'lexical'
+import { act, renderHook } from '@testing-library/react'
+import {
+ BLUR_COMMAND,
+ COMMAND_PRIORITY_EDITOR,
+ FOCUS_COMMAND,
+} from 'lexical'
+import { useEditorBlur } from '../use-editor-blur'
+
+describe('useEditorBlur', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should register blur and focus handlers and toggle visibility state', () => {
+ const blurUnregister = vi.fn()
+ const focusUnregister = vi.fn()
+ const registerCommand = vi
+ .fn()
+ .mockReturnValueOnce(blurUnregister)
+ .mockReturnValueOnce(focusUnregister)
+ const editor = {
+ registerCommand,
+ } as unknown as LexicalEditor
+
+ const { result, unmount } = renderHook(() => useEditorBlur(editor))
+
+ expect(result.current.blurHidden).toBe(false)
+ expect(registerCommand).toHaveBeenNthCalledWith(
+ 1,
+ BLUR_COMMAND,
+ expect.any(Function),
+ COMMAND_PRIORITY_EDITOR,
+ )
+ expect(registerCommand).toHaveBeenNthCalledWith(
+ 2,
+ FOCUS_COMMAND,
+ expect.any(Function),
+ COMMAND_PRIORITY_EDITOR,
+ )
+
+ const blurHandler = registerCommand.mock.calls[0][1] as () => boolean
+ const focusHandler = registerCommand.mock.calls[1][1] as () => boolean
+
+ act(() => {
+ expect(blurHandler()).toBe(false)
+ vi.advanceTimersByTime(199)
+ })
+ expect(result.current.blurHidden).toBe(false)
+
+ act(() => {
+ vi.advanceTimersByTime(1)
+ })
+ expect(result.current.blurHidden).toBe(true)
+
+ act(() => {
+ expect(focusHandler()).toBe(false)
+ })
+ expect(result.current.blurHidden).toBe(false)
+
+ act(() => {
+ blurHandler()
+ })
+ unmount()
+
+ expect(blurUnregister).toHaveBeenCalledTimes(1)
+ expect(focusUnregister).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/remote-cursors/__tests__/index.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/remote-cursors/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..4140284f565
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/remote-cursors/__tests__/index.spec.tsx
@@ -0,0 +1,56 @@
+import { render } from '@testing-library/react'
+import { LocalCursorPlugin, SkillRemoteCursors } from '../index'
+
+const mockEditor = {
+ registerCommand: vi.fn(),
+ registerUpdateListener: vi.fn(),
+ getRootElement: vi.fn(() => null),
+ getEditorState: vi.fn(() => ({
+ read: (reader: () => unknown) => reader(),
+ })),
+}
+
+vi.mock('@lexical/react/LexicalComposerContext', () => ({
+ useLexicalComposerContext: () => [mockEditor],
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: {
+ id: 'user-1',
+ },
+ }),
+}))
+
+vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
+ collaborationManager: {
+ onOnlineUsersUpdate: () => vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
+ skillCollaborationManager: {
+ onCursorUpdate: () => vi.fn(),
+ emitCursorUpdate: vi.fn(),
+ },
+}))
+
+describe('skill editor remote cursors', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should not register local cursor listeners when collaboration is disabled', () => {
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ expect(mockEditor.registerCommand).not.toHaveBeenCalled()
+ expect(mockEditor.registerUpdateListener).not.toHaveBeenCalled()
+ })
+
+ it('should render no overlay when remote cursors are disabled', () => {
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/index.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..58d1f884ef7
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/index.spec.tsx
@@ -0,0 +1,108 @@
+import { render } from '@testing-library/react'
+
+import {
+ DELETE_TOOL_BLOCK_COMMAND,
+ INSERT_TOOL_BLOCK_COMMAND,
+} from '../commands'
+import {
+ ToolBlock,
+} from '../index'
+
+const mockInsertNodes = vi.hoisted(() => vi.fn())
+const mockCreateToolBlockNode = vi.hoisted(() => vi.fn((payload: unknown) => ({ kind: 'tool-block-node', payload })))
+const mockRegisterCommand = vi.hoisted(() => vi.fn())
+const mockEditor = vi.hoisted(() => ({
+ hasNodes: vi.fn(() => true),
+ registerCommand: mockRegisterCommand,
+}))
+const unregisterFns = vi.hoisted(() => [vi.fn(), vi.fn()])
+
+vi.mock('@lexical/react/LexicalComposerContext', () => ({
+ useLexicalComposerContext: () => [mockEditor],
+}))
+
+vi.mock('@lexical/utils', () => ({
+ mergeRegister: (...callbacks: Array<() => void>) => () => callbacks.forEach(callback => callback()),
+}))
+
+vi.mock('lexical', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ $insertNodes: mockInsertNodes,
+ COMMAND_PRIORITY_EDITOR: 100,
+ }
+})
+
+vi.mock('../node', () => ({
+ ToolBlockNode: class MockToolBlockNode {},
+ $createToolBlockNode: mockCreateToolBlockNode,
+}))
+
+describe('ToolBlock', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockEditor.hasNodes.mockReturnValue(true)
+ mockRegisterCommand
+ .mockReturnValueOnce(unregisterFns[0])
+ .mockReturnValueOnce(unregisterFns[1])
+ })
+
+ it('should register insert and delete handlers with the editor', () => {
+ render()
+
+ expect(mockEditor.hasNodes).toHaveBeenCalled()
+ expect(mockRegisterCommand).toHaveBeenNthCalledWith(
+ 1,
+ INSERT_TOOL_BLOCK_COMMAND,
+ expect.any(Function),
+ 100,
+ )
+ expect(mockRegisterCommand).toHaveBeenNthCalledWith(
+ 2,
+ DELETE_TOOL_BLOCK_COMMAND,
+ expect.any(Function),
+ 100,
+ )
+ })
+
+ it('should create and insert a tool block node when the insert handler runs', () => {
+ render()
+
+ const insertHandler = mockRegisterCommand.mock.calls[0][1] as (payload: unknown) => boolean
+ const payload = {
+ provider: 'openai/tools',
+ tool: 'search',
+ configId: '11111111-1111-4111-8111-111111111111',
+ }
+
+ expect(insertHandler(payload)).toBe(true)
+ expect(mockCreateToolBlockNode).toHaveBeenCalledWith(payload)
+ expect(mockInsertNodes).toHaveBeenCalledWith([
+ { kind: 'tool-block-node', payload },
+ ])
+ })
+
+ it('should return true from the delete handler', () => {
+ render()
+
+ const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
+
+ expect(deleteHandler()).toBe(true)
+ })
+
+ it('should unregister command handlers on unmount', () => {
+ const { unmount } = render()
+
+ unmount()
+
+ expect(unregisterFns[0]).toHaveBeenCalledTimes(1)
+ expect(unregisterFns[1]).toHaveBeenCalledTimes(1)
+ })
+
+ it('should throw when the tool block node is not registered', () => {
+ mockEditor.hasNodes.mockReturnValue(false)
+
+ expect(() => render()).toThrow('ToolBlockPlugin: ToolBlockNode not registered on editor')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/node.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/node.spec.tsx
new file mode 100644
index 00000000000..6c6716bc5ac
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/node.spec.tsx
@@ -0,0 +1,103 @@
+import { act, render, screen } from '@testing-library/react'
+import {
+ createLexicalTestEditor,
+} from '@/app/components/base/prompt-editor/plugins/test-helpers'
+import {
+ $createToolBlockNode,
+ $isToolBlockNode,
+ ToolBlockNode,
+} from '../node'
+import { buildToolToken } from '../utils'
+
+vi.mock('../component', () => ({
+ default: (props: Record) => (
+ {JSON.stringify(props)}
+ ),
+}))
+
+const payload = {
+ provider: 'openai/tools',
+ tool: 'search',
+ configId: '11111111-1111-4111-8111-111111111111',
+ label: 'Search',
+ icon: 'ri-search-line',
+ iconDark: {
+ content: 'moon',
+ background: '#000000',
+ },
+}
+
+describe('ToolBlockNode', () => {
+ it('should expose lexical metadata and serialize its payload', () => {
+ const editor = createLexicalTestEditor('tool-block-node-metadata-test', [ToolBlockNode])
+ let node!: ToolBlockNode
+
+ act(() => {
+ editor.update(() => {
+ node = $createToolBlockNode(payload)
+ })
+ })
+
+ const dom = node.createDOM()
+
+ expect(ToolBlockNode.getType()).toBe('tool-block')
+ expect(node.isInline()).toBe(true)
+ expect(node.updateDOM()).toBe(false)
+ expect(node.exportJSON()).toEqual({
+ type: 'tool-block',
+ version: 1,
+ ...payload,
+ })
+ expect(node.getTextContent()).toBe(buildToolToken(payload))
+ expect($isToolBlockNode(node)).toBe(true)
+ expect($isToolBlockNode(null)).toBe(false)
+ expect(dom.tagName).toBe('SPAN')
+ expect(dom).toHaveClass('inline-flex', 'items-center', 'align-middle')
+ })
+
+ it('should clone and import serialized nodes', () => {
+ const editor = createLexicalTestEditor('tool-block-node-clone-test', [ToolBlockNode])
+ let original!: ToolBlockNode
+ let cloned!: ToolBlockNode
+ let imported!: ToolBlockNode
+
+ act(() => {
+ editor.update(() => {
+ original = $createToolBlockNode(payload)
+ cloned = ToolBlockNode.clone(original)
+ imported = ToolBlockNode.importJSON({
+ type: 'tool-block',
+ version: 1,
+ ...payload,
+ label: 'Imported',
+ })
+ })
+ })
+
+ expect(cloned).not.toBe(original)
+ expect(cloned.exportJSON()).toEqual(original.exportJSON())
+ expect(imported.exportJSON()).toEqual({
+ type: 'tool-block',
+ version: 1,
+ ...payload,
+ label: 'Imported',
+ })
+ })
+
+ it('should decorate the node with the tool block component payload', () => {
+ const editor = createLexicalTestEditor('tool-block-node-decorate-test', [ToolBlockNode])
+ let node!: ToolBlockNode
+
+ act(() => {
+ editor.update(() => {
+ node = $createToolBlockNode(payload)
+ })
+ })
+
+ render(node.decorate())
+
+ expect(screen.getByTestId('tool-block-component')).toHaveTextContent('"provider":"openai/tools"')
+ expect(screen.getByTestId('tool-block-component')).toHaveTextContent('"tool":"search"')
+ expect(screen.getByTestId('tool-block-component')).toHaveTextContent('"label":"Search"')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-block-context.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-block-context.spec.tsx
new file mode 100644
index 00000000000..542fed2090f
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-block-context.spec.tsx
@@ -0,0 +1,56 @@
+import type { PropsWithChildren } from 'react'
+import { renderHook } from '@testing-library/react'
+import {
+ ToolBlockContextProvider,
+ useToolBlockContext,
+} from '../tool-block-context'
+
+describe('ToolBlockContextProvider', () => {
+ it('should fall back to a null context without a provider', () => {
+ const { result } = renderHook(() => useToolBlockContext())
+
+ expect(result.current).toBeNull()
+ })
+
+ it('should expose selected values and update subscribers', () => {
+ let value = {
+ nodeId: 'node-1',
+ useModal: true,
+ disableToolBlocks: false,
+ }
+
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ )
+
+ const { result, rerender } = renderHook(
+ () => useToolBlockContext(context => context?.nodeId),
+ { wrapper },
+ )
+
+ expect(result.current).toBe('node-1')
+
+ value = {
+ nodeId: 'node-2',
+ useModal: false,
+ disableToolBlocks: true,
+ }
+ rerender()
+
+ expect(result.current).toBe('node-2')
+ })
+
+ it('should treat an undefined provider value as null', () => {
+ const wrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useToolBlockContext(), { wrapper })
+
+ expect(result.current).toBeNull()
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-block-replacement-block.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-block-replacement-block.spec.tsx
new file mode 100644
index 00000000000..df425c1d275
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-block-replacement-block.spec.tsx
@@ -0,0 +1,74 @@
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { render, waitFor } from '@testing-library/react'
+import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
+import {
+ getNodeCount,
+ renderLexicalEditor,
+ setEditorRootText,
+ waitForEditorReady,
+} from '@/app/components/base/prompt-editor/plugins/test-helpers'
+import {
+ ToolBlockNode,
+} from '../node'
+import ToolBlockReplacementBlock from '../tool-block-replacement-block'
+import { buildToolToken } from '../utils'
+
+vi.mock('../component', () => ({
+ default: ({ tool }: { tool: string }) => {tool}
,
+}))
+
+const token = buildToolToken({
+ provider: 'openai/tools',
+ tool: 'search',
+ configId: '11111111-1111-4111-8111-111111111111',
+})
+
+const renderReplacementPlugin = () => {
+ return renderLexicalEditor({
+ namespace: 'tool-block-replacement-plugin-test',
+ nodes: [CustomTextNode, ToolBlockNode],
+ children: ,
+ })
+}
+
+describe('ToolBlockReplacementBlock', () => {
+ it('should replace serialized tool tokens with tool block nodes', async () => {
+ const { getEditor } = renderReplacementPlugin()
+ const editor = await waitForEditorReady(getEditor)
+
+ setEditorRootText(editor, `prefix ${token} suffix`, text => new CustomTextNode(text))
+
+ await waitFor(() => {
+ expect(getNodeCount(editor, ToolBlockNode)).toBe(1)
+ })
+ })
+
+ it('should leave plain text untouched when no token is present', async () => {
+ const { getEditor } = renderReplacementPlugin()
+ const editor = await waitForEditorReady(getEditor)
+
+ setEditorRootText(editor, 'plain text without tokens', text => new CustomTextNode(text))
+
+ await waitFor(() => {
+ expect(getNodeCount(editor, ToolBlockNode)).toBe(0)
+ })
+ })
+
+ it('should throw when the tool block node is not registered', () => {
+ expect(() => {
+ render(
+ {
+ throw error
+ },
+ nodes: [CustomTextNode],
+ }}
+ >
+
+ ,
+ )
+ }).toThrow('ToolBlockReplacementBlock: ToolBlockNode not registered on editor')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-group-block-node.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-group-block-node.spec.tsx
new file mode 100644
index 00000000000..20d0dede5ba
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-group-block-node.spec.tsx
@@ -0,0 +1,101 @@
+import { act, render, screen } from '@testing-library/react'
+import {
+ createLexicalTestEditor,
+} from '@/app/components/base/prompt-editor/plugins/test-helpers'
+import {
+ $createToolGroupBlockNode,
+ $isToolGroupBlockNode,
+ ToolGroupBlockNode,
+} from '../tool-group-block-node'
+import { buildToolTokenList } from '../utils'
+
+vi.mock('../tool-group-block-component', () => ({
+ default: (props: Record) => (
+ {JSON.stringify(props)}
+ ),
+}))
+
+const tools = [
+ {
+ provider: 'openai/tools',
+ tool: 'search',
+ configId: '11111111-1111-4111-8111-111111111111',
+ },
+ {
+ provider: 'anthropic',
+ tool: 'browse',
+ configId: '22222222-2222-4222-8222-222222222222',
+ },
+]
+
+describe('ToolGroupBlockNode', () => {
+ it('should expose lexical metadata and serialize its payload', () => {
+ const editor = createLexicalTestEditor('tool-group-block-node-metadata-test', [ToolGroupBlockNode])
+ let node!: ToolGroupBlockNode
+
+ act(() => {
+ editor.update(() => {
+ node = $createToolGroupBlockNode({ tools })
+ })
+ })
+
+ const dom = node.createDOM()
+
+ expect(ToolGroupBlockNode.getType()).toBe('tool-group-block')
+ expect(node.isInline()).toBe(true)
+ expect(node.updateDOM()).toBe(false)
+ expect(node.exportJSON()).toEqual({
+ type: 'tool-group-block',
+ version: 1,
+ tools,
+ })
+ expect(node.getTextContent()).toBe(buildToolTokenList(tools))
+ expect($isToolGroupBlockNode(node)).toBe(true)
+ expect($isToolGroupBlockNode(null)).toBe(false)
+ expect(dom.tagName).toBe('SPAN')
+ expect(dom).toHaveClass('inline-flex', 'items-center', 'align-middle')
+ })
+
+ it('should clone and import serialized nodes', () => {
+ const editor = createLexicalTestEditor('tool-group-block-node-clone-test', [ToolGroupBlockNode])
+ let original!: ToolGroupBlockNode
+ let cloned!: ToolGroupBlockNode
+ let imported!: ToolGroupBlockNode
+
+ act(() => {
+ editor.update(() => {
+ original = $createToolGroupBlockNode({ tools })
+ cloned = ToolGroupBlockNode.clone(original)
+ imported = ToolGroupBlockNode.importJSON({
+ type: 'tool-group-block',
+ version: 1,
+ tools: tools.slice(0, 1),
+ })
+ })
+ })
+
+ expect(cloned).not.toBe(original)
+ expect(cloned.exportJSON()).toEqual(original.exportJSON())
+ expect(imported.exportJSON()).toEqual({
+ type: 'tool-group-block',
+ version: 1,
+ tools: tools.slice(0, 1),
+ })
+ })
+
+ it('should decorate the node with the tool group payload', () => {
+ const editor = createLexicalTestEditor('tool-group-block-node-decorate-test', [ToolGroupBlockNode])
+ let node!: ToolGroupBlockNode
+
+ act(() => {
+ editor.update(() => {
+ node = $createToolGroupBlockNode({ tools })
+ })
+ })
+
+ render(node.decorate())
+
+ expect(screen.getByTestId('tool-group-block-component')).toHaveTextContent('"provider":"openai/tools"')
+ expect(screen.getByTestId('tool-group-block-component')).toHaveTextContent('"provider":"anthropic"')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-group-block-replacement-block.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-group-block-replacement-block.spec.tsx
new file mode 100644
index 00000000000..d223fb71a4e
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-group-block-replacement-block.spec.tsx
@@ -0,0 +1,79 @@
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { render, waitFor } from '@testing-library/react'
+import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
+import {
+ getNodeCount,
+ renderLexicalEditor,
+ setEditorRootText,
+ waitForEditorReady,
+} from '@/app/components/base/prompt-editor/plugins/test-helpers'
+import { ToolGroupBlockNode } from '../tool-group-block-node'
+import ToolGroupBlockReplacementBlock from '../tool-group-block-replacement-block'
+import { buildToolTokenList } from '../utils'
+
+vi.mock('../tool-group-block-component', () => ({
+ default: ({ tools }: { tools: Array<{ tool: string }> }) => {tools.map(tool => tool.tool).join(',')}
,
+}))
+
+const tokenList = buildToolTokenList([
+ {
+ provider: 'openai/tools',
+ tool: 'search',
+ configId: '11111111-1111-4111-8111-111111111111',
+ },
+ {
+ provider: 'anthropic',
+ tool: 'browse',
+ configId: '22222222-2222-4222-8222-222222222222',
+ },
+])
+
+const renderReplacementPlugin = () => {
+ return renderLexicalEditor({
+ namespace: 'tool-group-block-replacement-plugin-test',
+ nodes: [CustomTextNode, ToolGroupBlockNode],
+ children: ,
+ })
+}
+
+describe('ToolGroupBlockReplacementBlock', () => {
+ it('should replace serialized tool token lists with tool group nodes', async () => {
+ const { getEditor } = renderReplacementPlugin()
+ const editor = await waitForEditorReady(getEditor)
+
+ setEditorRootText(editor, `prefix ${tokenList} suffix`, text => new CustomTextNode(text))
+
+ await waitFor(() => {
+ expect(getNodeCount(editor, ToolGroupBlockNode)).toBe(1)
+ })
+ })
+
+ it('should leave plain text untouched when no tool token list is present', async () => {
+ const { getEditor } = renderReplacementPlugin()
+ const editor = await waitForEditorReady(getEditor)
+
+ setEditorRootText(editor, 'plain text without tokens', text => new CustomTextNode(text))
+
+ await waitFor(() => {
+ expect(getNodeCount(editor, ToolGroupBlockNode)).toBe(0)
+ })
+ })
+
+ it('should throw when the tool group block node is not registered', () => {
+ expect(() => {
+ render(
+ {
+ throw error
+ },
+ nodes: [CustomTextNode],
+ }}
+ >
+
+ ,
+ )
+ }).toThrow('ToolGroupBlockReplacementBlock: ToolGroupBlockNode not registered on editor')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-header.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-header.spec.tsx
new file mode 100644
index 00000000000..8f3f8921642
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/tool-header.spec.tsx
@@ -0,0 +1,84 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import ToolHeader from '../tool-header'
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: (props: Record) => (
+ {JSON.stringify(props)}
+ ),
+}))
+
+describe('ToolHeader', () => {
+ it('should render labels and close action without an icon', () => {
+ const onClose = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Provider')).toBeInTheDocument()
+ expect(screen.getByText('Tool')).toBeInTheDocument()
+ expect(screen.getByText('Description')).toBeInTheDocument()
+ expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render a remote icon and invoke the back action', () => {
+ const onBack = vi.fn()
+
+ const { container } = render(
+ ,
+ )
+
+ const remoteIcon = container.querySelector('[style*="background-image"]')
+
+ expect(remoteIcon).toHaveStyle({ backgroundImage: 'url(https://cdn.example.com/icon.png)' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Back to tools' }))
+
+ expect(onBack).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render app icons for both icon names and emoji payloads', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('app-icon')).toHaveTextContent('"icon":"ri-search-line"')
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByTestId('app-icon')).toHaveTextContent('"icon":"moon"')
+ expect(screen.getByTestId('app-icon')).toHaveTextContent('"background":"#000000"')
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/utils.spec.ts b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/utils.spec.ts
new file mode 100644
index 00000000000..5df0b32735d
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/__tests__/utils.spec.ts
@@ -0,0 +1,51 @@
+import {
+ buildToolToken,
+ buildToolTokenList,
+ getToolTokenListRegexString,
+ getToolTokenRegexString,
+ parseToolToken,
+ parseToolTokenList,
+} from '../utils'
+
+const firstToken = {
+ provider: 'openai/tools',
+ tool: 'search',
+ configId: '11111111-1111-4111-8111-111111111111',
+}
+
+const secondToken = {
+ provider: 'anthropic',
+ tool: 'browse',
+ configId: '22222222-2222-4222-8222-222222222222',
+}
+
+describe('tool-block utils', () => {
+ it('should expose regexes that match tool tokens and token lists', () => {
+ const tokenRegex = new RegExp(`^${getToolTokenRegexString()}$`)
+ const listRegex = new RegExp(`^${getToolTokenListRegexString()}$`)
+
+ expect(tokenRegex.test(buildToolToken(firstToken))).toBe(true)
+ expect(tokenRegex.test('§[tool].[bad token]§')).toBe(false)
+ expect(listRegex.test(buildToolTokenList([firstToken, secondToken]))).toBe(true)
+ })
+
+ it('should parse tool tokens and token lists', () => {
+ expect(parseToolToken(buildToolToken(firstToken))).toEqual(firstToken)
+ expect(parseToolToken('plain-text')).toBeNull()
+ expect(parseToolTokenList(buildToolTokenList([firstToken, secondToken]))).toEqual([
+ firstToken,
+ secondToken,
+ ])
+ expect(parseToolTokenList('[plain-text')).toBeNull()
+ expect(parseToolTokenList('[]')).toBeNull()
+ expect(parseToolTokenList('[ , ]')).toBeNull()
+ expect(parseToolTokenList('[plain-text]')).toBeNull()
+ })
+
+ it('should build serialized tool tokens and lists', () => {
+ expect(buildToolToken(firstToken)).toBe('§[tool].[openai/tools].[search].[11111111-1111-4111-8111-111111111111]§')
+ expect(buildToolTokenList([firstToken, secondToken])).toBe(
+ `[${buildToolToken(firstToken)},${buildToolToken(secondToken)}]`,
+ )
+ })
+})
diff --git a/web/app/components/workflow/skill/editor/skill-editor/tool-setting/__tests__/tool-settings-section.spec.tsx b/web/app/components/workflow/skill/editor/skill-editor/tool-setting/__tests__/tool-settings-section.spec.tsx
new file mode 100644
index 00000000000..f7e17f2e301
--- /dev/null
+++ b/web/app/components/workflow/skill/editor/skill-editor/tool-setting/__tests__/tool-settings-section.spec.tsx
@@ -0,0 +1,162 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
+import ToolSettingsSection from '../tool-settings-section'
+
+const mocks = vi.hoisted(() => ({
+ formProps: [] as Array>,
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/app/components/base/divider', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form', () => ({
+ default: (props: Record) => {
+ mocks.formProps.push(props)
+ const variable = (props.schemas as Array<{ variable: string }>)[0]?.variable ?? 'unknown'
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
+ toolParametersToFormSchemas: (params: Array<{ name: string, type: string, default?: unknown }>) => {
+ return params.map(param => ({
+ variable: param.name,
+ type: param.type,
+ default: param.default,
+ }))
+ },
+}))
+
+beforeEach(() => {
+ mocks.formProps.length = 0
+})
+
+describe('ToolSettingsSection', () => {
+ it('should return null when the provider is not team-authorized or when there are no schemas', () => {
+ const { rerender, container } = render(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+
+ rerender(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should build safe config values and merge settings and params changes', () => {
+ const onChange = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('divider')).toBeInTheDocument()
+ expect(screen.getByText('detailPanel.toolSelector.reasoningConfig')).toBeInTheDocument()
+ expect(mocks.formProps).toHaveLength(2)
+ expect(mocks.formProps[0]).toMatchObject({
+ nodeId: 'workflow',
+ disableVariableReference: false,
+ value: {
+ model: {
+ auto: 0,
+ value: {
+ type: VarKindType.constant,
+ value: 'gpt-4.1',
+ },
+ },
+ },
+ })
+ expect(mocks.formProps[1]).toMatchObject({
+ nodeId: 'workflow',
+ disableVariableReference: false,
+ value: {
+ attachment: {
+ auto: 1,
+ value: {
+ type: VarKindType.variable,
+ value: null,
+ },
+ },
+ },
+ })
+
+ fireEvent.click(screen.getByTestId('reasoning-form-model'))
+ fireEvent.click(screen.getByTestId('reasoning-form-attachment'))
+
+ expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ settings: {
+ model: {
+ auto: 0,
+ value: {
+ type: 'constant',
+ value: 'updated',
+ },
+ },
+ },
+ }))
+ expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ parameters: {
+ attachment: {
+ auto: 0,
+ value: {
+ type: 'constant',
+ value: 'updated',
+ },
+ },
+ },
+ }))
+ })
+})
diff --git a/web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx b/web/app/components/workflow/skill/file-tree/artifacts/__tests__/artifacts-section.spec.tsx
similarity index 92%
rename from web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx
rename to web/app/components/workflow/skill/file-tree/artifacts/__tests__/artifacts-section.spec.tsx
index abb5b1076da..6a8ec9f8553 100644
--- a/web/app/components/workflow/skill/file-tree/artifacts/artifacts-section.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/artifacts/__tests__/artifacts-section.spec.tsx
@@ -1,6 +1,6 @@
import type { SandboxFileDownloadTicket, SandboxFileNode } from '@/types/sandbox-file'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import ArtifactsSection from './artifacts-section'
+import ArtifactsSection from '.././artifacts-section'
type MockStoreState = {
appId: string | undefined
@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
appId: 'app-1',
selectedArtifactPath: null,
} as MockStoreState,
- flatData: [] as SandboxFileNode[],
+ flatData: undefined as SandboxFileNode[] | undefined,
isLoading: false,
isDownloading: false,
selectArtifact: vi.fn(),
@@ -66,7 +66,7 @@ describe('ArtifactsSection', () => {
vi.clearAllMocks()
mocks.storeState.appId = 'app-1'
mocks.storeState.selectedArtifactPath = null
- mocks.flatData = []
+ mocks.flatData = undefined
mocks.isLoading = false
mocks.isDownloading = false
mocks.fetchDownloadUrl.mockResolvedValue({
@@ -119,6 +119,7 @@ describe('ArtifactsSection', () => {
// Covers expanded branches for empty and loading states.
describe('Expanded content', () => {
it('should render empty state when expanded and there are no files', () => {
+ mocks.flatData = []
render()
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
@@ -134,6 +135,15 @@ describe('ArtifactsSection', () => {
expect(screen.queryByText('workflow.skillSidebar.artifacts.emptyState')).not.toBeInTheDocument()
})
+
+ it('should treat an undefined query result as an empty tree', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
+
+ expect(screen.getByText('workflow.skillSidebar.artifacts.emptyState')).toBeInTheDocument()
+ expect(document.querySelector('.bg-state-accent-solid')).not.toBeInTheDocument()
+ })
})
// Covers real tree integration for selecting and downloading artifacts.
diff --git a/web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx b/web/app/components/workflow/skill/file-tree/artifacts/__tests__/artifacts-tree.spec.tsx
similarity index 99%
rename from web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx
rename to web/app/components/workflow/skill/file-tree/artifacts/__tests__/artifacts-tree.spec.tsx
index 09389268418..1982cc7010d 100644
--- a/web/app/components/workflow/skill/file-tree/artifacts/artifacts-tree.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/artifacts/__tests__/artifacts-tree.spec.tsx
@@ -1,6 +1,6 @@
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { fireEvent, render, screen } from '@testing-library/react'
-import ArtifactsTree from './artifacts-tree'
+import ArtifactsTree from '.././artifacts-tree'
const createNode = (overrides: Partial = {}): SandboxFileTreeNode => ({
id: 'node-1',
diff --git a/web/app/components/workflow/skill/file-tree/artifacts/__tests__/utils.spec.ts b/web/app/components/workflow/skill/file-tree/artifacts/__tests__/utils.spec.ts
new file mode 100644
index 00000000000..786ff1b23ef
--- /dev/null
+++ b/web/app/components/workflow/skill/file-tree/artifacts/__tests__/utils.spec.ts
@@ -0,0 +1,62 @@
+import { buildTreeFromFlatList } from '../utils'
+
+describe('artifacts utils', () => {
+ it('should build nested tree nodes from a flat node list', () => {
+ const tree = buildTreeFromFlatList([
+ {
+ path: 'folder',
+ is_dir: true,
+ size: 0,
+ mtime: 1,
+ extension: null,
+ },
+ {
+ path: 'folder/readme.md',
+ is_dir: false,
+ size: 12,
+ mtime: 2,
+ extension: 'md',
+ },
+ {
+ path: 'logo.png',
+ is_dir: false,
+ size: 3,
+ mtime: 3,
+ extension: 'png',
+ },
+ ])
+
+ expect(tree).toEqual([
+ expect.objectContaining({
+ id: 'folder',
+ node_type: 'folder',
+ children: [
+ expect.objectContaining({
+ id: 'folder/readme.md',
+ node_type: 'file',
+ extension: 'md',
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ id: 'logo.png',
+ node_type: 'file',
+ children: [],
+ }),
+ ])
+ })
+
+ it('should skip nodes whose parent path does not exist in the flat list', () => {
+ const tree = buildTreeFromFlatList([
+ {
+ path: 'missing-parent/readme.md',
+ is_dir: false,
+ size: 7,
+ mtime: 1,
+ extension: 'md',
+ },
+ ])
+
+ expect(tree).toEqual([])
+ })
+})
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/__tests__/drag-action-tooltip.spec.tsx
similarity index 94%
rename from web/app/components/workflow/skill/file-tree/tree/drag-action-tooltip.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/drag-action-tooltip.spec.tsx
index 6e126e145c1..843f826373c 100644
--- a/web/app/components/workflow/skill/file-tree/tree/drag-action-tooltip.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/drag-action-tooltip.spec.tsx
@@ -1,7 +1,7 @@
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'
+import { ROOT_ID } from '../../../constants'
+import DragActionTooltip from '.././drag-action-tooltip'
type MockWorkflowState = {
dragOverFolderId: string | null
@@ -18,7 +18,7 @@ vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
}))
-vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }),
}))
diff --git a/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/__tests__/file-tree.spec.tsx
similarity index 94%
rename from web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/file-tree.spec.tsx
index fa6a60bfd84..b370a271d58 100644
--- a/web/app/components/workflow/skill/file-tree/tree/file-tree.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/file-tree.spec.tsx
@@ -1,8 +1,8 @@
import type { HTMLAttributes, ReactNode, Ref } from 'react'
import type { AppAssetTreeView } from '@/types/app-asset'
import { fireEvent, render, screen } from '@testing-library/react'
-import { ROOT_ID } from '../../constants'
-import FileTree from './file-tree'
+import { ROOT_ID } from '../../../constants'
+import FileTree from '.././file-tree'
type MockWorkflowState = {
expandedFolderIds: Set
@@ -216,66 +216,66 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
-vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetTreeData: () => mocks.skillAssetTreeData,
}))
-vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+vi.mock('../../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
useSkillTreeCollaboration: () => mocks.useSkillTreeCollaboration(),
}))
-vi.mock('../../hooks/file-tree/dnd/use-root-file-drop', () => ({
+vi.mock('../../../hooks/file-tree/dnd/use-root-file-drop', () => ({
useRootFileDrop: () => mocks.rootDropHandlers,
}))
-vi.mock('../../hooks/file-tree/interaction/use-inline-create-node', () => ({
+vi.mock('../../../hooks/file-tree/interaction/use-inline-create-node', () => ({
useInlineCreateNode: () => mocks.inlineCreateNode,
}))
-vi.mock('../../hooks/file-tree/interaction/use-skill-shortcuts', () => ({
+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', () => ({
+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', () => ({
+vi.mock('../../../hooks/file-tree/operations/use-node-move', () => ({
useNodeMove: () => ({ executeMoveNode: mocks.executeMoveNode }),
}))
-vi.mock('../../hooks/file-tree/operations/use-node-reorder', () => ({
+vi.mock('../../../hooks/file-tree/operations/use-node-reorder', () => ({
useNodeReorder: () => ({ executeReorderNode: mocks.executeReorderNode }),
}))
-vi.mock('../../hooks/file-tree/operations/use-paste-operation', () => ({
+vi.mock('../../../hooks/file-tree/operations/use-paste-operation', () => ({
usePasteOperation: (args: unknown) => mocks.usePasteOperation(args),
}))
-vi.mock('../../utils/tree-utils', () => ({
+vi.mock('../../../utils/tree-utils', () => ({
isDescendantOf: (parentId: string, nodeId: string, treeChildren: AppAssetTreeView[]) =>
mocks.isDescendantOf(parentId, nodeId, treeChildren),
}))
-vi.mock('./search-result-list', () => ({
+vi.mock('.././search-result-list', () => ({
default: ({ searchTerm }: { searchTerm: string }) => (
{searchTerm}
),
}))
-vi.mock('./drag-action-tooltip', () => ({
+vi.mock('.././drag-action-tooltip', () => ({
default: ({ action }: { action: string }) => (
{action}
),
}))
-vi.mock('./upload-status-tooltip', () => ({
+vi.mock('.././upload-status-tooltip', () => ({
default: ({ fallback }: { fallback?: ReactNode }) => (
{fallback}
),
}))
-vi.mock('./tree-context-menu', () => ({
+vi.mock('.././tree-context-menu', () => ({
default: ({
children,
treeRef,
diff --git a/web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/__tests__/menu-item.spec.tsx
similarity index 93%
rename from web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/menu-item.spec.tsx
index 9550afa649b..686f95d627b 100644
--- a/web/app/components/workflow/skill/file-tree/tree/menu-item.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/menu-item.spec.tsx
@@ -1,4 +1,4 @@
-import type { MenuItemProps } from './menu-item'
+import type { MenuItemProps } from '.././menu-item'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ContextMenu,
@@ -10,7 +10,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
-import MenuItem from './menu-item'
+import MenuItem from '.././menu-item'
const MockIcon = (props: React.SVGProps) =>
@@ -67,6 +67,12 @@ describe('MenuItem', () => {
expect(item).not.toHaveClass('w-full')
})
+ it('should render inside the context menu variant', () => {
+ renderMenuItem({ menuType: 'context', label: 'Reveal' })
+
+ expect(screen.getByRole('menuitem', { name: /reveal/i })).toBeInTheDocument()
+ })
+
it('should apply destructive variant styles when variant is destructive', () => {
// Arrange
renderMenuItem({ variant: 'destructive', label: 'Delete' })
diff --git a/web/app/components/workflow/skill/file-tree/tree/__tests__/node-delete-confirm-dialog.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/__tests__/node-delete-confirm-dialog.spec.tsx
new file mode 100644
index 00000000000..fbbf59e5c10
--- /dev/null
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/node-delete-confirm-dialog.spec.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import NodeDeleteConfirmDialog from '../node-delete-confirm-dialog'
+
+describe('NodeDeleteConfirmDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the file deletion copy and call confirm/cancel handlers', () => {
+ const onConfirm = vi.fn()
+ const onCancel = vi.fn()
+
+ render(
+ ,
+ )
+
+ 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 }))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render the folder deletion copy and disable confirm while deleting', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmTitle')).toBeInTheDocument()
+ expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmContent')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.confirm/i })).toBeDisabled()
+ })
+})
diff --git a/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/__tests__/node-menu.spec.tsx
similarity index 99%
rename from web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/node-menu.spec.tsx
index f6bd486ea20..c7e27fb8d30 100644
--- a/web/app/components/workflow/skill/file-tree/tree/node-menu.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/node-menu.spec.tsx
@@ -10,8 +10,8 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
-import { NODE_MENU_TYPE } from '../../constants'
-import NodeMenu from './node-menu'
+import { NODE_MENU_TYPE } from '../../../constants'
+import NodeMenu from '.././node-menu'
type MockWorkflowState = {
hasClipboard: () => boolean
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/__tests__/search-result-list.spec.tsx
similarity index 99%
rename from web/app/components/workflow/skill/file-tree/tree/search-result-list.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/search-result-list.spec.tsx
index c018af096f5..224ab81c4a6 100644
--- a/web/app/components/workflow/skill/file-tree/tree/search-result-list.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/search-result-list.spec.tsx
@@ -1,6 +1,6 @@
import type { AppAssetTreeView } from '@/types/app-asset'
import { fireEvent, render, screen } from '@testing-library/react'
-import SearchResultList from './search-result-list'
+import SearchResultList from '.././search-result-list'
type MockWorkflowState = {
activeTabId: string | null
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/__tests__/tree-context-menu.spec.tsx
similarity index 66%
rename from web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/tree-context-menu.spec.tsx
index 63cf90d3e94..801421c69de 100644
--- a/web/app/components/workflow/skill/file-tree/tree/tree-context-menu.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-context-menu.spec.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import { ROOT_ID } from '../../constants'
-import TreeContextMenu from './tree-context-menu'
+import { ROOT_ID } from '../../../constants'
+import TreeContextMenu from '.././tree-context-menu'
const mocks = vi.hoisted(() => ({
selectedNodeIds: new Set(),
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
getNode: vi.fn(),
selectNode: vi.fn(),
useFileOperations: vi.fn(),
+ dynamicImporters: [] as Array<() => Promise>,
fileOperations: {
fileInputRef: { current: null },
folderInputRef: { current: null },
@@ -38,8 +39,9 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
-vi.mock('next/dynamic', () => ({
- default: () => {
+vi.mock('@/next/dynamic', () => ({
+ default: (loader: () => Promise) => {
+ mocks.dynamicImporters.push(loader)
const MockImportSkillModal = ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => {
if (!isOpen)
return null
@@ -57,15 +59,30 @@ vi.mock('next/dynamic', () => ({
},
}))
-vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
+vi.mock('../../start-tab/import-skill-modal', () => ({
+ default: ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => {
+ if (!isOpen)
+ return null
+
+ return (
+
+
+
+ )
+ },
+}))
+
+vi.mock('../../../hooks/file-tree/operations/use-file-operations', () => ({
useFileOperations: (...args: unknown[]) => {
mocks.useFileOperations(...args)
return mocks.fileOperations
},
}))
-vi.mock('./node-menu', () => ({
- default: ({ type, menuType, nodeId, actionNodeIds, onImportSkills }: { type: string, menuType: string, nodeId?: string, actionNodeIds?: string[], onImportSkills?: () => void }) => (
+vi.mock('.././node-menu', () => ({
+ default: ({ type, menuType, nodeId, actionNodeIds, onImportSkills, onClose }: { type: string, menuType: string, nodeId?: string, actionNodeIds?: string[], onImportSkills?: () => void, onClose?: () => void }) => (
({
open-import-skill-modal
)}
+ {onClose && (
+
+ )}
),
}))
@@ -196,6 +218,20 @@ describe('TreeContextMenu', () => {
expect(screen.queryByTestId('import-skill-modal')).not.toBeInTheDocument()
})
+ it('should wire the dynamic import loader and the menu close callback', async () => {
+ render(
+
+ blank area
+ ,
+ )
+
+ fireEvent.contextMenu(screen.getByText('blank area'))
+ fireEvent.click(screen.getByRole('button', { name: 'close-node-menu' }))
+
+ expect(mocks.dynamicImporters).toHaveLength(1)
+ await expect(mocks.dynamicImporters[0]()).resolves.toBeTruthy()
+ })
+
it('should keep delete confirmation dialog mounted for item context actions', () => {
mocks.fileOperations.showDeleteConfirm = true
@@ -212,5 +248,51 @@ describe('TreeContextMenu', () => {
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
})
+
+ it('should confirm folder deletion through the mounted dialog', () => {
+ mocks.fileOperations.showDeleteConfirm = true
+ mocks.getNode.mockReturnValue({
+ select: mocks.selectNode,
+ data: { name: 'docs' },
+ })
+
+ render(
+
+
+ docs
+
+ ,
+ )
+
+ fireEvent.contextMenu(screen.getByRole('treeitem'))
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i, hidden: true }))
+
+ expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmTitle')).toBeInTheDocument()
+ expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmContent')).toBeInTheDocument()
+ expect(mocks.fileOperations.handleDeleteConfirm).toHaveBeenCalledTimes(1)
+ })
+
+ it('should ignore context targets without a valid node id or menu type', () => {
+ render(
+
+
+
+ invalid-file
+
+
+ unknown-type
+
+
+ ,
+ )
+
+ fireEvent.contextMenu(screen.getByText('invalid-file'))
+ fireEvent.contextMenu(screen.getByText('unknown-type'))
+
+ expect(mocks.getNode).not.toHaveBeenCalled()
+ expect(mocks.setSelectedNodeIds).not.toHaveBeenCalled()
+ expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'root')
+ expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', ROOT_ID)
+ })
})
})
diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-edit-input.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-edit-input.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/file-tree/tree/tree-edit-input.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/tree-edit-input.spec.tsx
index 4953b634cef..adab06b6308 100644
--- a/web/app/components/workflow/skill/file-tree/tree/tree-edit-input.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-edit-input.spec.tsx
@@ -1,7 +1,7 @@
import type { NodeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../type'
+import type { TreeNodeData } from '../../../type'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import TreeEditInput from './tree-edit-input'
+import TreeEditInput from '.././tree-edit-input'
type MockNodeApi = Pick, 'data' | 'reset' | 'tree'>
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/__tests__/tree-guide-lines.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/file-tree/tree/tree-guide-lines.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/tree-guide-lines.spec.tsx
index 578c4ad7c51..2f79775818e 100644
--- a/web/app/components/workflow/skill/file-tree/tree/tree-guide-lines.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-guide-lines.spec.tsx
@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
-import TreeGuideLines from './tree-guide-lines'
+import TreeGuideLines from '.././tree-guide-lines'
describe('TreeGuideLines', () => {
beforeEach(() => {
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/__tests__/tree-node-icon.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/file-tree/tree/tree-node-icon.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/tree-node-icon.spec.tsx
index cb2296ca866..04d3001460c 100644
--- a/web/app/components/workflow/skill/file-tree/tree/tree-node-icon.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-node-icon.spec.tsx
@@ -1,11 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import { TreeNodeIcon } from './tree-node-icon'
+import { TreeNodeIcon } from '.././tree-node-icon'
const mocks = vi.hoisted(() => ({
getFileIconType: vi.fn(() => 'document'),
}))
-vi.mock('../../utils/file-utils', () => ({
+vi.mock('../../../utils/file-utils', () => ({
getFileIconType: mocks.getFileIconType,
}))
diff --git a/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-node.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/tree-node.spec.tsx
index da29172ae19..75dbdc684bc 100644
--- a/web/app/components/workflow/skill/file-tree/tree/tree-node.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/tree-node.spec.tsx
@@ -1,7 +1,7 @@
import type { NodeApi, NodeRendererProps, TreeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../type'
+import type { TreeNodeData } from '../../../type'
import { fireEvent, render, screen } from '@testing-library/react'
-import TreeNode from './tree-node'
+import TreeNode from '.././tree-node'
type MockWorkflowSelectorState = {
dirtyContents: Set
@@ -87,7 +87,7 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
-vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
+vi.mock('../../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
useTreeNodeHandlers: () => ({
handleClick: handlerMocks.handleClick,
handleDoubleClick: handlerMocks.handleDoubleClick,
@@ -96,7 +96,7 @@ vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
}),
}))
-vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
+vi.mock('../../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
useFolderFileDrop: () => ({
isDragOver: dndMocks.isDragOver,
isBlinking: dndMocks.isBlinking,
@@ -109,11 +109,11 @@ vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
}),
}))
-vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
+vi.mock('../../../hooks/file-tree/operations/use-file-operations', () => ({
useFileOperations: () => fileOperationMocks,
}))
-vi.mock('./node-menu', () => ({
+vi.mock('.././node-menu', () => ({
default: ({
type,
menuType,
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/__tests__/upload-status-tooltip.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/file-tree/tree/upload-status-tooltip.spec.tsx
rename to web/app/components/workflow/skill/file-tree/tree/__tests__/upload-status-tooltip.spec.tsx
index efa9fe28a2c..a76371b6f7f 100644
--- a/web/app/components/workflow/skill/file-tree/tree/upload-status-tooltip.spec.tsx
+++ b/web/app/components/workflow/skill/file-tree/tree/__tests__/upload-status-tooltip.spec.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import UploadStatusTooltip from './upload-status-tooltip'
+import UploadStatusTooltip from '.././upload-status-tooltip'
type MockWorkflowState = {
uploadStatus: 'idle' | 'uploading' | 'success' | 'partial_error'
diff --git a/web/app/components/workflow/skill/hooks/__tests__/use-fetch-text-content.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-fetch-text-content.spec.tsx
new file mode 100644
index 00000000000..947252e24b2
--- /dev/null
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-fetch-text-content.spec.tsx
@@ -0,0 +1,56 @@
+import {
+ QueryClient,
+ QueryClientProvider,
+} from '@tanstack/react-query'
+import { renderHook, waitFor } from '@testing-library/react'
+import { useFetchTextContent } from '../use-fetch-text-content'
+
+const fetchMock = vi.fn()
+
+describe('use-fetch-text-content', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', fetchMock)
+ })
+
+ const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ )
+ }
+
+ it('should fetch and cache text content when a download url is provided', async () => {
+ fetchMock.mockResolvedValue({
+ text: vi.fn().mockResolvedValue('hello world'),
+ } as unknown as Response)
+
+ const { result } = renderHook(() => useFetchTextContent('https://example.com/file.txt'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.data).toBe('hello world'))
+
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/file.txt')
+ expect(result.current.isLoading).toBe(false)
+ })
+
+ it('should stay idle when the download url is missing', () => {
+ const { result } = renderHook(() => useFetchTextContent(undefined), {
+ wrapper: createWrapper(),
+ })
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(result.current.fetchStatus).toBe('idle')
+ expect(result.current.data).toBeUndefined()
+ })
+})
diff --git a/web/app/components/workflow/skill/hooks/use-file-node-view-state.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-file-node-view-state.spec.tsx
similarity index 85%
rename from web/app/components/workflow/skill/hooks/use-file-node-view-state.spec.tsx
rename to web/app/components/workflow/skill/hooks/__tests__/use-file-node-view-state.spec.tsx
index 4be17f2008b..7a0682cc68c 100644
--- a/web/app/components/workflow/skill/hooks/use-file-node-view-state.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-file-node-view-state.spec.tsx
@@ -1,5 +1,5 @@
import { renderHook, waitFor } from '@testing-library/react'
-import { useFileNodeViewState } from './use-file-node-view-state'
+import { useFileNodeViewState as useFileNodeViewPhase } from '.././use-file-node-view-state'
type HookProps = {
fileTabId: string | null
@@ -21,7 +21,7 @@ const createProps = (overrides: Partial = {}): HookProps => ({
describe('useFileNodeViewState', () => {
describe('resolution lifecycle', () => {
it('should return ready when there is no active file tab', () => {
- const { result } = renderHook(() => useFileNodeViewState(createProps({
+ const { result } = renderHook(() => useFileNodeViewPhase(createProps({
fileTabId: null,
})))
@@ -29,14 +29,14 @@ describe('useFileNodeViewState', () => {
})
it('should return resolving during initial node resolution', () => {
- const { result } = renderHook(() => useFileNodeViewState(createProps()))
+ const { result } = renderHook(() => useFileNodeViewPhase(createProps()))
expect(result.current).toBe('resolving')
})
it('should return missing when query settles without a matching node', () => {
const { result, rerender } = renderHook(
- (props: HookProps) => useFileNodeViewState(props),
+ (props: HookProps) => useFileNodeViewPhase(props),
{ initialProps: createProps() },
)
@@ -51,7 +51,7 @@ describe('useFileNodeViewState', () => {
it('should stay missing during background refetch after missing is resolved', async () => {
const { result, rerender } = renderHook(
- (props: HookProps) => useFileNodeViewState(props),
+ (props: HookProps) => useFileNodeViewPhase(props),
{ initialProps: createProps() },
)
@@ -76,7 +76,7 @@ describe('useFileNodeViewState', () => {
it('should become ready once the target node appears', () => {
const { result, rerender } = renderHook(
- (props: HookProps) => useFileNodeViewState(props),
+ (props: HookProps) => useFileNodeViewPhase(props),
{ initialProps: createProps() },
)
@@ -92,7 +92,7 @@ describe('useFileNodeViewState', () => {
it('should reset to resolving when switching to another file tab', () => {
const { result, rerender } = renderHook(
- (props: HookProps) => useFileNodeViewState(props),
+ (props: HookProps) => useFileNodeViewPhase(props),
{ initialProps: createProps({
isNodeMapLoading: false,
isNodeMapFetching: false,
diff --git a/web/app/components/workflow/skill/hooks/__tests__/use-file-type-info.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-file-type-info.spec.tsx
new file mode 100644
index 00000000000..b1a38915c55
--- /dev/null
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-file-type-info.spec.tsx
@@ -0,0 +1,63 @@
+import { renderHook } from '@testing-library/react'
+import { useFileTypeInfo } from '../use-file-type-info'
+
+describe('useFileTypeInfo', () => {
+ it('should return a non-previewable default state when the file node is missing', () => {
+ const { result } = renderHook(() => useFileTypeInfo(undefined))
+
+ expect(result.current).toEqual({
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isMediaFile: false,
+ isPreviewable: false,
+ })
+ })
+
+ it('should classify markdown and editable files from their file name', () => {
+ const { result } = renderHook(() => useFileTypeInfo({
+ name: 'README.md',
+ }))
+
+ expect(result.current.isMarkdown).toBe(true)
+ expect(result.current.isEditable).toBe(true)
+ expect(result.current.isCodeOrText).toBe(false)
+ expect(result.current.isPreviewable).toBe(true)
+ })
+
+ it('should use an explicit extension override when provided', () => {
+ const { result } = renderHook(() => useFileTypeInfo({
+ name: 'README',
+ extension: '.PDF',
+ }))
+
+ expect(result.current.isPdf).toBe(true)
+ expect(result.current.isPreviewable).toBe(true)
+ expect(result.current.isEditable).toBe(false)
+ })
+
+ it('should fall back to the file name when the explicit extension is null', () => {
+ const { result } = renderHook(() => useFileTypeInfo({
+ name: 'clip.mp4',
+ extension: null,
+ }))
+
+ expect(result.current.isVideo).toBe(true)
+ expect(result.current.isMediaFile).toBe(true)
+ expect(result.current.isPreviewable).toBe(true)
+ })
+
+ it('should classify sqlite files as non-editable previews', () => {
+ const { result } = renderHook(() => useFileTypeInfo({
+ name: 'data.sqlite',
+ }))
+
+ expect(result.current.isSQLite).toBe(true)
+ expect(result.current.isEditable).toBe(false)
+ expect(result.current.isPreviewable).toBe(true)
+ })
+})
diff --git a/web/app/components/workflow/skill/hooks/use-skill-auto-save.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-skill-auto-save.spec.tsx
similarity index 94%
rename from web/app/components/workflow/skill/hooks/use-skill-auto-save.spec.tsx
rename to web/app/components/workflow/skill/hooks/__tests__/use-skill-auto-save.spec.tsx
index ea55cf47c05..e61ab0c7bca 100644
--- a/web/app/components/workflow/skill/hooks/use-skill-auto-save.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-skill-auto-save.spec.tsx
@@ -1,11 +1,11 @@
import { act, renderHook } from '@testing-library/react'
-import { useSkillAutoSave } from './use-skill-auto-save'
+import { useSkillAutoSave } from '.././use-skill-auto-save'
const { mockSaveAllDirty } = vi.hoisted(() => ({
mockSaveAllDirty: vi.fn(),
}))
-vi.mock('./skill-save-context', () => ({
+vi.mock('.././skill-save-context', () => ({
useSkillSaveManager: () => ({
saveAllDirty: mockSaveAllDirty,
}),
diff --git a/web/app/components/workflow/skill/hooks/use-skill-file-data.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-skill-file-data.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/use-skill-file-data.spec.tsx
rename to web/app/components/workflow/skill/hooks/__tests__/use-skill-file-data.spec.tsx
index 6e91f455183..186427ee68d 100644
--- a/web/app/components/workflow/skill/hooks/use-skill-file-data.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-skill-file-data.spec.tsx
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
-import { useSkillFileData } from './use-skill-file-data'
+import { useSkillFileData } from '.././use-skill-file-data'
const { mockUseQuery, mockContentOptions, mockDownloadUrlOptions } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
diff --git a/web/app/components/workflow/skill/hooks/use-skill-save-manager.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-skill-save-manager.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/use-skill-save-manager.spec.tsx
rename to web/app/components/workflow/skill/hooks/__tests__/use-skill-save-manager.spec.tsx
index adb1b7d9ecf..12133dea830 100644
--- a/web/app/components/workflow/skill/hooks/use-skill-save-manager.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-skill-save-manager.spec.tsx
@@ -5,9 +5,9 @@ import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { consoleQuery } from '@/service/client'
-import { START_TAB_ID } from '../constants'
-import { useSkillSaveManager } from './skill-save-context'
-import { SkillSaveProvider } from './use-skill-save-manager'
+import { START_TAB_ID } from '../../constants'
+import { useSkillSaveManager } from '.././skill-save-context'
+import { SkillSaveProvider } from '.././use-skill-save-manager'
const {
mockMutateAsync,
@@ -42,7 +42,7 @@ vi.mock('@/app/components/base/ui/toast', () => ({
},
}))
-vi.mock('../../collaboration/skills/skill-collaboration-manager', () => ({
+vi.mock('../../../collaboration/skills/skill-collaboration-manager', () => ({
skillCollaborationManager: {
isFileCollaborative: (fileId: string) => mockIsFileCollaborative(fileId),
isLeader: (fileId: string) => mockIsLeader(fileId),
@@ -64,11 +64,11 @@ const createWrapper = (params: { appId: string, store: ReturnType (
-
+
{children}
-
+
)
}
diff --git a/web/app/components/workflow/skill/hooks/__tests__/use-sqlite-database.spec.tsx b/web/app/components/workflow/skill/hooks/__tests__/use-sqlite-database.spec.tsx
new file mode 100644
index 00000000000..222935d9175
--- /dev/null
+++ b/web/app/components/workflow/skill/hooks/__tests__/use-sqlite-database.spec.tsx
@@ -0,0 +1,330 @@
+import {
+ act,
+ renderHook,
+ waitFor,
+} from '@testing-library/react'
+
+const mocks = vi.hoisted(() => {
+ class MemoryVFS {
+ name = 'memory'
+ mapNameToFile = new Map()
+
+ constructor() {
+ mocks.vfsInstances.push(this)
+ }
+ }
+
+ return {
+ fetch: vi.fn(),
+ sqliteFactory: vi.fn(),
+ sqliteESMFactory: vi.fn(async () => ({ wasm: true })),
+ execWithParams: vi.fn(),
+ openV2: vi.fn(),
+ close: vi.fn(),
+ vfsRegister: vi.fn(),
+ vfsInstances: [] as MemoryVFS[],
+ MemoryVFS,
+ }
+})
+
+vi.mock('wa-sqlite/dist/wa-sqlite.mjs', () => ({
+ default: () => mocks.sqliteESMFactory(),
+}))
+
+vi.mock('wa-sqlite', () => ({
+ Factory: () => ({
+ execWithParams: mocks.execWithParams,
+ open_v2: mocks.openV2,
+ close: mocks.close,
+ vfs_register: mocks.vfsRegister,
+ }),
+ SQLITE_OPEN_READONLY: 1,
+}))
+
+vi.mock('wa-sqlite/src/examples/MemoryVFS.js', () => ({
+ MemoryVFS: mocks.MemoryVFS,
+}))
+
+describe('useSQLiteDatabase', () => {
+ type HookProps = {
+ url: string | undefined
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.vfsInstances.length = 0
+ vi.stubGlobal('fetch', mocks.fetch)
+ vi.stubGlobal('crypto', {
+ randomUUID: () => 'uuid-1',
+ })
+
+ mocks.openV2.mockResolvedValue(99)
+ mocks.close.mockResolvedValue(undefined)
+ mocks.execWithParams.mockImplementation(async (_db: number, sql: string) => {
+ if (sql.includes('sqlite_master')) {
+ return {
+ columns: ['name'],
+ rows: [['users'], ['empty']],
+ }
+ }
+
+ if (sql === 'SELECT * FROM "users" LIMIT 5') {
+ return {
+ columns: ['id', 'name'],
+ rows: [[1, 'Ada']],
+ }
+ }
+
+ if (sql === 'SELECT * FROM "users" LIMIT 2') {
+ return {
+ columns: ['id', 'name'],
+ rows: [[1, 'Ada']],
+ }
+ }
+
+ if (sql === 'SELECT * FROM "empty"') {
+ return {
+ columns: [],
+ rows: [],
+ }
+ }
+
+ if (sql === 'PRAGMA table_info("empty")') {
+ return {
+ columns: ['cid', 'name'],
+ rows: [[0, 'id'], [1, 'name']],
+ }
+ }
+
+ return {
+ columns: ['id'],
+ rows: [],
+ }
+ })
+ })
+
+ const importHook = async () => {
+ vi.resetModules()
+ const hookModule = await import('../use-sqlite-database')
+ const constantsModule = await import('../sqlite/constants')
+ return {
+ useSQLiteDatabase: hookModule.useSQLiteDatabase,
+ TABLES_QUERY: constantsModule.TABLES_QUERY,
+ }
+ }
+
+ it('should load tables and query cached table data', async () => {
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+
+ const { useSQLiteDatabase, TABLES_QUERY } = await importHook()
+
+ const { result } = renderHook(() => useSQLiteDatabase('https://example.com/demo.db'))
+
+ await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
+ expect(mocks.fetch).toHaveBeenCalledWith('https://example.com/demo.db', { signal: expect.any(AbortSignal) })
+ expect(mocks.execWithParams).toHaveBeenCalledWith(99, TABLES_QUERY, [])
+
+ const first = await act(async () => result.current.queryTable('users', 5))
+ expect(first).toEqual({
+ columns: ['id', 'name'],
+ values: [[1, 'Ada']],
+ })
+
+ const second = await act(async () => result.current.queryTable('users', 5))
+ expect(second).toEqual(first)
+ expect(mocks.execWithParams).toHaveBeenCalledTimes(2)
+ })
+
+ it('should derive columns from pragma output when a table has no selected columns', async () => {
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result } = renderHook(() => useSQLiteDatabase('https://example.com/demo.db'))
+
+ await waitFor(() => expect(result.current.tables).toContain('empty'))
+
+ const data = await act(async () => result.current.queryTable('empty'))
+
+ expect(data).toEqual({
+ columns: ['id', 'name'],
+ values: [],
+ })
+ })
+
+ it('should return null when querying an unknown table or before initialization', async () => {
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result } = renderHook(() => useSQLiteDatabase('https://example.com/demo.db'))
+
+ expect(await act(async () => result.current.queryTable('users'))).toBeNull()
+
+ await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
+ expect(await act(async () => result.current.queryTable('missing'))).toBeNull()
+ })
+
+ it('should surface fetch errors and reset when the url is removed', async () => {
+ mocks.fetch
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ arrayBuffer: vi.fn(),
+ } as unknown as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+
+ const { useSQLiteDatabase } = await importHook()
+
+ const { result, rerender } = renderHook(
+ ({ url }: HookProps) => useSQLiteDatabase(url),
+ {
+ initialProps: {
+ url: 'https://example.com/broken.db',
+ } as HookProps,
+ },
+ )
+
+ await waitFor(() => expect(result.current.error?.message).toBe('Failed to fetch database: 500'))
+
+ rerender({ url: 'https://example.com/demo.db' } as HookProps)
+ await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
+
+ rerender({ url: undefined } as HookProps)
+ await waitFor(() => expect(result.current.tables).toEqual([]))
+ expect(mocks.close).toHaveBeenCalled()
+ })
+
+ it('should use a fallback temporary file name when crypto.randomUUID is unavailable', async () => {
+ vi.stubGlobal('crypto', undefined)
+ vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
+ vi.spyOn(Math, 'random').mockReturnValue(0.5)
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result } = renderHook(() => useSQLiteDatabase('https://example.com/fallback.db'))
+
+ await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
+ expect(mocks.openV2.mock.calls[0][0]).toMatch(/^preview-1700000000000-/)
+ })
+
+ it('should ignore a resolved fetch when the hook is cancelled before the response arrives', async () => {
+ let resolveFetch!: (value: Response) => void
+ const fetchPromise = new Promise((resolve) => {
+ resolveFetch = resolve
+ })
+ mocks.fetch.mockReturnValue(fetchPromise)
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result, rerender } = renderHook(
+ ({ url }: HookProps) => useSQLiteDatabase(url),
+ { initialProps: { url: 'https://example.com/cancel.db' } as HookProps },
+ )
+
+ rerender({ url: undefined } as HookProps)
+ resolveFetch({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+
+ await waitFor(() => expect(result.current.tables).toEqual([]))
+ expect(mocks.openV2).not.toHaveBeenCalled()
+ })
+
+ it('should ignore an array buffer that resolves after cancellation', async () => {
+ let resolveArrayBuffer!: (value: ArrayBuffer) => void
+ const arrayBufferMock = vi.fn().mockImplementation(() => new Promise((resolve) => {
+ resolveArrayBuffer = resolve
+ }))
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ arrayBuffer: arrayBufferMock,
+ } as unknown as Response)
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result, rerender } = renderHook(
+ ({ url }: HookProps) => useSQLiteDatabase(url),
+ { initialProps: { url: 'https://example.com/cancel-buffer.db' } as HookProps },
+ )
+
+ await waitFor(() => expect(arrayBufferMock).toHaveBeenCalledTimes(1))
+ rerender({ url: undefined } as HookProps)
+ resolveArrayBuffer(new ArrayBuffer(8))
+
+ await waitFor(() => expect(result.current.tables).toEqual([]))
+ expect(mocks.openV2).not.toHaveBeenCalled()
+ })
+
+ it('should close a database opened after cancellation and remove its temp file', async () => {
+ let resolveOpen!: (value: number) => void
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
+ } as unknown as Response)
+ mocks.openV2.mockImplementationOnce(() => new Promise((resolve) => {
+ resolveOpen = resolve
+ }))
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result, rerender } = renderHook(
+ ({ url }: HookProps) => useSQLiteDatabase(url),
+ { initialProps: { url: 'https://example.com/cancel-open.db' } as HookProps },
+ )
+
+ await waitFor(() => expect(mocks.openV2).toHaveBeenCalledTimes(1))
+ rerender({ url: undefined } as HookProps)
+ resolveOpen(777)
+
+ await waitFor(() => expect(result.current.tables).toEqual([]))
+ expect(mocks.close).toHaveBeenCalledWith(777)
+ expect(mocks.vfsInstances.at(-1)?.mapNameToFile.size).toBe(0)
+ })
+
+ it('should wrap non-error failures when initialization rejects', async () => {
+ mocks.fetch.mockRejectedValue('network failure')
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result } = renderHook(() => useSQLiteDatabase('https://example.com/non-error.db'))
+
+ await waitFor(() => expect(result.current.error?.message).toBe('network failure'))
+ expect(result.current.error).toBeInstanceOf(Error)
+ })
+
+ it('should ignore rejected initialization after the hook has been cancelled', async () => {
+ let rejectFetch!: (reason?: unknown) => void
+ const fetchPromise = new Promise((_, reject) => {
+ rejectFetch = reject
+ })
+ mocks.fetch.mockReturnValue(fetchPromise)
+
+ const { useSQLiteDatabase } = await importHook()
+ const { result, rerender } = renderHook(
+ ({ url }: HookProps) => useSQLiteDatabase(url),
+ { initialProps: { url: 'https://example.com/cancel-error.db' } as HookProps },
+ )
+
+ rerender({ url: undefined } as HookProps)
+ rejectFetch(new Error('late failure'))
+
+ await waitFor(() => expect(result.current.tables).toEqual([]))
+ expect(result.current.error).toBeNull()
+ })
+})
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/__tests__/use-skill-asset-tree.spec.tsx
similarity index 99%
rename from web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/data/__tests__/use-skill-asset-tree.spec.tsx
index 9f8d8db0f8a..1b28beb7f63 100644
--- 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/__tests__/use-skill-asset-tree.spec.tsx
@@ -7,7 +7,7 @@ import {
useExistingSkillNames,
useSkillAssetNodeMap,
useSkillAssetTreeData,
-} from './use-skill-asset-tree'
+} from '.././use-skill-asset-tree'
const { mockUseQuery, mockAppAssetTreeOptions } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
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/__tests__/use-skill-tree-collaboration.spec.tsx
similarity index 99%
rename from web/app/components/workflow/skill/hooks/file-tree/data/use-skill-tree-collaboration.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/data/__tests__/use-skill-tree-collaboration.spec.tsx
index e5e4365e439..3523a7d4bb5 100644
--- 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/__tests__/use-skill-tree-collaboration.spec.tsx
@@ -11,7 +11,7 @@ import { consoleQuery } from '@/service/client'
import {
useSkillTreeCollaboration,
useSkillTreeUpdateEmitter,
-} from './use-skill-tree-collaboration'
+} from '.././use-skill-tree-collaboration'
const {
mockEmitTreeUpdate,
diff --git a/web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-file-drop.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-file-drop.spec.tsx
index d90f8b6547e..4c0a8fb1faa 100644
--- a/web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-file-drop.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-file-drop.spec.tsx
@@ -176,6 +176,45 @@ describe('useFileDrop', () => {
expect(store.getState().uploadStatus).toBe('idle')
})
+ it('should ignore non-file items and null files from the drop payload', async () => {
+ const store = createWorkflowStore({})
+ const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
+ const event = createDragEvent({
+ items: [
+ createDataTransferItem({ kind: 'string' }),
+ createDataTransferItem({ file: null }),
+ ],
+ })
+
+ await act(async () => {
+ await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-null')
+ })
+
+ expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled()
+ expect(mockUploadMutateAsync).not.toHaveBeenCalled()
+ expect(mockToastError).not.toHaveBeenCalled()
+ })
+
+ it('should handle drag payloads with missing item collections', async () => {
+ const store = createWorkflowStore({})
+ const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
+ const event = {
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ dataTransfer: {
+ types: ['Files'],
+ dropEffect: 'none',
+ },
+ } as unknown as React.DragEvent
+
+ await act(async () => {
+ await result.current.handleDrop(event, null)
+ })
+
+ expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled()
+ expect(mockUploadMutateAsync).not.toHaveBeenCalled()
+ })
+
it('should upload valid files while rejecting directories in a mixed drop payload', async () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
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/__tests__/use-folder-file-drop.spec.tsx
similarity index 74%
rename from web/app/components/workflow/skill/hooks/file-tree/dnd/use-folder-file-drop.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-folder-file-drop.spec.tsx
index 263f2899243..aec179b52bb 100644
--- 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/__tests__/use-folder-file-drop.spec.tsx
@@ -1,12 +1,12 @@
import type { ReactNode } from 'react'
import type { NodeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../../type'
+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'
+import { INTERNAL_NODE_DRAG_TYPE } from '../../../../constants'
+import { useFolderFileDrop } from '.././use-folder-file-drop'
const {
mockHandleDragOver,
@@ -16,7 +16,7 @@ const {
mockHandleDrop: vi.fn(),
}))
-vi.mock('./use-unified-drag', () => ({
+vi.mock('.././use-unified-drag', () => ({
useUnifiedDrag: () => ({
handleDragOver: mockHandleDragOver,
handleDrop: mockHandleDrop,
@@ -25,17 +25,21 @@ vi.mock('./use-unified-drag', () => ({
const createWrapper = (store: ReturnType) => {
return ({ children }: { children: ReactNode }) => (
-
+
{children}
-
+
)
}
+type MutableNodeApi = NodeApi & {
+ isOpen: boolean
+}
+
const createNode = (params: {
id?: string
nodeType: 'file' | 'folder'
isOpen?: boolean
-}): NodeApi => {
+}): MutableNodeApi => {
const node = {
data: {
id: params.id ?? 'node-1',
@@ -50,7 +54,7 @@ const createNode = (params: {
open: vi.fn(),
}
- return node as unknown as NodeApi
+ return node as unknown as MutableNodeApi
}
const createDragEvent = (types: string[]): React.DragEvent => {
@@ -112,6 +116,27 @@ describe('useFolderFileDrop', () => {
// Scenario: drag handlers delegate only for supported drag events on folder nodes.
describe('drag handlers', () => {
+ it('should track supported drag enter and leave events for folder nodes', () => {
+ const store = createWorkflowStore({})
+ const node = createNode({ id: 'folder-enter', nodeType: 'folder' })
+
+ const { result } = renderHook(() => useFolderFileDrop({
+ node,
+ treeChildren: EMPTY_TREE_CHILDREN,
+ }), {
+ wrapper: createWrapper(store),
+ })
+
+ const event = createDragEvent(['Files'])
+ act(() => {
+ result.current.dragHandlers.onDragEnter(event)
+ result.current.dragHandlers.onDragLeave(event)
+ })
+
+ expect(mockHandleDragOver).not.toHaveBeenCalled()
+ expect(mockHandleDrop).not.toHaveBeenCalled()
+ })
+
it('should delegate drag over and drop for supported file drag events', () => {
const store = createWorkflowStore({})
const node = createNode({ id: 'folder-2', nodeType: 'folder' })
@@ -181,6 +206,24 @@ describe('useFolderFileDrop', () => {
isFolder: true,
})
})
+
+ it('should ignore drop events on non-folder nodes', () => {
+ const store = createWorkflowStore({})
+ const node = createNode({ id: 'file-2', nodeType: 'file' })
+
+ const { result } = renderHook(() => useFolderFileDrop({
+ node,
+ treeChildren: EMPTY_TREE_CHILDREN,
+ }), {
+ wrapper: createWrapper(store),
+ })
+
+ act(() => {
+ result.current.dragHandlers.onDrop(createDragEvent(['Files']))
+ })
+
+ expect(mockHandleDrop).not.toHaveBeenCalled()
+ })
})
// Scenario: auto-expand lifecycle should blink first, expand later, and cleanup when drag state changes.
@@ -238,5 +281,31 @@ describe('useFolderFileDrop', () => {
})
expect(node.open).not.toHaveBeenCalled()
})
+
+ it('should skip opening the folder when it becomes open before the expand timer fires', () => {
+ const store = createWorkflowStore({})
+ store.getState().setDragOverFolderId('folder-7')
+ const node = createNode({ id: 'folder-7', 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)
+
+ node.isOpen = true
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(result.current.isBlinking).toBe(false)
+ 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/__tests__/use-root-file-drop.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/hooks/file-tree/dnd/use-root-file-drop.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-root-file-drop.spec.tsx
index b4356f30642..afa574ca2d0 100644
--- 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/__tests__/use-root-file-drop.spec.tsx
@@ -5,8 +5,8 @@ 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'
+import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../../../../constants'
+import { useRootFileDrop } from '.././use-root-file-drop'
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
mockUploadMutateAsync: vi.fn(),
@@ -39,9 +39,9 @@ const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEve
const createWrapper = (store: ReturnType) => {
return ({ children }: { children: ReactNode }) => (
-
+
{children}
-
+
)
}
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/__tests__/use-unified-drag.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/hooks/file-tree/dnd/use-unified-drag.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/dnd/__tests__/use-unified-drag.spec.tsx
index fc84285cb0d..eecc5b6e4f7 100644
--- 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/__tests__/use-unified-drag.spec.tsx
@@ -4,8 +4,8 @@ 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'
+import { INTERNAL_NODE_DRAG_TYPE } from '../../../../constants'
+import { useUnifiedDrag } from '.././use-unified-drag'
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
mockUploadMutateAsync: vi.fn(),
@@ -38,9 +38,9 @@ const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEve
const createWrapper = (store: ReturnType) => {
return ({ children }: { children: ReactNode }) => (
-
+
{children}
-
+
)
}
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/__tests__/use-delayed-click.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/file-tree/interaction/use-delayed-click.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-delayed-click.spec.tsx
index f92ac175632..a40a55af760 100644
--- 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/__tests__/use-delayed-click.spec.tsx
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
-import { useDelayedClick } from './use-delayed-click'
+import { useDelayedClick } from '.././use-delayed-click'
describe('useDelayedClick', () => {
beforeEach(() => {
diff --git a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-inline-create-node.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-inline-create-node.spec.tsx
similarity index 95%
rename from web/app/components/workflow/skill/hooks/file-tree/interaction/use-inline-create-node.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-inline-create-node.spec.tsx
index c10a40cd8d3..9dc7336e1aa 100644
--- a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-inline-create-node.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-inline-create-node.spec.tsx
@@ -1,13 +1,13 @@
import type { ReactNode } from 'react'
import type { TreeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../../type'
+import type { TreeNodeData } from '../../../../type'
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 { START_TAB_ID } from '../../../constants'
-import { useInlineCreateNode } from './use-inline-create-node'
+import { START_TAB_ID } from '../../../../constants'
+import { useInlineCreateNode } from '.././use-inline-create-node'
const {
mockUploadMutate,
@@ -37,7 +37,7 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../data/use-skill-tree-collaboration', () => ({
+vi.mock('../../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate,
}))
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/__tests__/use-skill-shortcuts.spec.tsx
similarity index 80%
rename from web/app/components/workflow/skill/hooks/file-tree/interaction/use-skill-shortcuts.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-skill-shortcuts.spec.tsx
index d596a979f06..411b097dd6b 100644
--- 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/__tests__/use-skill-shortcuts.spec.tsx
@@ -1,9 +1,9 @@
import type { RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../../type'
+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'
+import { useSkillShortcuts } from '.././use-skill-shortcuts'
const {
mockUseKeyPress,
@@ -91,6 +91,25 @@ describe('useSkillShortcuts', () => {
expect(mockCutNodes).toHaveBeenCalledWith(['file-1', 'file-2'])
})
+ it('should ignore cut shortcut when the tree ref is missing even inside the tree container', () => {
+ const treeRef = { current: null }
+ 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).not.toHaveBeenCalled()
+ expect(mockCutNodes).not.toHaveBeenCalled()
+ })
+
it('should cut selected nodes even when event target is outside tree container', () => {
const treeRef = createTreeRef(['file-3'])
renderHook(() => useSkillShortcuts({ treeRef }))
@@ -123,6 +142,25 @@ describe('useSkillShortcuts', () => {
expect(mockCutNodes).not.toHaveBeenCalled()
})
+ it('should ignore cut shortcut when there is no selection inside the tree container', () => {
+ const treeRef = createTreeRef([])
+ 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).not.toHaveBeenCalled()
+ expect(mockCutNodes).not.toHaveBeenCalled()
+ })
+
it('should ignore cut shortcut when shortcuts are disabled', () => {
const treeRef = createTreeRef(['file-1'])
const { rerender } = renderHook(
diff --git a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-sync-tree-with-active-tab.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-sync-tree-with-active-tab.spec.tsx
similarity index 59%
rename from web/app/components/workflow/skill/hooks/file-tree/interaction/use-sync-tree-with-active-tab.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-sync-tree-with-active-tab.spec.tsx
index 05a5c722587..5eb7515e668 100644
--- a/web/app/components/workflow/skill/hooks/file-tree/interaction/use-sync-tree-with-active-tab.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-sync-tree-with-active-tab.spec.tsx
@@ -1,11 +1,11 @@
import type { ReactNode, RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../../type'
+import type { TreeNodeData } from '../../../../type'
import { renderHook } from '@testing-library/react'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store'
-import { START_TAB_ID } from '../../../constants'
-import { useSyncTreeWithActiveTab } from './use-sync-tree-with-active-tab'
+import { START_TAB_ID } from '../../../../constants'
+import { useSyncTreeWithActiveTab } from '.././use-sync-tree-with-active-tab'
type MockTreeNode = {
id: string
@@ -18,9 +18,9 @@ type MockTreeNode = {
const createWrapper = (store: ReturnType) => {
return ({ children }: { children: ReactNode }) => (
-
+
{children}
-
+
)
}
@@ -38,6 +38,46 @@ describe('useSyncTreeWithActiveTab', () => {
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
})
+ it('should skip syncing when there is no active tab', () => {
+ const store = createWorkflowStore({})
+ const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame')
+ const treeRef = createTreeRef({
+ selectedNodes: [],
+ deselectAll: vi.fn(),
+ get: vi.fn(),
+ openParents: vi.fn(),
+ select: vi.fn(),
+ })
+
+ renderHook(() => useSyncTreeWithActiveTab({
+ treeRef,
+ activeTabId: null,
+ isTreeLoading: false,
+ }), { wrapper: createWrapper(store) })
+
+ expect(requestAnimationFrameSpy).not.toHaveBeenCalled()
+ })
+
+ it('should skip syncing while the tree is still loading', () => {
+ const store = createWorkflowStore({})
+ const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame')
+ const treeRef = createTreeRef({
+ selectedNodes: [],
+ deselectAll: vi.fn(),
+ get: vi.fn(),
+ openParents: vi.fn(),
+ select: vi.fn(),
+ })
+
+ renderHook(() => useSyncTreeWithActiveTab({
+ treeRef,
+ activeTabId: 'file-1',
+ isTreeLoading: true,
+ }), { wrapper: createWrapper(store) })
+
+ expect(requestAnimationFrameSpy).not.toHaveBeenCalled()
+ })
+
it('should clear tree selection when active tab is start tab', () => {
const store = createWorkflowStore({})
const deselectAll = vi.fn()
@@ -59,6 +99,38 @@ describe('useSyncTreeWithActiveTab', () => {
expect(deselectAll).toHaveBeenCalledTimes(1)
})
+ it('should leave selection untouched for artifact tabs when nothing is selected', () => {
+ const store = createWorkflowStore({})
+ const deselectAll = vi.fn()
+ const treeRef = createTreeRef({
+ selectedNodes: [],
+ deselectAll,
+ get: vi.fn(),
+ openParents: vi.fn(),
+ select: vi.fn(),
+ })
+
+ renderHook(() => useSyncTreeWithActiveTab({
+ treeRef,
+ activeTabId: 'artifact:file.png',
+ isTreeLoading: false,
+ }), { wrapper: createWrapper(store) })
+
+ expect(deselectAll).not.toHaveBeenCalled()
+ })
+
+ it('should stop when the tree ref is not attached yet', () => {
+ const store = createWorkflowStore({})
+
+ renderHook(() => useSyncTreeWithActiveTab({
+ treeRef: { current: null },
+ activeTabId: 'file-1',
+ isTreeLoading: false,
+ }), { wrapper: createWrapper(store) })
+
+ expect(store.getState().expandedFolderIds.size).toBe(0)
+ })
+
it('should reveal ancestors and select active file node when node exists', () => {
const store = createWorkflowStore({})
const openParents = vi.fn()
@@ -127,6 +199,39 @@ describe('useSyncTreeWithActiveTab', () => {
expect(select).not.toHaveBeenCalled()
})
+ it('should avoid reopening parents when every ancestor is already open', () => {
+ const store = createWorkflowStore({})
+ const openParents = vi.fn()
+ const select = vi.fn()
+
+ const root: MockTreeNode = { id: 'root', isRoot: true, parent: null }
+ const folder: MockTreeNode = { id: 'folder-a', isRoot: false, parent: root, isOpen: true }
+ const fileNode: MockTreeNode = {
+ id: 'file-1',
+ isRoot: false,
+ parent: folder,
+ isSelected: false,
+ isFocused: false,
+ }
+
+ const treeRef = createTreeRef({
+ selectedNodes: [],
+ deselectAll: vi.fn(),
+ get: vi.fn(() => fileNode),
+ openParents,
+ select,
+ })
+
+ renderHook(() => useSyncTreeWithActiveTab({
+ treeRef,
+ activeTabId: 'file-1',
+ isTreeLoading: false,
+ }), { wrapper: createWrapper(store) })
+
+ expect(openParents).not.toHaveBeenCalled()
+ expect(select).toHaveBeenCalledWith('file-1')
+ })
+
it('should retry syncing on syncSignal change when node appears later', () => {
const store = createWorkflowStore({})
const select = vi.fn()
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/__tests__/use-tree-node-handlers.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/file-tree/interaction/use-tree-node-handlers.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/interaction/__tests__/use-tree-node-handlers.spec.tsx
index 5878c090a27..b2f57899ee3 100644
--- 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/__tests__/use-tree-node-handlers.spec.tsx
@@ -1,7 +1,7 @@
import type { NodeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../../type'
+import type { TreeNodeData } from '../../../../type'
import { act, renderHook } from '@testing-library/react'
-import { useTreeNodeHandlers } from './use-tree-node-handlers'
+import { useTreeNodeHandlers } from '.././use-tree-node-handlers'
const {
mockClearArtifactSelection,
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/__tests__/use-create-operations.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-create-operations.spec.tsx
index 33a1b9153f9..4d91d873e69 100644
--- 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/__tests__/use-create-operations.spec.tsx
@@ -2,7 +2,7 @@ import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
-import { useCreateOperations } from './use-create-operations'
+import { useCreateOperations } from '.././use-create-operations'
type UploadMutationPayload = {
appId: string
@@ -48,11 +48,11 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../../../utils/skill-upload-utils', () => ({
+vi.mock('../../../../utils/skill-upload-utils', () => ({
prepareSkillUploadFile: mocks.prepareSkillUploadFile,
}))
-vi.mock('../data/use-skill-tree-collaboration', () => ({
+vi.mock('../../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
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/__tests__/use-download-operation.spec.tsx
similarity index 89%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-download-operation.spec.tsx
index 7a12e131f49..780432c92b9 100644
--- 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/__tests__/use-download-operation.spec.tsx
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react'
-import { useDownloadOperation } from './use-download-operation'
+import { useDownloadOperation } from '.././use-download-operation'
type DownloadRequest = {
params: {
@@ -130,6 +130,25 @@ describe('useDownloadOperation', () => {
expect(result.current.isDownloading).toBe(false)
})
+ it('should preserve raw content when parsed text payload has no content field', async () => {
+ mockGetFileContent.mockResolvedValueOnce({ content: '{"title":"Skill"}' })
+ const onClose = vi.fn()
+ const { result } = renderHook(() => useDownloadOperation({
+ appId: 'app-1',
+ nodeId: 'node-raw',
+ fileName: 'config.json',
+ onClose,
+ }))
+
+ await act(async () => {
+ await result.current.handleDownload()
+ })
+
+ const downloadedBlob = mockDownloadBlob.mock.calls.at(-1)?.[0].data
+ await expect(downloadedBlob?.text()).resolves.toBe('{"title":"Skill"}')
+ expect(mockDownloadUrl).not.toHaveBeenCalled()
+ })
+
it('should download binary file from download url when file is not text', async () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDownloadOperation({
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/__tests__/use-file-operations.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-file-operations.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-file-operations.spec.tsx
index 3bbebc26bca..2d9b32d1fe6 100644
--- 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/__tests__/use-file-operations.spec.tsx
@@ -1,11 +1,11 @@
import type { RefObject } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { StoreApi } from 'zustand'
-import type { TreeNodeData } from '../../../type'
+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'
+import { useFileOperations } from '.././use-file-operations'
type AppStoreState = {
appDetail?: {
@@ -127,17 +127,17 @@ vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mocks.workflowStore,
}))
-vi.mock('../data/use-skill-asset-tree', () => ({
+vi.mock('../../data/use-skill-asset-tree', () => ({
useSkillAssetTreeData: () => ({
data: mocks.treeData,
}),
}))
-vi.mock('../../../utils/tree-utils', () => ({
+vi.mock('../../../../utils/tree-utils', () => ({
toApiParentId: mocks.toApiParentId,
}))
-vi.mock('./use-create-operations', () => ({
+vi.mock('.././use-create-operations', () => ({
useCreateOperations: (options: {
parentId: string | null
appId: string
@@ -146,7 +146,7 @@ vi.mock('./use-create-operations', () => ({
}) => mocks.createOpsHook(options),
}))
-vi.mock('./use-modify-operations', () => ({
+vi.mock('.././use-modify-operations', () => ({
useModifyOperations: (options: {
nodeId: string
node?: NodeApi
@@ -158,7 +158,7 @@ vi.mock('./use-modify-operations', () => ({
}) => mocks.modifyOpsHook(options),
}))
-vi.mock('./use-download-operation', () => ({
+vi.mock('.././use-download-operation', () => ({
useDownloadOperation: (options: {
appId: string
nodeId: string
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/__tests__/use-modify-operations.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-modify-operations.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-modify-operations.spec.tsx
index d5afb4962b6..d05c24fb835 100644
--- 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/__tests__/use-modify-operations.spec.tsx
@@ -1,11 +1,11 @@
import type { RefObject } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { StoreApi } from 'zustand'
-import type { TreeNodeData } from '../../../type'
+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'
+import { useModifyOperations } from '.././use-modify-operations'
type DeleteMutationPayload = {
appId: string
@@ -29,11 +29,11 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../data/use-skill-tree-collaboration', () => ({
+vi.mock('../../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
-vi.mock('../../../utils/tree-utils', () => ({
+vi.mock('../../../../utils/tree-utils', () => ({
getAllDescendantFileIds: mocks.getAllDescendantFileIds,
isDescendantOf: mocks.isDescendantOf,
}))
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/__tests__/use-node-move.spec.tsx
similarity index 95%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-node-move.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-node-move.spec.tsx
index a3f806c24b0..43eacb0b9cb 100644
--- 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/__tests__/use-node-move.spec.tsx
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
-import { useNodeMove } from './use-node-move'
+import { useNodeMove } from '.././use-node-move'
type AppStoreState = {
appDetail?: {
@@ -38,11 +38,11 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../data/use-skill-tree-collaboration', () => ({
+vi.mock('../../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
-vi.mock('../../../utils/tree-utils', () => ({
+vi.mock('../../../../utils/tree-utils', () => ({
toApiParentId: mocks.toApiParentId,
}))
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/__tests__/use-node-reorder.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-node-reorder.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-node-reorder.spec.tsx
index 378be4bb719..254f1e1faec 100644
--- 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/__tests__/use-node-reorder.spec.tsx
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
-import { useNodeReorder } from './use-node-reorder'
+import { useNodeReorder } from '.././use-node-reorder'
type AppStoreState = {
appDetail?: {
@@ -37,7 +37,7 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../data/use-skill-tree-collaboration', () => ({
+vi.mock('../../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
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/__tests__/use-paste-operation.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/hooks/file-tree/operations/use-paste-operation.spec.tsx
rename to web/app/components/workflow/skill/hooks/file-tree/operations/__tests__/use-paste-operation.spec.tsx
index a4c48b1269d..0230c4f4816 100644
--- 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/__tests__/use-paste-operation.spec.tsx
@@ -1,9 +1,9 @@
import type { RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
-import type { TreeNodeData } from '../../../type'
+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'
+import { usePasteOperation } from '.././use-paste-operation'
type MoveMutationPayload = {
appId: string
@@ -83,11 +83,11 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../data/use-skill-tree-collaboration', () => ({
+vi.mock('../../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
-vi.mock('../../../utils/tree-utils', () => ({
+vi.mock('../../../../utils/tree-utils', () => ({
getTargetFolderIdFromSelection: mocks.getTargetFolderIdFromSelection,
toApiParentId: mocks.toApiParentId,
findNodeById: mocks.findNodeById,
diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.ts b/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.ts
index 42ad5c9e043..7914233ce88 100644
--- a/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.ts
+++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-download-operation.ts
@@ -37,6 +37,7 @@ export function useDownloadOperation({
const { content } = await consoleClient.appAsset.getFileContent({
params: { appId, nodeId },
})
+ const textFileName = fileName!
let rawText = content
try {
const parsed = JSON.parse(content) as { content?: string }
@@ -48,7 +49,7 @@ export function useDownloadOperation({
downloadBlob({
data: new Blob([rawText], { type: 'text/plain;charset=utf-8' }),
- fileName: fileName || 'download.txt',
+ fileName: textFileName,
})
}
else {
diff --git a/web/app/components/workflow/skill/hooks/sqlite/__tests__/constants.spec.ts b/web/app/components/workflow/skill/hooks/sqlite/__tests__/constants.spec.ts
new file mode 100644
index 00000000000..d56f288243f
--- /dev/null
+++ b/web/app/components/workflow/skill/hooks/sqlite/__tests__/constants.spec.ts
@@ -0,0 +1,7 @@
+import { TABLES_QUERY } from '../constants'
+
+describe('sqlite constants', () => {
+ it('should expose the table listing query', () => {
+ expect(TABLES_QUERY).toBe('SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\' ORDER BY name')
+ })
+})
diff --git a/web/app/components/workflow/skill/skill-body/sidebar-search-add.spec.tsx b/web/app/components/workflow/skill/skill-body/__tests__/sidebar-search-add.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/skill-body/sidebar-search-add.spec.tsx
rename to web/app/components/workflow/skill/skill-body/__tests__/sidebar-search-add.spec.tsx
index 756592db012..486b700c90e 100644
--- a/web/app/components/workflow/skill/skill-body/sidebar-search-add.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/__tests__/sidebar-search-add.spec.tsx
@@ -1,7 +1,7 @@
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'
+import { ROOT_ID } from '../../constants'
+import SidebarSearchAdd from '.././sidebar-search-add'
type WorkflowStoreState = {
fileTreeSearchTerm: string
@@ -59,7 +59,9 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('next/dynamic', () => ({
- default: () => {
+ default: (loader: () => Promise) => {
+ void loader()
+
const MockImportSkillModal = ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => {
if (!isOpen)
return null
@@ -86,11 +88,11 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
-vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetTreeData: () => ({ data: mocks.treeData }),
}))
-vi.mock('../hooks/file-tree/operations/use-file-operations', () => ({
+vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
useFileOperations: (options: unknown) => {
mocks.useFileOperations(options)
return mocks.fileOperations
diff --git a/web/app/components/workflow/skill/skill-body/layout/content-area.spec.tsx b/web/app/components/workflow/skill/skill-body/layout/__tests__/content-area.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/skill-body/layout/content-area.spec.tsx
rename to web/app/components/workflow/skill/skill-body/layout/__tests__/content-area.spec.tsx
index 655e75d610b..6dc743be5d2 100644
--- a/web/app/components/workflow/skill/skill-body/layout/content-area.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/layout/__tests__/content-area.spec.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
-import ContentArea from './content-area'
+import ContentArea from '.././content-area'
describe('ContentArea', () => {
describe('Rendering', () => {
diff --git a/web/app/components/workflow/skill/skill-body/layout/content-body.spec.tsx b/web/app/components/workflow/skill/skill-body/layout/__tests__/content-body.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/skill-body/layout/content-body.spec.tsx
rename to web/app/components/workflow/skill/skill-body/layout/__tests__/content-body.spec.tsx
index 98519a48b4f..a0239784605 100644
--- a/web/app/components/workflow/skill/skill-body/layout/content-body.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/layout/__tests__/content-body.spec.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
-import ContentBody from './content-body'
+import ContentBody from '.././content-body'
describe('ContentBody', () => {
describe('Rendering', () => {
diff --git a/web/app/components/workflow/skill/skill-body/layout/sidebar.spec.tsx b/web/app/components/workflow/skill/skill-body/layout/__tests__/sidebar.spec.tsx
similarity index 95%
rename from web/app/components/workflow/skill/skill-body/layout/sidebar.spec.tsx
rename to web/app/components/workflow/skill/skill-body/layout/__tests__/sidebar.spec.tsx
index 365520da789..e8a0325b84f 100644
--- a/web/app/components/workflow/skill/skill-body/layout/sidebar.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/layout/__tests__/sidebar.spec.tsx
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import { STORAGE_KEYS } from '@/config/storage-keys'
-import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from '../../constants'
-import Sidebar from './sidebar'
+import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH } from '../../../constants'
+import Sidebar from '.././sidebar'
type ResizePanelParams = {
direction?: 'horizontal' | 'vertical' | 'both'
@@ -23,7 +23,7 @@ vi.mock('ahooks', () => ({
}),
}))
-vi.mock('../../../nodes/_base/hooks/use-resize-panel', () => ({
+vi.mock('../../../../nodes/_base/hooks/use-resize-panel', () => ({
useResizePanel: (params?: ResizePanelParams) => {
mocks.lastResizeParams = params
return {
diff --git a/web/app/components/workflow/skill/skill-body/layout/skill-page-layout.spec.tsx b/web/app/components/workflow/skill/skill-body/layout/__tests__/skill-page-layout.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/skill-body/layout/skill-page-layout.spec.tsx
rename to web/app/components/workflow/skill/skill-body/layout/__tests__/skill-page-layout.spec.tsx
index 4c4504d7a27..1c6a4be6821 100644
--- a/web/app/components/workflow/skill/skill-body/layout/skill-page-layout.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/layout/__tests__/skill-page-layout.spec.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
-import SkillPageLayout from './skill-page-layout'
+import SkillPageLayout from '.././skill-page-layout'
describe('SkillPageLayout', () => {
describe('Rendering', () => {
diff --git a/web/app/components/workflow/skill/skill-body/panels/artifact-content-panel.spec.tsx b/web/app/components/workflow/skill/skill-body/panels/__tests__/artifact-content-panel.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/skill-body/panels/artifact-content-panel.spec.tsx
rename to web/app/components/workflow/skill/skill-body/panels/__tests__/artifact-content-panel.spec.tsx
index d1c162b3387..bf4ceaa0fd8 100644
--- a/web/app/components/workflow/skill/skill-body/panels/artifact-content-panel.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/panels/__tests__/artifact-content-panel.spec.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
-import ArtifactContentPanel from './artifact-content-panel'
+import ArtifactContentPanel from '.././artifact-content-panel'
type WorkflowStoreState = {
activeTabId: string | null
diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/file-editor-renderer.spec.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/file-editor-renderer.spec.tsx
new file mode 100644
index 00000000000..b9c4bb7b43d
--- /dev/null
+++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/file-editor-renderer.spec.tsx
@@ -0,0 +1,157 @@
+import type { OnMount } from '@monaco-editor/react'
+import { act, render, screen } from '@testing-library/react'
+
+import FileEditorRenderer from '../file-editor-renderer'
+
+const mocks = vi.hoisted(() => ({
+ theme: 'light',
+ isClient: true,
+ loaderConfig: vi.fn(),
+ editorProps: [] as Array>,
+ markdownProps: [] as Array>,
+ monacoSetTheme: vi.fn(),
+ getFileLanguage: vi.fn((_: string) => 'typescript'),
+}))
+
+vi.mock('@monaco-editor/react', () => ({
+ loader: {
+ config: (config: unknown) => mocks.loaderConfig(config),
+ },
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({ theme: mocks.theme }),
+}))
+
+vi.mock('@/types/app', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ Theme: { ...actual.Theme, light: 'light', dark: 'dark' },
+ }
+})
+
+vi.mock('@/utils/client', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ get isClient() {
+ return mocks.isClient
+ },
+ }
+})
+
+vi.mock('../../../../utils/file-utils', () => ({
+ getFileLanguage: (name: string) => mocks.getFileLanguage(name),
+}))
+
+vi.mock('../../../../editor/code-file-editor', () => ({
+ default: (props: Record) => {
+ mocks.editorProps.push(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('../../../../editor/markdown-file-editor', () => ({
+ default: (props: Record) => {
+ mocks.markdownProps.push(props)
+ return {String(props.value)}
+ },
+}))
+
+describe('FileEditorRenderer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.theme = 'light'
+ mocks.isClient = true
+ mocks.editorProps.length = 0
+ mocks.markdownProps.length = 0
+ })
+
+ it('should render the markdown editor with the collaborative props intact', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('markdown-editor')).toHaveTextContent('# Skill')
+ expect(mocks.markdownProps[0]).toMatchObject({
+ instanceId: 'file-1',
+ autoFocus: true,
+ collaborationEnabled: true,
+ })
+ })
+
+ it('should render the code editor with the default theme before Monaco mounts', () => {
+ render(
+ ,
+ )
+
+ expect(mocks.editorProps[0]).toMatchObject({
+ fileId: 'file-2',
+ theme: 'default-theme',
+ value: 'const a = 1',
+ collaborationEnabled: false,
+ })
+ expect(mocks.getFileLanguage).toHaveBeenCalledWith('main.ts')
+ })
+
+ it('should switch the code editor theme after Monaco mounts in dark mode', () => {
+ mocks.theme = 'dark'
+
+ render(
+ ,
+ )
+
+ act(() => {
+ screen.getByRole('button', { name: 'mount-code-editor' }).click()
+ })
+
+ expect(mocks.monacoSetTheme).toHaveBeenCalledWith('vs-dark')
+ expect(mocks.editorProps.at(-1)).toMatchObject({
+ theme: 'vs-dark',
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/file-preview-renderer.spec.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/file-preview-renderer.spec.tsx
new file mode 100644
index 00000000000..4ca304324d8
--- /dev/null
+++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/file-preview-renderer.spec.tsx
@@ -0,0 +1,116 @@
+import { render, screen } from '@testing-library/react'
+import * as React from 'react'
+import Loading from '@/app/components/base/loading'
+import FilePreviewRenderer from '../file-preview-renderer'
+
+const mocks = vi.hoisted(() => ({
+ dynamicLoaders: [] as Array<() => React.ReactNode>,
+ dynamicImporters: [] as Array<() => Promise>,
+ dynamicComponents: [
+ ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+ ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+ ],
+ dynamicIndex: 0,
+}))
+
+vi.mock('@/next/dynamic', () => ({
+ default: (loader: () => Promise, options?: { loading?: () => React.ReactNode }) => {
+ mocks.dynamicImporters.push(loader)
+ if (options?.loading)
+ mocks.dynamicLoaders.push(options.loading)
+ return mocks.dynamicComponents[mocks.dynamicIndex++]
+ },
+}))
+
+vi.mock('../../../../viewer/media-file-preview', () => ({
+ default: ({ type, src }: { type: string, 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('../../../../viewer/sqlite-file-preview', () => ({
+ default: ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+}))
+
+vi.mock('../../../../viewer/pdf-file-preview', () => ({
+ default: ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+}))
+describe('FilePreviewRenderer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.dynamicLoaders.length = 0
+ mocks.dynamicImporters.length = 0
+ mocks.dynamicIndex = 0
+ })
+
+ it('should render media, sqlite, pdf, and unsupported previews for each preview state', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('media-preview')).toHaveTextContent('video:https://example.com/demo.mp4')
+
+ rerender(
+ ,
+ )
+ expect(screen.getByTestId('sqlite-preview')).toHaveTextContent('https://example.com/demo.db')
+
+ rerender(
+ ,
+ )
+ expect(screen.getByTestId('pdf-preview')).toHaveTextContent('https://example.com/demo.pdf')
+
+ rerender(
+ ,
+ )
+ expect(screen.getByTestId('unsupported-preview')).toHaveTextContent('archive.bin:42:https://example.com/archive.bin')
+ })
+
+ it('should expose loading placeholders and dynamic importers for async previews', async () => {
+ vi.resetModules()
+ mocks.dynamicLoaders.length = 0
+ mocks.dynamicImporters.length = 0
+ mocks.dynamicIndex = 0
+
+ const previewRendererModule = await import('../file-preview-renderer')
+ const FreshFilePreviewRenderer = previewRendererModule.default
+
+ render(
+ ,
+ )
+
+ expect(mocks.dynamicLoaders).toHaveLength(2)
+ expect(mocks.dynamicImporters).toHaveLength(2)
+
+ for (const renderLoader of mocks.dynamicLoaders) {
+ const { unmount } = render(<>{renderLoader()}>)
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ unmount()
+ }
+
+ for (const loadModule of mocks.dynamicImporters)
+ await expect(loadModule()).resolves.toBeTruthy()
+
+ expect(Loading).toBeDefined()
+ })
+})
diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx
index 7819042617f..438595b42c6 100644
--- a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx
@@ -384,6 +384,14 @@ vi.mock('../../../../viewer/media-file-preview', () => ({
),
}))
+vi.mock('../../../../viewer/sqlite-file-preview', () => ({
+ default: ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+}))
+
+vi.mock('../../../../viewer/pdf-file-preview', () => ({
+ default: ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+}))
+
vi.mock('../../../../viewer/unsupported-file-download', () => ({
default: ({ name, size, downloadUrl }: { name: string, size?: number, downloadUrl: string }) => (
{`${name}|${String(size)}|${downloadUrl}`}
@@ -786,6 +794,73 @@ describe('FileContentPanel', () => {
expect(mocks.useSkillFileData).toHaveBeenCalledWith('app-1', 'file-1', 'download')
})
+ it('should render video preview for video files', () => {
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: true,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isPreviewable: true,
+ }
+ mocks.fileData.downloadUrlData = { download_url: 'https://example.com/video.mp4' }
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'video.mp4', extension: 'mp4' })],
+ ])
+
+ render()
+
+ expect(screen.getByTestId('media-preview')).toHaveTextContent('video|https://example.com/video.mp4')
+ })
+
+ it('should render sqlite preview for database files', async () => {
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: true,
+ isEditable: false,
+ isPreviewable: true,
+ }
+ mocks.fileData.downloadUrlData = { download_url: 'https://example.com/demo.db' }
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'demo.db', extension: 'db' })],
+ ])
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sqlite-preview')).toHaveTextContent('https://example.com/demo.db')
+ })
+ })
+
+ it('should render pdf preview for pdf files', async () => {
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: true,
+ isSQLite: false,
+ isEditable: false,
+ isPreviewable: true,
+ }
+ mocks.fileData.downloadUrlData = { download_url: 'https://example.com/demo.pdf' }
+ mocks.nodeMapData = new Map([
+ ['file-1', createNode({ name: 'demo.pdf', extension: 'pdf' })],
+ ])
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('pdf-preview')).toHaveTextContent('https://example.com/demo.pdf')
+ })
+ })
+
it('should render unsupported download panel for non-previewable files', () => {
// Arrange
mocks.fileTypeInfo = {
diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/utils.spec.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/utils.spec.ts
new file mode 100644
index 00000000000..5eb508b8f0d
--- /dev/null
+++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/utils.spec.ts
@@ -0,0 +1,36 @@
+import { extractFileReferenceIds, parseSkillFileMetadata } from '../utils'
+
+describe('file-content-panel utils', () => {
+ describe('extractFileReferenceIds', () => {
+ it('should collect distinct file reference ids from rich text content', () => {
+ const content = [
+ '§[file].[app].[11111111-1111-4111-8111-111111111111]§',
+ 'plain text',
+ '§[file].[app].[22222222-2222-4222-8222-222222222222]§',
+ '§[file].[app].[11111111-1111-4111-8111-111111111111]§',
+ ].join('\n')
+
+ expect([...extractFileReferenceIds(content)]).toEqual([
+ '11111111-1111-4111-8111-111111111111',
+ '22222222-2222-4222-8222-222222222222',
+ ])
+ })
+ })
+
+ describe('parseSkillFileMetadata', () => {
+ it('should return an empty object for missing and invalid metadata inputs', () => {
+ expect(parseSkillFileMetadata(undefined)).toEqual({})
+ expect(parseSkillFileMetadata(null)).toEqual({})
+ expect(parseSkillFileMetadata(1)).toEqual({})
+ expect(parseSkillFileMetadata('not-json')).toEqual({})
+ expect(parseSkillFileMetadata('"text"')).toEqual({})
+ })
+
+ it('should parse object-shaped metadata from JSON strings and objects', () => {
+ expect(parseSkillFileMetadata('{"title":"Skill"}')).toEqual({ title: 'Skill' })
+
+ const metadata = { files: { 'guide.md': { id: 'file-1' } } }
+ expect(parseSkillFileMetadata(metadata)).toBe(metadata)
+ })
+ })
+})
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/__tests__/file-tab-item.spec.tsx
similarity index 98%
rename from web/app/components/workflow/skill/skill-body/tabs/file-tab-item.spec.tsx
rename to web/app/components/workflow/skill/skill-body/tabs/__tests__/file-tab-item.spec.tsx
index 46031033432..b60106f7fd4 100644
--- a/web/app/components/workflow/skill/skill-body/tabs/file-tab-item.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/tabs/__tests__/file-tab-item.spec.tsx
@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
-import FileTabItem from './file-tab-item'
+import FileTabItem from '.././file-tab-item'
type FileTabItemProps = ComponentProps
diff --git a/web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx b/web/app/components/workflow/skill/skill-body/tabs/__tests__/file-tabs.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx
rename to web/app/components/workflow/skill/skill-body/tabs/__tests__/file-tabs.spec.tsx
index bf0ce19fb82..2aed4d26d11 100644
--- a/web/app/components/workflow/skill/skill-body/tabs/file-tabs.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/tabs/__tests__/file-tabs.spec.tsx
@@ -1,7 +1,7 @@
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'
+import { makeArtifactTabId, START_TAB_ID } from '../../../constants'
+import FileTabs from '.././file-tabs'
type MockWorkflowState = {
openTabIds: string[]
@@ -42,7 +42,7 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
-vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }),
}))
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/__tests__/start-tab-item.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/skill-body/tabs/start-tab-item.spec.tsx
rename to web/app/components/workflow/skill/skill-body/tabs/__tests__/start-tab-item.spec.tsx
index 9bec26dc535..175d06c2515 100644
--- a/web/app/components/workflow/skill/skill-body/tabs/start-tab-item.spec.tsx
+++ b/web/app/components/workflow/skill/skill-body/tabs/__tests__/start-tab-item.spec.tsx
@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
-import StartTabItem from './start-tab-item'
+import StartTabItem from '.././start-tab-item'
type StartTabItemProps = ComponentProps
diff --git a/web/app/components/workflow/skill/skill-body/tabs/file-tabs.tsx b/web/app/components/workflow/skill/skill-body/tabs/file-tabs.tsx
index a39de627b3f..e5d3c20fb29 100644
--- a/web/app/components/workflow/skill/skill-body/tabs/file-tabs.tsx
+++ b/web/app/components/workflow/skill/skill-body/tabs/file-tabs.tsx
@@ -97,7 +97,7 @@ const FileTabs = () => {
{openTabIds.map((fileId) => {
const isArtifact = isArtifactTab(fileId)
const node = isArtifact ? undefined : nodeMap?.get(fileId)
- const artifactFileName = isArtifact ? getArtifactPath(fileId).split('/').pop() ?? fileId : undefined
+ const artifactFileName = isArtifact ? getArtifactPath(fileId).split('/').pop()! : undefined
const name = isArtifact ? artifactFileName! : (node?.name ?? fileId)
const extension = isArtifact ? getFileExtension(artifactFileName!) : node?.extension
const isActive = activeTabId === fileId
diff --git a/web/app/components/workflow/skill/start-tab/action-card.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/action-card.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/start-tab/action-card.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/action-card.spec.tsx
index 7bf532cfa03..a4442085f8f 100644
--- a/web/app/components/workflow/skill/start-tab/action-card.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/action-card.spec.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import ActionCard from './action-card'
+import ActionCard from '.././action-card'
describe('ActionCard', () => {
beforeEach(() => {
diff --git a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/create-blank-skill-modal.spec.tsx
similarity index 74%
rename from web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/create-blank-skill-modal.spec.tsx
index f8be2f44ec4..54233c915aa 100644
--- a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/create-blank-skill-modal.spec.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import CreateBlankSkillModal from './create-blank-skill-modal'
+import CreateBlankSkillModal from '.././create-blank-skill-modal'
const mocks = vi.hoisted(() => ({
appId: 'app-1',
@@ -13,17 +13,17 @@ const mocks = vi.hoisted(() => ({
existingNames: new Set(),
}))
-vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useExistingSkillNames: () => ({
data: mocks.existingNames,
}),
}))
-vi.mock('../utils/skill-upload-utils', () => ({
+vi.mock('../../utils/skill-upload-utils', () => ({
prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args),
}))
-vi.mock('./use-skill-batch-upload', () => ({
+vi.mock('.././use-skill-batch-upload', () => ({
useSkillBatchUpload: () => ({
appId: mocks.appId,
startUpload: mocks.startUpload,
@@ -147,5 +147,44 @@ describe('CreateBlankSkillModal', () => {
expect(mocks.uploadTree).toHaveBeenCalledTimes(1)
})
})
+
+ it('should trim the skill name before creating the upload payload', async () => {
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: ' new-skill ' } })
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
+
+ await waitFor(() => {
+ expect(mocks.prepareSkillUploadFile).toHaveBeenCalledTimes(1)
+ })
+
+ expect(mocks.uploadTree).toHaveBeenCalledWith(expect.objectContaining({
+ tree: [
+ expect.objectContaining({
+ name: 'new-skill',
+ children: [expect.objectContaining({ name: 'SKILL.md' })],
+ }),
+ ],
+ files: expect.any(Map),
+ }))
+ const uploadFiles = mocks.uploadTree.mock.calls[0][0].files as Map
+ expect([...uploadFiles.keys()]).toEqual(['new-skill/SKILL.md'])
+ })
+
+ it('should keep the modal locked while a skill is being created', async () => {
+ const onClose = vi.fn()
+ mocks.uploadTree.mockImplementationOnce(() => new Promise(() => {}))
+
+ render()
+
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-skill' } })
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/i }))
+
+ expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
+ expect(onClose).not.toHaveBeenCalled()
+ })
})
})
diff --git a/web/app/components/workflow/skill/start-tab/create-import-section.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/create-import-section.spec.tsx
similarity index 93%
rename from web/app/components/workflow/skill/start-tab/create-import-section.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/create-import-section.spec.tsx
index f75a9d6dc12..4ab02b3a963 100644
--- a/web/app/components/workflow/skill/start-tab/create-import-section.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/create-import-section.spec.tsx
@@ -1,7 +1,7 @@
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'
+import CreateImportSection from '.././create-import-section'
type MockWorkflowState = {
setUploadStatus: ReturnType
@@ -26,13 +26,13 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useExistingSkillNames: () => ({
data: mocks.existingNames,
}),
}))
-vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
diff --git a/web/app/components/workflow/skill/start-tab/file-explorer-intro.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/file-explorer-intro.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/start-tab/file-explorer-intro.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/file-explorer-intro.spec.tsx
index d6673c466f6..f1d65b2e1db 100644
--- a/web/app/components/workflow/skill/start-tab/file-explorer-intro.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/file-explorer-intro.spec.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
-import FileExplorerIntro from './file-explorer-intro'
+import FileExplorerIntro from '.././file-explorer-intro'
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, ns, components }: {
diff --git a/web/app/components/workflow/skill/start-tab/import-skill-modal.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/import-skill-modal.spec.tsx
similarity index 84%
rename from web/app/components/workflow/skill/start-tab/import-skill-modal.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/import-skill-modal.spec.tsx
index 49f2e0ad3c3..8a7ead292e5 100644
--- a/web/app/components/workflow/skill/start-tab/import-skill-modal.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/import-skill-modal.spec.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { ZipValidationError } from '../utils/zip-extract'
-import ImportSkillModal from './import-skill-modal'
+import { ZipValidationError } from '../../utils/zip-extract'
+import ImportSkillModal from '.././import-skill-modal'
const mocks = vi.hoisted(() => ({
appId: 'app-1',
@@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({
existingNames: new Set(),
}))
-vi.mock('../utils/zip-extract', () => {
+vi.mock('../../utils/zip-extract', () => {
class MockZipValidationError extends Error {
code: string
@@ -33,17 +33,17 @@ vi.mock('../utils/zip-extract', () => {
}
})
-vi.mock('../utils/zip-to-upload-tree', () => ({
+vi.mock('../../utils/zip-to-upload-tree', () => ({
buildUploadDataFromZip: (...args: unknown[]) => mocks.buildUploadDataFromZip(...args),
}))
-vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useExistingSkillNames: () => ({
data: mocks.existingNames,
}),
}))
-vi.mock('./use-skill-batch-upload', () => ({
+vi.mock('.././use-skill-batch-upload', () => ({
useSkillBatchUpload: () => ({
appId: mocks.appId,
startUpload: mocks.startUpload,
@@ -118,6 +118,17 @@ describe('ImportSkillModal', () => {
expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i })).not.toBeDisabled()
})
+ it('should format small files in bytes and large files in megabytes', () => {
+ const { rerender } = render()
+
+ selectFile(createZipFile('tiny.zip', 512))
+ expect(screen.getByText('512 B')).toBeInTheDocument()
+
+ rerender()
+ selectFile(createZipFile('large.zip', 2 * 1024 * 1024))
+ expect(screen.getByText('2.0 MB')).toBeInTheDocument()
+ })
+
it('should select a zip file when it is dropped on the drop zone', () => {
render()
@@ -275,5 +286,34 @@ describe('ImportSkillModal', () => {
expect(mocks.toastError).toHaveBeenCalledWith('workflow.skill.startTab.importModal.errorInvalidZip')
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
+
+ it('should keep the modal locked while an import is in progress', async () => {
+ const onClose = vi.fn()
+ let resolveArrayBuffer!: (value: ArrayBuffer) => void
+ const file = createZipFile('pending.zip')
+ Object.defineProperty(file, 'arrayBuffer', {
+ value: vi.fn().mockImplementation(() => new Promise((resolve) => {
+ resolveArrayBuffer = resolve
+ })),
+ configurable: true,
+ })
+
+ render()
+ selectFile(file)
+ fireEvent.click(screen.getByRole('button', { name: /workflow\.skill\.startTab\.importModal\.importButton/i }))
+
+ expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
+ expect(onClose).not.toHaveBeenCalled()
+
+ resolveArrayBuffer(new ArrayBuffer(8))
+ mocks.extractAndValidateZip.mockRejectedValueOnce(new Error('unknown'))
+
+ await waitFor(() => {
+ expect(mocks.failUpload).toHaveBeenCalledTimes(1)
+ })
+ })
})
})
diff --git a/web/app/components/workflow/skill/start-tab/index.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/index.spec.tsx
similarity index 92%
rename from web/app/components/workflow/skill/start-tab/index.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/index.spec.tsx
index c3769ab00a2..2af7702b576 100644
--- a/web/app/components/workflow/skill/start-tab/index.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/index.spec.tsx
@@ -1,7 +1,7 @@
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'
+import StartTabContent from '.././index'
type MockWorkflowState = {
setUploadStatus: ReturnType
@@ -26,13 +26,13 @@ vi.mock('@/service/use-app-asset', () => ({
}),
}))
-vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useExistingSkillNames: () => ({
data: mocks.existingNames,
}),
}))
-vi.mock('../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
diff --git a/web/app/components/workflow/skill/start-tab/section-header.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/section-header.spec.tsx
similarity index 96%
rename from web/app/components/workflow/skill/start-tab/section-header.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/section-header.spec.tsx
index ef811070c1b..df80f7530df 100644
--- a/web/app/components/workflow/skill/start-tab/section-header.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/section-header.spec.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
-import SectionHeader from './section-header'
+import SectionHeader from '.././section-header'
describe('SectionHeader', () => {
beforeEach(() => {
diff --git a/web/app/components/workflow/skill/start-tab/skill-templates-section.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/skill-templates-section.spec.tsx
similarity index 94%
rename from web/app/components/workflow/skill/start-tab/skill-templates-section.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/skill-templates-section.spec.tsx
index cfac1035828..45f16075a02 100644
--- a/web/app/components/workflow/skill/start-tab/skill-templates-section.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/skill-templates-section.spec.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import SkillTemplatesSection from './skill-templates-section'
+import SkillTemplatesSection from '.././skill-templates-section'
type TemplateEntry = {
id: string
@@ -19,21 +19,21 @@ const mocks = vi.hoisted(() => ({
existingNames: new Set(),
}))
-vi.mock('./templates/registry', () => ({
+vi.mock('.././templates/registry', () => ({
SKILL_TEMPLATES: mocks.templates,
}))
-vi.mock('./templates/template-to-upload', () => ({
+vi.mock('.././templates/template-to-upload', () => ({
buildUploadDataFromTemplate: (...args: unknown[]) => mocks.buildUploadDataFromTemplate(...args),
}))
-vi.mock('../hooks/file-tree/data/use-skill-asset-tree', () => ({
+vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useExistingSkillNames: () => ({
data: mocks.existingNames,
}),
}))
-vi.mock('./use-skill-batch-upload', () => ({
+vi.mock('.././use-skill-batch-upload', () => ({
useSkillBatchUpload: () => ({
appId: mocks.appId,
startUpload: mocks.startUpload,
diff --git a/web/app/components/workflow/skill/start-tab/template-card.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/template-card.spec.tsx
similarity index 95%
rename from web/app/components/workflow/skill/start-tab/template-card.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/template-card.spec.tsx
index db4e78797ab..64c62d459ee 100644
--- a/web/app/components/workflow/skill/start-tab/template-card.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/template-card.spec.tsx
@@ -1,6 +1,6 @@
-import type { SkillTemplateSummary } from './templates/types'
+import type { SkillTemplateSummary } from '.././templates/types'
import { fireEvent, render, screen } from '@testing-library/react'
-import TemplateCard from './template-card'
+import TemplateCard from '.././template-card'
const createTemplate = (overrides: Partial = {}): SkillTemplateSummary => ({
id: 'docx',
diff --git a/web/app/components/workflow/skill/start-tab/template-search.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/template-search.spec.tsx
similarity index 97%
rename from web/app/components/workflow/skill/start-tab/template-search.spec.tsx
rename to web/app/components/workflow/skill/start-tab/__tests__/template-search.spec.tsx
index f0cb3fb0a0d..5692122dc6f 100644
--- a/web/app/components/workflow/skill/start-tab/template-search.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/template-search.spec.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
-import TemplateSearch from './template-search'
+import TemplateSearch from '.././template-search'
describe('TemplateSearch', () => {
beforeEach(() => {
diff --git a/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx b/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx
index d1203b2aed4..30ed2155b3d 100644
--- a/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx
+++ b/web/app/components/workflow/skill/start-tab/__tests__/use-skill-batch-upload.spec.tsx
@@ -56,6 +56,16 @@ describe('useSkillBatchUpload', () => {
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 3, failed: 0 })
})
+ it('should clamp negative totals to zero when starting an upload', () => {
+ const { result } = renderHook(() => useSkillBatchUpload())
+
+ act(() => {
+ result.current.startUpload(-2)
+ })
+
+ expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 0, failed: 0 })
+ })
+
it('should update upload progress through the shared helper', () => {
const { result } = renderHook(() => useSkillBatchUpload())
@@ -65,6 +75,16 @@ describe('useSkillBatchUpload', () => {
expect(mocks.workflowState.setUploadProgress).toHaveBeenCalledWith({ uploaded: 2, total: 5, failed: 0 })
})
+
+ it('should mark the upload as failed through the shared helper', () => {
+ const { result } = renderHook(() => useSkillBatchUpload())
+
+ act(() => {
+ result.current.failUpload()
+ })
+
+ expect(mocks.workflowState.setUploadStatus).toHaveBeenCalledWith('partial_error')
+ })
})
describe('Tree Upload', () => {
@@ -142,5 +162,25 @@ describe('useSkillBatchUpload', () => {
expect(mocks.workflowState.openTab).toHaveBeenCalledWith('skill-md-id', { pinned: true })
expect(openedId).toBe('skill-md-id')
})
+
+ it('should return null when no skill document exists in the created nodes', () => {
+ const { result } = renderHook(() => useSkillBatchUpload())
+
+ let openedId: string | null = 'placeholder'
+ act(() => {
+ openedId = result.current.openCreatedSkillDocument([
+ {
+ id: 'folder-id',
+ name: 'alpha',
+ node_type: 'folder',
+ size: 0,
+ children: [],
+ },
+ ])
+ })
+
+ expect(mocks.workflowState.openTab).not.toHaveBeenCalled()
+ expect(openedId).toBeNull()
+ })
})
})
diff --git a/web/app/components/workflow/skill/start-tab/templates/registry.spec.ts b/web/app/components/workflow/skill/start-tab/templates/__tests__/registry.spec.ts
similarity index 93%
rename from web/app/components/workflow/skill/start-tab/templates/registry.spec.ts
rename to web/app/components/workflow/skill/start-tab/templates/__tests__/registry.spec.ts
index 0bc4605d5de..a1cfff65e28 100644
--- a/web/app/components/workflow/skill/start-tab/templates/registry.spec.ts
+++ b/web/app/components/workflow/skill/start-tab/templates/__tests__/registry.spec.ts
@@ -1,5 +1,5 @@
-import type { SkillTemplateNode } from './types'
-import { SKILL_TEMPLATES } from './registry'
+import type { SkillTemplateNode } from '.././types'
+import { SKILL_TEMPLATES } from '.././registry'
const countFiles = (nodes: SkillTemplateNode[]): number => {
return nodes.reduce((acc, node) => {
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/__tests__/template-to-upload.spec.ts
similarity index 92%
rename from web/app/components/workflow/skill/start-tab/templates/template-to-upload.spec.ts
rename to web/app/components/workflow/skill/start-tab/templates/__tests__/template-to-upload.spec.ts
index fd57d6d433c..4cc0e2c78f3 100644
--- a/web/app/components/workflow/skill/start-tab/templates/template-to-upload.spec.ts
+++ b/web/app/components/workflow/skill/start-tab/templates/__tests__/template-to-upload.spec.ts
@@ -1,11 +1,11 @@
-import type { SkillTemplateNode } from './types'
-import { buildUploadDataFromTemplate } from './template-to-upload'
+import type { SkillTemplateNode } from '.././types'
+import { buildUploadDataFromTemplate } from '.././template-to-upload'
const mocks = vi.hoisted(() => ({
prepareSkillUploadFile: vi.fn(),
}))
-vi.mock('../../utils/skill-upload-utils', () => ({
+vi.mock('../../../utils/skill-upload-utils', () => ({
prepareSkillUploadFile: (...args: unknown[]) => mocks.prepareSkillUploadFile(...args),
}))
diff --git a/web/app/components/workflow/skill/start-tab/templates/skills/__tests__/index.spec.ts b/web/app/components/workflow/skill/start-tab/templates/skills/__tests__/index.spec.ts
new file mode 100644
index 00000000000..1bb2d51efe8
--- /dev/null
+++ b/web/app/components/workflow/skill/start-tab/templates/skills/__tests__/index.spec.ts
@@ -0,0 +1,59 @@
+import algorithmicArt from '../algorithmic-art'
+import brandGuidelines from '../brand-guidelines'
+import canvasDesign from '../canvas-design'
+import claudeApi from '../claude-api'
+import docCoauthoring from '../doc-coauthoring'
+import docx from '../docx'
+import frontendDesign from '../frontend-design'
+import internalComms from '../internal-comms'
+import mcpBuilder from '../mcp-builder'
+import pdf from '../pdf'
+import pptx from '../pptx'
+import skillCreator from '../skill-creator'
+import slackGifCreator from '../slack-gif-creator'
+import themeFactory from '../theme-factory'
+import webArtifactsBuilder from '../web-artifacts-builder'
+import webappTesting from '../webapp-testing'
+import xlsx from '../xlsx'
+import { SKILL_TEMPLATES } from '../../registry'
+import type { SkillTemplateNode } from '../../types'
+
+const countFiles = (nodes: SkillTemplateNode[]): number => {
+ return nodes.reduce((count, node) => {
+ if (node.node_type === 'file')
+ return count + 1
+ return count + countFiles(node.children)
+ }, 0)
+}
+
+const templates = [
+ { id: 'algorithmic-art', nodes: algorithmicArt },
+ { id: 'brand-guidelines', nodes: brandGuidelines },
+ { id: 'canvas-design', nodes: canvasDesign },
+ { id: 'claude-api', nodes: claudeApi },
+ { id: 'doc-coauthoring', nodes: docCoauthoring },
+ { id: 'docx', nodes: docx },
+ { id: 'frontend-design', nodes: frontendDesign },
+ { id: 'internal-comms', nodes: internalComms },
+ { id: 'mcp-builder', nodes: mcpBuilder },
+ { id: 'pdf', nodes: pdf },
+ { id: 'pptx', nodes: pptx },
+ { id: 'skill-creator', nodes: skillCreator },
+ { id: 'slack-gif-creator', nodes: slackGifCreator },
+ { id: 'theme-factory', nodes: themeFactory },
+ { id: 'web-artifacts-builder', nodes: webArtifactsBuilder },
+ { id: 'webapp-testing', nodes: webappTesting },
+ { id: 'xlsx', nodes: xlsx },
+]
+
+describe('skill template files', () => {
+ it.each(templates)('should export a non-empty tree for %s', ({ id, nodes }) => {
+ expect(nodes.length).toBeGreaterThan(0)
+ expect(nodes.every(node => Boolean(node.name))).toBe(true)
+
+ const template = SKILL_TEMPLATES.find(item => item.id === id)
+
+ expect(template).toBeDefined()
+ expect(countFiles(nodes)).toBe(template?.fileCount)
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/__tests__/drag-utils.spec.ts b/web/app/components/workflow/skill/utils/__tests__/drag-utils.spec.ts
new file mode 100644
index 00000000000..bab2b261543
--- /dev/null
+++ b/web/app/components/workflow/skill/utils/__tests__/drag-utils.spec.ts
@@ -0,0 +1,41 @@
+import type * as React from 'react'
+import { INTERNAL_NODE_DRAG_TYPE } from '../../constants'
+import {
+ getDragActionType,
+ isDragEvent,
+ isFileDrag,
+ isNodeDrag,
+} from '../drag-utils'
+
+const createDragEvent = (types: string[]): React.DragEvent => ({
+ dataTransfer: {
+ types,
+ },
+} as unknown as React.DragEvent)
+
+describe('drag-utils', () => {
+ it('should detect external file drags', () => {
+ const event = createDragEvent(['Files'])
+
+ expect(isFileDrag(event)).toBe(true)
+ expect(isNodeDrag(event)).toBe(false)
+ expect(isDragEvent(event)).toBe(true)
+ expect(getDragActionType(event)).toBe('upload')
+ })
+
+ it('should detect internal node drags', () => {
+ const event = createDragEvent([INTERNAL_NODE_DRAG_TYPE])
+
+ expect(isFileDrag(event)).toBe(false)
+ expect(isNodeDrag(event)).toBe(true)
+ expect(isDragEvent(event)).toBe(true)
+ expect(getDragActionType(event)).toBe('move')
+ })
+
+ it('should reject unsupported drag payloads', () => {
+ const event = createDragEvent(['text/plain'])
+
+ expect(isDragEvent(event)).toBe(false)
+ expect(getDragActionType(event)).toBeNull()
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/__tests__/file-utils.spec.ts b/web/app/components/workflow/skill/utils/__tests__/file-utils.spec.ts
new file mode 100644
index 00000000000..b4683af7ac9
--- /dev/null
+++ b/web/app/components/workflow/skill/utils/__tests__/file-utils.spec.ts
@@ -0,0 +1,82 @@
+import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
+import {
+ getFileExtension,
+ getFileIconType,
+ getFileLanguage,
+ isBinaryFile,
+ isImageFile,
+ isMarkdownFile,
+ isPdfFile,
+ isSQLiteFile,
+ isTextLikeFile,
+ isVideoFile,
+} from '../file-utils'
+
+describe('file-utils', () => {
+ describe('getFileExtension', () => {
+ it('should prefer the explicit extension and normalize casing', () => {
+ expect(getFileExtension('note.txt', '.MD')).toBe('md')
+ })
+
+ it('should derive the extension from the file name', () => {
+ expect(getFileExtension('archive.TAR.GZ')).toBe('gz')
+ })
+
+ it('should return an empty string when no name is available', () => {
+ expect(getFileExtension()).toBe('')
+ })
+ })
+
+ describe('getFileIconType', () => {
+ it('should resolve media, document, and database icon types', () => {
+ expect(getFileIconType('cover.png')).toBe(FileAppearanceTypeEnum.image)
+ expect(getFileIconType('anim.gif')).toBe(FileAppearanceTypeEnum.image)
+ expect(getFileIconType('clip.mp4')).toBe(FileAppearanceTypeEnum.video)
+ expect(getFileIconType('podcast.mp3')).toBe(FileAppearanceTypeEnum.audio)
+ expect(getFileIconType('notes.md')).toBe(FileAppearanceTypeEnum.markdown)
+ expect(getFileIconType('report.docx')).toBe(FileAppearanceTypeEnum.word)
+ expect(getFileIconType('schema.ts')).toBe(FileAppearanceTypeEnum.code)
+ expect(getFileIconType('sheet.xlsx')).toBe(FileAppearanceTypeEnum.excel)
+ expect(getFileIconType('slides.pptx')).toBe(FileAppearanceTypeEnum.ppt)
+ expect(getFileIconType('data.sqlite')).toBe(FileAppearanceTypeEnum.database)
+ expect(getFileIconType('unknown.bin')).toBe(FileAppearanceTypeEnum.document)
+ })
+
+ it('should let the explicit extension override the file name', () => {
+ expect(getFileIconType('README', '.pdf')).toBe(FileAppearanceTypeEnum.pdf)
+ })
+ })
+
+ describe('type guards', () => {
+ it('should classify markdown, image, video, sqlite, pdf, and binary files', () => {
+ expect(isMarkdownFile('mdx')).toBe(true)
+ expect(isImageFile('svg')).toBe(true)
+ expect(isVideoFile('webm')).toBe(true)
+ expect(isSQLiteFile('sqlite3')).toBe(true)
+ expect(isPdfFile('pdf')).toBe(true)
+ expect(isBinaryFile('docx')).toBe(true)
+ })
+
+ it('should detect text-like files by excluding binary and preview-only formats', () => {
+ expect(isTextLikeFile('ts')).toBe(true)
+ expect(isTextLikeFile('svg')).toBe(false)
+ expect(isTextLikeFile('mp4')).toBe(false)
+ expect(isTextLikeFile('pdf')).toBe(false)
+ expect(isTextLikeFile('zip')).toBe(false)
+ })
+ })
+
+ describe('getFileLanguage', () => {
+ it('should map supported extensions to editor languages', () => {
+ expect(getFileLanguage('index.tsx')).toBe('typescript')
+ expect(getFileLanguage('config.yml')).toBe('yaml')
+ expect(getFileLanguage('script.sh')).toBe('shell')
+ expect(getFileLanguage('template.html')).toBe('html')
+ expect(getFileLanguage('query.sql')).toBe('sql')
+ })
+
+ it('should fall back to plaintext for unknown extensions', () => {
+ expect(getFileLanguage('LICENSE')).toBe('plaintext')
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/__tests__/skill-upload-utils.spec.ts b/web/app/components/workflow/skill/utils/__tests__/skill-upload-utils.spec.ts
new file mode 100644
index 00000000000..eda97ce54b9
--- /dev/null
+++ b/web/app/components/workflow/skill/utils/__tests__/skill-upload-utils.spec.ts
@@ -0,0 +1,37 @@
+import { prepareSkillUploadFile } from '../skill-upload-utils'
+
+describe('skill-upload-utils', () => {
+ it('should wrap markdown files into the skill upload payload', async () => {
+ const file = new File(['# Skill body'], 'SKILL.md', { type: 'text/markdown' })
+
+ const prepared = await prepareSkillUploadFile(file)
+
+ expect(prepared).not.toBe(file)
+ expect(prepared.name).toBe('SKILL.md')
+ expect(prepared.type).toBe('text/markdown')
+ await expect(prepared.text()).resolves.toBe(JSON.stringify({
+ content: '# Skill body',
+ metadata: {},
+ }))
+ })
+
+ it('should keep non-markdown files untouched', async () => {
+ const file = new File(['binary'], 'archive.zip', { type: 'application/zip' })
+
+ const prepared = await prepareSkillUploadFile(file)
+
+ expect(prepared).toBe(file)
+ })
+
+ it('should fallback to text/plain when the source markdown file has no explicit type', async () => {
+ const file = new File(['# Skill body'], 'SKILL.md')
+
+ const prepared = await prepareSkillUploadFile(file)
+
+ expect(prepared.type).toBe('text/plain')
+ await expect(prepared.text()).resolves.toBe(JSON.stringify({
+ content: '# Skill body',
+ metadata: {},
+ }))
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/__tests__/tree-utils.spec.ts b/web/app/components/workflow/skill/utils/__tests__/tree-utils.spec.ts
new file mode 100644
index 00000000000..951687114ea
--- /dev/null
+++ b/web/app/components/workflow/skill/utils/__tests__/tree-utils.spec.ts
@@ -0,0 +1,166 @@
+import type { AppAssetTreeView } from '@/types/app-asset'
+import { ROOT_ID } from '../../constants'
+import {
+ buildNodeMap,
+ createDraftTreeNode,
+ findNodeById,
+ flattenMatchingNodes,
+ getAllDescendantFileIds,
+ getAncestorIds,
+ getTargetFolderIdFromSelection,
+ insertDraftTreeNode,
+ isDescendantOf,
+ isRootId,
+ toApiParentId,
+ toOpensObject,
+} from '../tree-utils'
+
+const tree: AppAssetTreeView[] = [
+ {
+ id: 'folder-a',
+ node_type: 'folder',
+ name: 'folder-a',
+ path: '/folder-a',
+ extension: '',
+ size: 0,
+ children: [
+ {
+ id: 'file-1',
+ node_type: 'file',
+ name: 'guide.md',
+ path: '/folder-a/guide.md',
+ extension: 'md',
+ size: 10,
+ children: [],
+ },
+ {
+ id: 'folder-b',
+ node_type: 'folder',
+ name: 'folder-b',
+ path: '/folder-a/folder-b',
+ extension: '',
+ size: 0,
+ children: [
+ {
+ id: 'file-2',
+ node_type: 'file',
+ name: 'data.json',
+ path: '/folder-a/folder-b/data.json',
+ extension: 'json',
+ size: 20,
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'empty-folder',
+ node_type: 'folder',
+ name: 'empty-folder',
+ path: '/empty-folder',
+ extension: '',
+ size: 0,
+ children: [],
+ },
+]
+
+describe('tree-utils', () => {
+ describe('root helpers', () => {
+ it('should normalize root identifiers for API calls', () => {
+ expect(isRootId(undefined)).toBe(true)
+ expect(isRootId(ROOT_ID)).toBe(true)
+ expect(isRootId('folder-a')).toBe(false)
+ expect(toApiParentId(ROOT_ID)).toBeNull()
+ expect(toApiParentId('folder-a')).toBe('folder-a')
+ })
+
+ it('should convert expanded ids into the opens object expected by the tree', () => {
+ expect(toOpensObject(new Set(['folder-a', 'folder-b']))).toEqual({
+ 'folder-a': true,
+ 'folder-b': true,
+ })
+ })
+ })
+
+ describe('tree traversal', () => {
+ it('should build node maps, find nodes, and collect ancestors', () => {
+ const nodeMap = buildNodeMap(tree)
+
+ expect(nodeMap.get('file-2')?.name).toBe('data.json')
+ expect(findNodeById(tree, 'folder-b')?.name).toBe('folder-b')
+ expect(findNodeById(tree, 'missing')).toBeNull()
+ expect(getAncestorIds('file-2', tree)).toEqual(['folder-a', 'folder-b'])
+ expect(getAncestorIds('missing', tree)).toEqual([])
+ })
+
+ it('should collect descendant file ids for folders and single files', () => {
+ expect(getAllDescendantFileIds('folder-a', tree)).toEqual(['file-1', 'file-2'])
+ expect(getAllDescendantFileIds('file-1', tree)).toEqual(['file-1'])
+ expect(getAllDescendantFileIds('missing', tree)).toEqual([])
+ })
+
+ it('should resolve descendant relationships and target folders from selections', () => {
+ expect(isDescendantOf('file-2', 'folder-a', tree)).toBe(true)
+ expect(isDescendantOf('folder-a', 'folder-a', tree)).toBe(true)
+ expect(isDescendantOf('file-1', 'empty-folder', tree)).toBe(false)
+ expect(isDescendantOf(null, 'folder-a', tree)).toBe(false)
+
+ expect(getTargetFolderIdFromSelection(null, tree)).toBe(ROOT_ID)
+ expect(getTargetFolderIdFromSelection('folder-a', tree)).toBe('folder-a')
+ expect(getTargetFolderIdFromSelection('file-2', tree)).toBe('folder-b')
+ expect(getTargetFolderIdFromSelection('missing', tree)).toBe(ROOT_ID)
+ })
+ })
+
+ describe('draft helpers', () => {
+ it('should create and insert draft nodes at root or under a matching folder', () => {
+ const draftFile = createDraftTreeNode({ id: 'draft-file', nodeType: 'file' })
+ const draftFolder = createDraftTreeNode({ id: 'draft-folder', nodeType: 'folder' })
+
+ expect(draftFile).toEqual({
+ id: 'draft-file',
+ node_type: 'file',
+ name: '',
+ path: '',
+ extension: '',
+ size: 0,
+ children: [],
+ })
+
+ expect(insertDraftTreeNode(tree, null, draftFile)[0].id).toBe('draft-file')
+
+ const inserted = insertDraftTreeNode(tree, 'folder-a', draftFolder)
+ expect(inserted[0].children[0].id).toBe('draft-folder')
+
+ const fallbackToRoot = insertDraftTreeNode(tree, 'missing', draftFile)
+ expect(fallbackToRoot[0].id).toBe('draft-file')
+ })
+ })
+
+ describe('search flattening', () => {
+ it('should flatten matching nodes and include their parent paths', () => {
+ expect(flattenMatchingNodes(tree, '')).toEqual([])
+ expect(flattenMatchingNodes(tree, 'guide')).toEqual([
+ {
+ node: tree[0].children[0],
+ parentPath: 'folder-a',
+ },
+ ])
+ expect(flattenMatchingNodes(tree, 'folder')).toEqual([
+ {
+ node: tree[0],
+ parentPath: '',
+ },
+ {
+ node: tree[0].children[1],
+ parentPath: 'folder-a',
+ },
+ {
+ node: tree[1],
+ parentPath: '',
+ },
+ ])
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/__tests__/zip-extract.spec.ts b/web/app/components/workflow/skill/utils/__tests__/zip-extract.spec.ts
new file mode 100644
index 00000000000..ed2a049a450
--- /dev/null
+++ b/web/app/components/workflow/skill/utils/__tests__/zip-extract.spec.ts
@@ -0,0 +1,213 @@
+import type { ZipValidationError } from '../zip-extract'
+import { extractAndValidateZip } from '../zip-extract'
+
+type MockZipEntry = {
+ name: string
+ originalSize: number
+}
+
+type MockUnzipScenario = {
+ entries?: MockZipEntry[]
+ result?: Record
+ error?: Error
+ errorAfterFilter?: Error
+}
+
+const mocks = vi.hoisted(() => ({
+ scenario: null as MockUnzipScenario | null,
+}))
+
+const expectZipValidationError = async (promise: Promise, code: ZipValidationError['code']) => {
+ await expect(promise).rejects.toMatchObject({
+ code,
+ } satisfies Partial)
+}
+
+vi.mock('fflate', () => ({
+ unzip: (
+ _data: Uint8Array,
+ options: { filter: (file: MockZipEntry) => boolean },
+ callback: (error: Error | null, result: Record) => void,
+ ) => {
+ const scenario = mocks.scenario
+
+ if (!scenario) {
+ callback(new Error('missing scenario'), {})
+ return
+ }
+
+ if (scenario.error) {
+ callback(scenario.error, {})
+ return
+ }
+
+ const filteredResult = Object.fromEntries(
+ (scenario.entries ?? [])
+ .filter(entry => options.filter(entry))
+ .map((entry) => {
+ const data = scenario.result?.[entry.name] ?? new Uint8Array(entry.originalSize)
+ return [entry.name, data]
+ }),
+ )
+
+ if (scenario.errorAfterFilter) {
+ callback(scenario.errorAfterFilter, {})
+ return
+ }
+
+ callback(null, filteredResult)
+ },
+}))
+
+describe('zip-extract', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.scenario = null
+ })
+
+ it('should extract a zip with one root folder and ignore system files', async () => {
+ mocks.scenario = {
+ entries: [
+ { name: 'demo-skill/SKILL.md', originalSize: 7 },
+ { name: 'demo-skill/assets/logo.txt', originalSize: 4 },
+ { name: '__MACOSX/demo-skill/.DS_Store', originalSize: 1 },
+ { name: 'demo-skill/.DS_Store', originalSize: 1 },
+ ],
+ result: {
+ 'demo-skill/SKILL.md': new TextEncoder().encode('# Skill'),
+ 'demo-skill/assets/logo.txt': new TextEncoder().encode('logo'),
+ },
+ }
+
+ const result = await extractAndValidateZip(new ArrayBuffer(8))
+
+ expect(result.rootFolderName).toBe('demo-skill')
+ expect([...result.files.keys()]).toEqual([
+ 'demo-skill/SKILL.md',
+ 'demo-skill/assets/logo.txt',
+ ])
+ })
+
+ it('should reject archives without files', async () => {
+ mocks.scenario = {
+ entries: [],
+ result: {},
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'empty_zip')
+ })
+
+ it('should reject archives with multiple root folders', async () => {
+ mocks.scenario = {
+ entries: [
+ { name: 'demo-a/SKILL.md', originalSize: 3 },
+ { name: 'demo-b/SKILL.md', originalSize: 3 },
+ ],
+ result: {
+ 'demo-a/SKILL.md': new TextEncoder().encode('# A'),
+ 'demo-b/SKILL.md': new TextEncoder().encode('# B'),
+ },
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'no_root_folder')
+ })
+
+ it('should reject archives with unsafe paths', async () => {
+ mocks.scenario = {
+ entries: [{ name: 'demo-skill/../evil.txt', originalSize: 4 }],
+ result: {},
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'path_traversal')
+ })
+
+ it('should reject archives with too many files', async () => {
+ mocks.scenario = {
+ entries: Array.from({ length: 201 }, (_, index) => ({
+ name: `demo-skill/file-${index}.txt`,
+ originalSize: 1,
+ })),
+ result: {},
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'too_many_files')
+ })
+
+ it('should reject zip files that exceed the compressed size limit before extraction', async () => {
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(51 * 1024 * 1024)), 'zip_too_large')
+ })
+
+ it('should ignore directory entries while extracting', async () => {
+ mocks.scenario = {
+ entries: [
+ { name: 'demo-skill/', originalSize: 0 },
+ { name: 'demo-skill/SKILL.md', originalSize: 7 },
+ ],
+ result: {
+ 'demo-skill/SKILL.md': new TextEncoder().encode('# Skill'),
+ },
+ }
+
+ const result = await extractAndValidateZip(new ArrayBuffer(8))
+ expect([...result.files.keys()]).toEqual(['demo-skill/SKILL.md'])
+ })
+
+ it('should reject archives when filtered extracted size exceeds the estimate limit', async () => {
+ mocks.scenario = {
+ entries: [
+ { name: 'demo-skill/huge.bin', originalSize: 201 * 1024 * 1024 },
+ ],
+ result: {},
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'extracted_too_large')
+ })
+
+ it('should reject archives when extracted file data exceeds the actual size limit', async () => {
+ const huge = new Uint8Array(201 * 1024 * 1024)
+ mocks.scenario = {
+ entries: [
+ { name: 'demo-skill/huge.bin', originalSize: 1 },
+ ],
+ result: {
+ 'demo-skill/huge.bin': huge,
+ },
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'extracted_too_large')
+ })
+
+ it('should reject invalid zip data', async () => {
+ mocks.scenario = {
+ error: new Error('bad zip'),
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'invalid_zip')
+ })
+
+ it('should preserve the original filter validation error when decompression fails afterward', async () => {
+ mocks.scenario = {
+ entries: [{ name: 'demo-skill/../evil.txt', originalSize: 4 }],
+ errorAfterFilter: new Error('bad zip'),
+ }
+
+ await expectZipValidationError(extractAndValidateZip(new ArrayBuffer(8)), 'path_traversal')
+ })
+
+ it('should ignore common Windows system files while extracting', async () => {
+ mocks.scenario = {
+ entries: [
+ { name: 'demo-skill/Thumbs.db', originalSize: 1 },
+ { name: 'demo-skill/desktop.ini', originalSize: 1 },
+ { name: 'demo-skill/SKILL.md', originalSize: 7 },
+ ],
+ result: {
+ 'demo-skill/SKILL.md': new TextEncoder().encode('# Skill'),
+ },
+ }
+
+ const result = await extractAndValidateZip(new ArrayBuffer(8))
+
+ expect([...result.files.keys()]).toEqual(['demo-skill/SKILL.md'])
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/__tests__/zip-to-upload-tree.spec.ts b/web/app/components/workflow/skill/utils/__tests__/zip-to-upload-tree.spec.ts
new file mode 100644
index 00000000000..a83e40a4b4b
--- /dev/null
+++ b/web/app/components/workflow/skill/utils/__tests__/zip-to-upload-tree.spec.ts
@@ -0,0 +1,60 @@
+import { buildUploadDataFromZip } from '../zip-to-upload-tree'
+
+const mocks = vi.hoisted(() => ({
+ prepareSkillUploadFile: vi.fn<(file: File) => Promise>(),
+}))
+
+vi.mock('../skill-upload-utils', () => ({
+ prepareSkillUploadFile: (file: File) => mocks.prepareSkillUploadFile(file),
+}))
+
+describe('zip-to-upload-tree', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.prepareSkillUploadFile.mockImplementation(async file => file)
+ })
+
+ it('should convert extracted zip files into upload tree data', async () => {
+ const extracted = {
+ rootFolderName: 'demo-skill',
+ files: new Map([
+ ['demo-skill/SKILL.md', new Uint8Array([35, 32, 83, 107, 105, 108, 108])],
+ ['demo-skill/assets/logo.png', new Uint8Array([1, 2, 3])],
+ ]),
+ }
+
+ const result = await buildUploadDataFromZip(extracted)
+
+ expect([...result.files.keys()]).toEqual([
+ 'demo-skill/SKILL.md',
+ 'demo-skill/assets/logo.png',
+ ])
+ expect(result.tree).toEqual([
+ {
+ name: 'demo-skill',
+ node_type: 'folder',
+ children: [
+ {
+ name: 'SKILL.md',
+ node_type: 'file',
+ size: result.files.get('demo-skill/SKILL.md')?.size ?? 0,
+ },
+ {
+ name: 'assets',
+ node_type: 'folder',
+ children: [
+ {
+ name: 'logo.png',
+ node_type: 'file',
+ size: result.files.get('demo-skill/assets/logo.png')?.size ?? 0,
+ },
+ ],
+ },
+ ],
+ },
+ ])
+ expect(mocks.prepareSkillUploadFile).toHaveBeenCalledTimes(2)
+ expect(result.files.get('demo-skill/SKILL.md')?.type).toBe('text/markdown')
+ expect(result.files.get('demo-skill/assets/logo.png')?.type).toBe('application/octet-stream')
+ })
+})
diff --git a/web/app/components/workflow/skill/utils/file-utils.ts b/web/app/components/workflow/skill/utils/file-utils.ts
index 12c2f8dad29..d8c11fa47d5 100644
--- a/web/app/components/workflow/skill/utils/file-utils.ts
+++ b/web/app/components/workflow/skill/utils/file-utils.ts
@@ -83,7 +83,8 @@ export function getFileExtension(name?: string, extension?: string): string {
return extension.replace(/^\./, '').toLowerCase()
if (!name)
return ''
- return name.split('.').pop()?.toLowerCase() ?? ''
+ const parts = name.split('.')
+ return parts[parts.length - 1].toLowerCase()
}
const AUDIO_EXTENSIONS = ['mp3', 'm4a', 'wav', 'amr', 'mpga', 'ogg', 'flac', 'aac', 'wma', 'aiff', 'opus']
@@ -110,7 +111,7 @@ const EXTENSION_TO_ICON_TYPE = new Map(
)
export function getFileIconType(name: string, ext?: string | null): FileAppearanceTypeEnum {
- const extension = ext?.replace(/^\./, '').toLowerCase() ?? name.split('.').pop()?.toLowerCase() ?? ''
+ const extension = getFileExtension(name, ext ?? undefined)
return EXTENSION_TO_ICON_TYPE.get(extension) ?? FileAppearanceTypeEnum.document
}
@@ -143,7 +144,7 @@ export function isPdfFile(extension: string): boolean {
}
export function getFileLanguage(name: string): string {
- const extension = name.split('.').pop()?.toLowerCase() ?? ''
+ const extension = getFileExtension(name)
const languageMap: Record = {
md: 'markdown',
diff --git a/web/app/components/workflow/skill/viewer/__tests__/media-file-preview.spec.tsx b/web/app/components/workflow/skill/viewer/__tests__/media-file-preview.spec.tsx
new file mode 100644
index 00000000000..45ae57437db
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/__tests__/media-file-preview.spec.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from '@testing-library/react'
+import MediaFilePreview from '../media-file-preview'
+
+describe('MediaFilePreview', () => {
+ it('should render an unavailable state when the source is missing', () => {
+ render()
+
+ expect(screen.getByText('workflow.skillEditor.previewUnavailable')).toBeInTheDocument()
+ })
+
+ it('should render an image preview when the type is image', () => {
+ const { container } = render()
+
+ expect(container.querySelector('img')).toHaveAttribute('src', 'https://example.com/image.png')
+ })
+
+ it('should render a video preview when the type is video', () => {
+ const { container } = render()
+
+ expect(container.querySelector('video')).toHaveAttribute('src', 'https://example.com/video.mp4')
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/__tests__/pdf-file-preview.spec.tsx b/web/app/components/workflow/skill/viewer/__tests__/pdf-file-preview.spec.tsx
new file mode 100644
index 00000000000..dd7757af14b
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/__tests__/pdf-file-preview.spec.tsx
@@ -0,0 +1,82 @@
+import type { ReactNode } from 'react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+
+import PdfFilePreview from '../pdf-file-preview'
+
+const mocks = vi.hoisted(() => ({
+ hotkeys: new Map void>(),
+ highlighterProps: null as null | Record,
+}))
+
+vi.mock('react-hotkeys-hook', () => ({
+ useHotkeys: (keys: string, callback: () => void) => {
+ mocks.hotkeys.set(keys, callback)
+ },
+}))
+
+vi.mock('react-pdf-highlighter', () => ({
+ PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => (
+
+ {beforeLoad}
+ {children({ numPages: 1 })}
+
+ ),
+ PdfHighlighter: (props: Record) => {
+ mocks.highlighterProps = props
+ return
+ },
+}))
+
+const getScaleContainer = () => {
+ const container = document.querySelector('div[style*="transform"]') as HTMLDivElement | null
+ expect(container).toBeInTheDocument()
+ return container!
+}
+
+describe('PdfFilePreview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.hotkeys.clear()
+ mocks.highlighterProps = null
+ })
+
+ it('should render the pdf viewer and loading state', () => {
+ render()
+
+ expect(screen.getByTestId('pdf-loader')).toBeInTheDocument()
+ expect(screen.getByTestId('pdf-highlighter')).toBeInTheDocument()
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should zoom with toolbar controls and hotkeys', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1)')
+
+ act(() => {
+ mocks.hotkeys.get('up')?.()
+ })
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
+
+ act(() => {
+ mocks.hotkeys.get('down')?.()
+ })
+ expect(getScaleContainer().getAttribute('style')).toContain('scale(1)')
+ })
+
+ it('should provide the non-interactive highlighter callbacks expected by the viewer', () => {
+ render()
+
+ expect(mocks.highlighterProps).toBeTruthy()
+ expect((mocks.highlighterProps?.enableAreaSelection as () => boolean)()).toBe(false)
+ expect((mocks.highlighterProps?.onSelectionFinished as () => null)()).toBeNull()
+
+ const highlight = (mocks.highlighterProps?.highlightTransform as () => ReactNode)()
+ const { container } = render(<>{highlight}>)
+ expect(container.firstChild?.nodeName).toBe('DIV')
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/__tests__/read-only-code-preview.spec.tsx b/web/app/components/workflow/skill/viewer/__tests__/read-only-code-preview.spec.tsx
new file mode 100644
index 00000000000..4d9580b548d
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/__tests__/read-only-code-preview.spec.tsx
@@ -0,0 +1,128 @@
+import { act, render, screen } from '@testing-library/react'
+import ReadOnlyCodePreview from '../read-only-code-preview'
+
+const mocks = vi.hoisted(() => ({
+ theme: 'light',
+ loaderConfig: vi.fn(),
+ editorProps: [] as Array>,
+ monacoSetTheme: vi.fn(),
+}))
+
+vi.mock('@monaco-editor/react', () => ({
+ loader: {
+ config: (config: unknown) => mocks.loaderConfig(config),
+ },
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({ theme: mocks.theme }),
+}))
+
+vi.mock('@/types/app', () => ({
+ Theme: { light: 'light', dark: 'dark' },
+ AgentStrategy: { functionCall: 'functionCall' },
+}))
+
+vi.mock('../../editor/code-file-editor', () => ({
+ default: (props: Record) => {
+ mocks.editorProps.push(props)
+ return (
+
+ )
+ },
+}))
+describe('ReadOnlyCodePreview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.theme = 'light'
+ mocks.editorProps.length = 0
+ })
+
+ it('should render with the default theme until Monaco mounts', () => {
+ render()
+
+ expect(mocks.editorProps[0]).toMatchObject({
+ value: 'const a = 1',
+ language: 'typescript',
+ theme: 'default-theme',
+ readOnly: true,
+ })
+ })
+
+ it('should resolve the light Monaco theme when mounted in light mode', () => {
+ render()
+
+ act(() => {
+ screen.getByRole('button', { name: 'mount-read-only-editor' }).click()
+ })
+
+ expect(mocks.monacoSetTheme).toHaveBeenCalledWith('light')
+ expect(mocks.editorProps.at(-1)).toMatchObject({
+ theme: 'light',
+ })
+ })
+
+ it('should switch to the resolved Monaco theme after mount', () => {
+ mocks.theme = 'dark'
+
+ render()
+
+ act(() => {
+ screen.getByRole('button', { name: 'mount-read-only-editor' }).click()
+ })
+
+ expect(mocks.monacoSetTheme).toHaveBeenCalledWith('vs-dark')
+ expect(mocks.editorProps.at(-1)).toMatchObject({
+ theme: 'vs-dark',
+ })
+ })
+
+ it('should expose a stable no-op change handler', () => {
+ render()
+
+ expect(() => (mocks.editorProps[0].onChange as () => void)()).not.toThrow()
+ })
+
+ it('should skip Monaco path configuration when window is unavailable at import time', async () => {
+ const originalWindow = globalThis.window
+
+ vi.resetModules()
+ mocks.loaderConfig.mockClear()
+ vi.stubGlobal('window', undefined)
+
+ const readOnlyCodePreviewModule = await import('../read-only-code-preview')
+ const FreshReadOnlyCodePreview = readOnlyCodePreviewModule.default
+
+ vi.stubGlobal('window', originalWindow)
+ mocks.editorProps.length = 0
+
+ render()
+
+ expect(mocks.loaderConfig).not.toHaveBeenCalled()
+ })
+
+ it('should configure Monaco asset paths when window is available at import time', async () => {
+ const originalWindow = globalThis.window
+
+ vi.resetModules()
+ mocks.loaderConfig.mockClear()
+ vi.stubGlobal('window', {
+ location: { origin: 'https://example.com' },
+ })
+
+ await import('../read-only-code-preview')
+
+ expect(mocks.loaderConfig).toHaveBeenCalledWith(expect.objectContaining({
+ paths: expect.objectContaining({
+ vs: expect.stringContaining('https://example.com'),
+ }),
+ }))
+
+ vi.stubGlobal('window', originalWindow)
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/__tests__/read-only-file-preview.spec.tsx b/web/app/components/workflow/skill/viewer/__tests__/read-only-file-preview.spec.tsx
new file mode 100644
index 00000000000..088ee980946
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/__tests__/read-only-file-preview.spec.tsx
@@ -0,0 +1,235 @@
+import { render, screen } from '@testing-library/react'
+import Loading from '@/app/components/base/loading'
+import ReadOnlyFilePreview from '../read-only-file-preview'
+
+const mocks = vi.hoisted(() => ({
+ fileTypeInfo: {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isMediaFile: false,
+ isPreviewable: false,
+ },
+ fetchState: {
+ data: undefined as string | undefined,
+ isLoading: false,
+ },
+ getFileLanguage: vi.fn((_: string) => 'typescript'),
+ dynamicLoaders: [] as Array<() => React.ReactNode>,
+ dynamicImporters: [] as Array<() => Promise>,
+ fetchArgs: [] as Array,
+ dynamicComponents: [
+ ({ value, language }: { value: string, language: string }) => {`${language}:${value}`}
,
+ ({ value }: { value: string }) => {value}
,
+ ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+ ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+ ],
+ dynamicIndex: 0,
+}))
+
+vi.mock('@/next/dynamic', () => ({
+ default: (loader: () => Promise, options?: { loading?: () => React.ReactNode }) => {
+ mocks.dynamicImporters.push(loader)
+ if (options?.loading)
+ mocks.dynamicLoaders.push(options.loading)
+ return mocks.dynamicComponents[mocks.dynamicIndex++]
+ },
+}))
+
+vi.mock('../media-file-preview', () => ({
+ default: ({ type, src }: { type: string, src: string }) => {`${type}:${src}`}
,
+}))
+
+vi.mock('../read-only-code-preview', () => ({
+ default: ({ value, language }: { value: string, language: string }) => {`${language}:${value}`}
,
+}))
+
+vi.mock('../read-only-markdown-preview', () => ({
+ default: ({ value }: { value: string }) => {value}
,
+}))
+
+vi.mock('../sqlite-file-preview', () => ({
+ default: ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+}))
+
+vi.mock('../pdf-file-preview', () => ({
+ default: ({ downloadUrl }: { downloadUrl: string }) => {downloadUrl}
,
+}))
+
+vi.mock('../unsupported-file-download', () => ({
+ default: ({ name }: { name: string }) => {name}
,
+}))
+
+vi.mock('../../hooks/use-file-type-info', () => ({
+ useFileTypeInfo: () => mocks.fileTypeInfo,
+}))
+
+vi.mock('../../hooks/use-fetch-text-content', () => ({
+ useFetchTextContent: (downloadUrl?: string) => {
+ mocks.fetchArgs.push(downloadUrl)
+ return mocks.fetchState
+ },
+}))
+
+vi.mock('../../utils/file-utils', () => ({
+ getFileLanguage: (name: string) => mocks.getFileLanguage(name),
+}))
+describe('ReadOnlyFilePreview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.dynamicIndex = 0
+ mocks.fileTypeInfo = {
+ isMarkdown: false,
+ isCodeOrText: false,
+ isImage: false,
+ isVideo: false,
+ isPdf: false,
+ isSQLite: false,
+ isEditable: false,
+ isMediaFile: false,
+ isPreviewable: false,
+ }
+ mocks.fetchState = {
+ data: undefined,
+ isLoading: false,
+ }
+ mocks.dynamicLoaders.length = 0
+ mocks.dynamicImporters.length = 0
+ mocks.fetchArgs.length = 0
+ })
+
+ it('should render the unsupported download state for non-previewable files', () => {
+ render()
+
+ expect(screen.getByTestId('unsupported-preview')).toHaveTextContent('file.bin')
+ })
+
+ it('should show a loading state while text content is being fetched', () => {
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isCodeOrText: true,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ mocks.fetchState = {
+ data: undefined,
+ isLoading: true,
+ }
+
+ render()
+
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render markdown, code, media, sqlite, and pdf previews for supported files', () => {
+ const { rerender } = render()
+
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isMarkdown: true,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ mocks.fetchState = {
+ data: '# Skill',
+ isLoading: false,
+ }
+ rerender()
+ expect(screen.getByTestId('markdown-preview')).toHaveTextContent('# Skill')
+ expect(mocks.fetchArgs.at(-1)).toBe('https://example.com/guide.md')
+
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isMarkdown: false,
+ isCodeOrText: true,
+ isEditable: true,
+ isPreviewable: true,
+ }
+ mocks.fetchState = {
+ data: 'const a = 1',
+ isLoading: false,
+ }
+ rerender()
+ expect(screen.getByTestId('code-preview')).toHaveTextContent('typescript:const a = 1')
+ expect(mocks.getFileLanguage).toHaveBeenCalledWith('file.ts')
+ expect(mocks.fetchArgs.at(-1)).toBe('https://example.com/file.ts')
+
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isCodeOrText: false,
+ isImage: true,
+ isEditable: false,
+ isPreviewable: true,
+ }
+ rerender()
+ expect(screen.getByTestId('media-preview')).toHaveTextContent('image:https://example.com/file.png')
+ expect(mocks.fetchArgs.at(-1)).toBeUndefined()
+
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isImage: false,
+ isSQLite: true,
+ isPreviewable: true,
+ }
+ rerender()
+ expect(screen.getByTestId('sqlite-preview')).toHaveTextContent('https://example.com/file.db')
+
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isSQLite: false,
+ isPdf: true,
+ isPreviewable: true,
+ }
+ rerender()
+ expect(screen.getByTestId('pdf-preview')).toHaveTextContent('https://example.com/file.pdf')
+ })
+
+ it('should render a video preview when the file type info marks it as video', () => {
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isVideo: true,
+ isPreviewable: true,
+ }
+
+ render()
+
+ expect(screen.getByTestId('media-preview')).toHaveTextContent('video:https://example.com/file.mp4')
+ })
+
+ it('should render the unsupported fallback when a previewable file has no dedicated renderer', () => {
+ mocks.fileTypeInfo = {
+ ...mocks.fileTypeInfo,
+ isPreviewable: true,
+ }
+
+ render()
+
+ expect(screen.getByTestId('unsupported-preview')).toHaveTextContent('file.custom')
+ })
+
+ it('should expose loading placeholders for all dynamic preview modules', async () => {
+ vi.resetModules()
+ mocks.dynamicLoaders.length = 0
+ mocks.dynamicImporters.length = 0
+
+ const readOnlyFilePreviewModule = await import('../read-only-file-preview')
+ const FreshReadOnlyFilePreview = readOnlyFilePreviewModule.default
+
+ render()
+
+ expect(mocks.dynamicLoaders).toHaveLength(4)
+ expect(mocks.dynamicImporters).toHaveLength(4)
+ mocks.dynamicLoaders.forEach((renderLoader) => {
+ const { unmount } = render(<>{renderLoader()}>)
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ unmount()
+ })
+ for (const loadModule of mocks.dynamicImporters)
+ await expect(loadModule()).resolves.toBeTruthy()
+ expect(Loading).toBeDefined()
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/__tests__/read-only-markdown-preview.spec.tsx b/web/app/components/workflow/skill/viewer/__tests__/read-only-markdown-preview.spec.tsx
new file mode 100644
index 00000000000..79ae9cabc05
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/__tests__/read-only-markdown-preview.spec.tsx
@@ -0,0 +1,37 @@
+import { render } from '@testing-library/react'
+
+import ReadOnlyMarkdownPreview from '../read-only-markdown-preview'
+
+const mocks = vi.hoisted(() => ({
+ editorProps: [] as Array>,
+}))
+
+vi.mock('../../editor/markdown-file-editor', () => ({
+ default: (props: Record) => {
+ mocks.editorProps.push(props)
+ return
+ },
+}))
+
+describe('ReadOnlyMarkdownPreview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.editorProps.length = 0
+ })
+
+ it('should render the markdown editor in read only mode', () => {
+ render()
+
+ expect(mocks.editorProps[0]).toMatchObject({
+ value: '# Skill',
+ readOnly: true,
+ })
+ expect(typeof mocks.editorProps[0].onChange).toBe('function')
+ })
+
+ it('should provide a no-op change handler for the read-only editor', () => {
+ render()
+
+ expect(() => (mocks.editorProps[0].onChange as () => void)()).not.toThrow()
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/__tests__/unsupported-file-download.spec.tsx b/web/app/components/workflow/skill/viewer/__tests__/unsupported-file-download.spec.tsx
new file mode 100644
index 00000000000..ece88df3dde
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/__tests__/unsupported-file-download.spec.tsx
@@ -0,0 +1,46 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+
+import UnsupportedFileDownload from '../unsupported-file-download'
+
+const mocks = vi.hoisted(() => ({
+ downloadUrl: vi.fn(),
+}))
+
+vi.mock('@/utils/download', () => ({
+ downloadUrl: (args: { url: string, fileName: string }) => mocks.downloadUrl(args),
+}))
+
+describe('UnsupportedFileDownload', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render file metadata and trigger a download when a url is available', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.download' }))
+
+ expect(screen.getByText('archive.zip')).toBeInTheDocument()
+ expect(screen.getByText('1.00 KB')).toBeInTheDocument()
+ expect(mocks.downloadUrl).toHaveBeenCalledWith({
+ url: 'https://example.com/archive.zip',
+ fileName: 'archive.zip',
+ })
+ })
+
+ it('should disable the download action when no url is available', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'common.operation.download' })
+ expect(button).toBeDisabled()
+
+ fireEvent.click(button)
+ expect(mocks.downloadUrl).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/constants.spec.ts b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/constants.spec.ts
new file mode 100644
index 00000000000..e59412cc9ff
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/constants.spec.ts
@@ -0,0 +1,7 @@
+import { PREVIEW_ROW_LIMIT } from '../constants'
+
+describe('sqlite preview constants', () => {
+ it('should expose the preview row limit', () => {
+ expect(PREVIEW_ROW_LIMIT).toBe(1000)
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/data-table.spec.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/data-table.spec.tsx
new file mode 100644
index 00000000000..3019a4de3cf
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/data-table.spec.tsx
@@ -0,0 +1,116 @@
+import type { RefObject } from 'react'
+import { render, screen } from '@testing-library/react'
+import DataTable from '../data-table'
+
+const mocks = vi.hoisted(() => ({
+ lastOptions: null as null | {
+ getScrollElement: () => HTMLDivElement | null
+ estimateSize: () => number
+ },
+ virtualRows: [
+ { index: 0, start: 10, end: 42 },
+ { index: 1, start: 42, end: 74 },
+ ],
+ totalSize: 100,
+}))
+
+vi.mock('@tanstack/react-virtual', () => ({
+ useVirtualizer: (options: typeof mocks.lastOptions) => {
+ mocks.lastOptions = options
+ options?.getScrollElement()
+ options?.estimateSize()
+ return {
+ getVirtualItems: () => mocks.virtualRows,
+ getTotalSize: () => mocks.totalSize,
+ }
+ },
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => `${key}${options ? `:${JSON.stringify(options)}` : ''}`,
+ }),
+}))
+const createScrollRef = () => ({ current: null }) as RefObject
+
+describe('DataTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.lastOptions = null
+ mocks.virtualRows = [
+ { index: 0, start: 10, end: 42 },
+ { index: 1, start: 42, end: 74 },
+ ]
+ mocks.totalSize = 100
+ })
+
+ it('should render virtualized rows, formatted values, and truncation footer', () => {
+ const longText = 'x'.repeat(130)
+
+ const { container } = render(
+ ,
+ )
+
+ expect(screen.getByText('id')).toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('skillSidebar.sqlitePreview.nullValue')).toBeInTheDocument()
+ expect(screen.getByText('skillSidebar.sqlitePreview.blobValue:{"size":2}')).toBeInTheDocument()
+ expect(screen.getByText(`${'x'.repeat(120)}…`)).toBeInTheDocument()
+ expect(screen.getByText('skillSidebar.sqlitePreview.rowsTruncated:{"limit":2}')).toBeInTheDocument()
+ expect(container.querySelectorAll('tr[aria-hidden="true"]')).toHaveLength(2)
+ expect(mocks.lastOptions?.estimateSize()).toBe(32)
+ expect(mocks.lastOptions?.getScrollElement()).toBeNull()
+ })
+
+ it('should fall back to the row index as a key when no key column exists', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Ada')).toBeInTheDocument()
+ expect(screen.getByText('Grace')).toBeInTheDocument()
+ })
+
+ it('should fall back to the virtual row index when the key column value is null', () => {
+ mocks.virtualRows = [{ index: 0, start: 0, end: 32 }]
+ mocks.totalSize = 32
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Ada')).toBeInTheDocument()
+ })
+
+ it('should skip spacer rows when the virtualizer has no visible rows', () => {
+ mocks.virtualRows = []
+ mocks.totalSize = 0
+
+ const { container } = render(
+ ,
+ )
+
+ expect(container.querySelectorAll('tr[aria-hidden="true"]')).toHaveLength(0)
+ expect(screen.getByText('name')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/index.spec.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..1d4ee81194c
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/index.spec.tsx
@@ -0,0 +1,138 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+
+import SQLiteFilePreview from '../index'
+
+const mocks = vi.hoisted(() => ({
+ databaseState: {
+ tables: [] as string[],
+ isLoading: false,
+ error: null as Error | null,
+ queryTable: vi.fn(),
+ },
+ tableState: {
+ data: null as null | { columns: string[], values: unknown[][] },
+ isLoading: false,
+ error: null as Error | null,
+ },
+ selectorProps: [] as Array>,
+ panelProps: [] as Array>,
+}))
+
+vi.mock('../../../hooks/use-sqlite-database', () => ({
+ useSQLiteDatabase: () => mocks.databaseState,
+}))
+
+vi.mock('../use-sqlite-table', () => ({
+ useSQLiteTable: (args: unknown) => {
+ mocks.selectorProps.push({ hookArgs: args })
+ return mocks.tableState
+ },
+}))
+
+vi.mock('../table-selector', () => ({
+ default: (props: Record) => {
+ mocks.selectorProps.push(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('../table-panel', () => ({
+ default: (props: Record) => {
+ mocks.panelProps.push(props)
+ return
+ },
+}))
+
+describe('SQLiteFilePreview', () => {
+ const getHookInvocations = () => {
+ return mocks.selectorProps.filter(item => Object.hasOwn(item, 'hookArgs'))
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.selectorProps.length = 0
+ mocks.panelProps.length = 0
+ mocks.databaseState = {
+ tables: [],
+ isLoading: false,
+ error: null,
+ queryTable: vi.fn(),
+ }
+ mocks.tableState = {
+ data: null,
+ isLoading: false,
+ error: null,
+ }
+ })
+
+ it('should render the unavailable state when no url is provided', () => {
+ render()
+
+ expect(screen.getByText('workflow.skillEditor.previewUnavailable')).toBeInTheDocument()
+ })
+
+ it('should render the loading state from the database hook', () => {
+ mocks.databaseState.isLoading = true
+ render()
+
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render the error state from the database hook', () => {
+ mocks.databaseState.error = new Error('boom')
+
+ render()
+
+ expect(screen.getByText('workflow.skillSidebar.sqlitePreview.loadError')).toBeInTheDocument()
+ })
+
+ it('should render the empty state when the database has no tables', () => {
+ render()
+
+ expect(screen.getByText('workflow.skillSidebar.sqlitePreview.emptyTables')).toBeInTheDocument()
+ })
+
+ it('should render selector and panel props when tables are available', () => {
+ mocks.databaseState.tables = ['users', 'events']
+ mocks.tableState.data = {
+ columns: ['id'],
+ values: Array.from({ length: 1000 }, (_, index) => [index + 1]),
+ }
+
+ render()
+
+ expect(screen.getByTestId('sqlite-table-panel')).toBeInTheDocument()
+ expect(mocks.selectorProps.find(item => Object.hasOwn(item, 'tables'))).toMatchObject({
+ tables: ['users', 'events'],
+ selectedTable: 'users',
+ isLoading: false,
+ })
+ expect(mocks.panelProps[0]).toMatchObject({
+ data: mocks.tableState.data,
+ isLoading: false,
+ error: null,
+ isTruncated: true,
+ })
+ })
+
+ it('should update the selected table when the selector changes', () => {
+ mocks.databaseState.tables = ['users', 'events']
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'change-table' }))
+
+ return waitFor(() => {
+ expect(getHookInvocations().at(-1)).toMatchObject({
+ hookArgs: {
+ selectedTable: 'events',
+ queryTable: mocks.databaseState.queryTable,
+ },
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/table-panel.spec.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/table-panel.spec.tsx
new file mode 100644
index 00000000000..63ec190f7dc
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/table-panel.spec.tsx
@@ -0,0 +1,87 @@
+import type { RefObject } from 'react'
+import { render, screen } from '@testing-library/react'
+import TablePanel from '../table-panel'
+
+const mocks = vi.hoisted(() => ({
+ dataTableProps: [] as Array>,
+}))
+
+vi.mock('../data-table', () => ({
+ default: (props: Record) => {
+ mocks.dataTableProps.push(props)
+ return
+ },
+}))
+const createScrollRef = () => ({ current: null }) as RefObject
+
+describe('TablePanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.dataTableProps.length = 0
+ })
+
+ it('should render a loading state', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render an error state', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.skillSidebar.sqlitePreview.loadError')).toBeInTheDocument()
+ })
+
+ it('should render the data table when rows are available', () => {
+ const scrollRef = createScrollRef()
+ const data = {
+ columns: ['id'],
+ values: [[1]],
+ }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('sqlite-data-table')).toBeInTheDocument()
+ expect(mocks.dataTableProps[0]).toMatchObject({
+ columns: ['id'],
+ values: [[1]],
+ scrollRef,
+ isTruncated: true,
+ })
+ })
+
+ it('should render an empty state when no rows are available', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.skillSidebar.sqlitePreview.emptyRows')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/table-selector.spec.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/table-selector.spec.tsx
new file mode 100644
index 00000000000..8543e722751
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/table-selector.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+
+import TableSelector from '../table-selector'
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {children}
,
+ PortalToFollowElemTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+describe('TableSelector', () => {
+ it('should render a compact label when only one table is available', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.skillSidebar.sqlitePreview.selectTable')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should render placeholder styling for multi-table selectors without a selection', () => {
+ render(
+ ,
+ )
+
+ const trigger = screen.getAllByRole('button', { name: /workflow\.skillSidebar\.sqlitePreview\.selectTable/i })[0]
+ expect(trigger).toHaveClass('cursor-pointer')
+ expect(screen.getByText('workflow.skillSidebar.sqlitePreview.selectTable')).toHaveClass('text-text-tertiary')
+ })
+
+ it('should allow selecting a different table', () => {
+ const onTableChange = vi.fn()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getAllByRole('button', { name: /users/i })[0])
+ fireEvent.click(screen.getByRole('button', { name: /events/i }))
+
+ expect(onTableChange).toHaveBeenCalledWith('events')
+ })
+
+ it('should disable the trigger while loading', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getAllByRole('button', { name: /users/i })[0]).toBeDisabled()
+ })
+})
diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/use-sqlite-table.spec.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/use-sqlite-table.spec.tsx
new file mode 100644
index 00000000000..68119157dad
--- /dev/null
+++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/__tests__/use-sqlite-table.spec.tsx
@@ -0,0 +1,128 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { useSQLiteTable } from '../use-sqlite-table'
+
+describe('useSQLiteTable', () => {
+ it('should reset when no table is selected', () => {
+ const queryTable = vi.fn()
+
+ const { result } = renderHook(() => useSQLiteTable({
+ selectedTable: '',
+ queryTable,
+ }))
+
+ expect(queryTable).not.toHaveBeenCalled()
+ expect(result.current).toEqual({
+ data: null,
+ isLoading: false,
+ error: null,
+ })
+ })
+
+ it('should load the selected table with the preview row limit', async () => {
+ const queryTable = vi.fn().mockResolvedValue({
+ columns: ['id'],
+ values: [[1]],
+ })
+
+ const { result } = renderHook(() => useSQLiteTable({
+ selectedTable: 'users',
+ queryTable,
+ }))
+
+ expect(result.current.isLoading).toBe(true)
+ await waitFor(() => expect(result.current.data).toEqual({
+ columns: ['id'],
+ values: [[1]],
+ }))
+
+ expect(queryTable).toHaveBeenCalledWith('users', 1000)
+ expect(result.current.error).toBeNull()
+ })
+
+ it('should surface query errors', async () => {
+ const queryTable = vi.fn().mockRejectedValue(new Error('boom'))
+
+ const { result } = renderHook(() => useSQLiteTable({
+ selectedTable: 'users',
+ queryTable,
+ }))
+
+ await waitFor(() => expect(result.current.error?.message).toBe('boom'))
+ expect(result.current.data).toBeNull()
+ expect(result.current.isLoading).toBe(false)
+ })
+
+ it('should wrap non-error rejections in an Error instance', async () => {
+ const queryTable = vi.fn().mockRejectedValue('boom')
+
+ const { result } = renderHook(() => useSQLiteTable({
+ selectedTable: 'users',
+ queryTable,
+ }))
+
+ await waitFor(() => expect(result.current.error?.message).toBe('boom'))
+ expect(result.current.error).toBeInstanceOf(Error)
+ })
+
+ it('should ignore stale async results after the table changes', async () => {
+ let resolveFirst!: (value: { columns: string[], values: number[][] }) => void
+ const queryTable = vi
+ .fn()
+ .mockImplementationOnce(() => new Promise((resolve) => {
+ resolveFirst = resolve
+ }))
+ .mockResolvedValueOnce({
+ columns: ['id'],
+ values: [[2]],
+ })
+
+ const { result, rerender } = renderHook(
+ ({ selectedTable }) => useSQLiteTable({
+ selectedTable,
+ queryTable,
+ }),
+ {
+ initialProps: {
+ selectedTable: 'users',
+ },
+ },
+ )
+
+ rerender({ selectedTable: 'events' })
+ resolveFirst({
+ columns: ['id'],
+ values: [[1]],
+ })
+
+ await waitFor(() => expect(result.current.data).toEqual({
+ columns: ['id'],
+ values: [[2]],
+ }))
+ })
+
+ it('should ignore rejected requests after the selection resets', async () => {
+ let rejectFirst!: (reason?: unknown) => void
+ const queryTable = vi.fn().mockImplementation(() => new Promise((_, reject) => {
+ rejectFirst = reject
+ }))
+
+ const { result, rerender } = renderHook(
+ ({ selectedTable }) => useSQLiteTable({
+ selectedTable,
+ queryTable,
+ }),
+ {
+ initialProps: {
+ selectedTable: 'users',
+ },
+ },
+ )
+
+ rerender({ selectedTable: '' })
+ rejectFirst(new Error('late failure'))
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
+ expect(result.current.error).toBeNull()
+ expect(result.current.data).toBeNull()
+ })
+})