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 }) =>
{children}
, +})) + +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() + }) +})