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