mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
chore(web): comprehensive unit tests
This commit is contained in:
@@ -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(<ContentArea />)
|
||||
|
||||
// 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(
|
||||
<ContentArea>
|
||||
<span>{childText}</span>
|
||||
</ContentArea>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(childText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render an empty section when children is undefined', () => {
|
||||
// Arrange
|
||||
const { container } = render(<ContentArea>{undefined}</ContentArea>)
|
||||
|
||||
// Act
|
||||
const section = container.querySelector('section')
|
||||
|
||||
// Assert
|
||||
expect(section).toBeInTheDocument()
|
||||
expect(section?.childElementCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(<ContentBody />)
|
||||
|
||||
// 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(
|
||||
<ContentBody>
|
||||
<span>{childText}</span>
|
||||
</ContentBody>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(childText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render an empty container when children is null', () => {
|
||||
// Arrange
|
||||
const { container } = render(<ContentBody>{null}</ContentBody>)
|
||||
|
||||
// Act
|
||||
const body = container.querySelector('div')
|
||||
|
||||
// Assert
|
||||
expect(body).toBeInTheDocument()
|
||||
expect(body?.childElementCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
<div>sidebar-content</div>
|
||||
</Sidebar>,
|
||||
)
|
||||
|
||||
// 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(<Sidebar />)
|
||||
|
||||
// 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(<Sidebar />)
|
||||
|
||||
// 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(<Sidebar>{null}</Sidebar>)
|
||||
|
||||
// Act
|
||||
const aside = container.querySelector('aside')
|
||||
|
||||
// Assert
|
||||
expect(aside).toBeInTheDocument()
|
||||
expect(aside?.childElementCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(<SkillPageLayout />)
|
||||
|
||||
// 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(
|
||||
<SkillPageLayout>
|
||||
<span>{leftText}</span>
|
||||
<span>{rightText}</span>
|
||||
</SkillPageLayout>,
|
||||
)
|
||||
|
||||
// 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(<SkillPageLayout />)
|
||||
|
||||
// Act
|
||||
const layout = container.querySelector('div')
|
||||
|
||||
// Assert
|
||||
expect(layout).toBeInTheDocument()
|
||||
expect(layout?.childElementCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ArtifactContentPanel />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>
|
||||
fileMetadata: Map<string, Record<string, unknown>>
|
||||
dirtyMetadataIds: Set<string>
|
||||
}
|
||||
|
||||
type WorkflowStoreActions = {
|
||||
setFileMetadata: (fileId: string, metadata: Record<string, unknown>) => void
|
||||
clearDraftMetadata: (fileId: string) => void
|
||||
setDraftMetadata: (fileId: string, metadata: Record<string, unknown>) => 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, unknown> | 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> = {}): 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<string, string>(),
|
||||
fileMetadata: new Map<string, Record<string, unknown>>(),
|
||||
dirtyMetadataIds: new Set<string>(),
|
||||
} 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<string, AppAssetTreeView>([['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 }) => (
|
||||
<div data-testid="dynamic-preview">{downloadUrl}</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
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: () => <div data-testid="start-tab-content" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../editor/markdown-file-editor', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
autoFocus,
|
||||
onAutoFocus,
|
||||
collaborationEnabled,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
collaborationEnabled?: boolean
|
||||
}) => (
|
||||
<div data-testid="markdown-editor">
|
||||
<span>{`value:${value}`}</span>
|
||||
<span>{`autoFocus:${String(Boolean(autoFocus))}`}</span>
|
||||
<span>{`collaboration:${String(Boolean(collaborationEnabled))}`}</span>
|
||||
<button type="button" onClick={() => onChange(`linked §[file].[app].[${FILE_REFERENCE_ID}]§`)}>
|
||||
markdown-change
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange('plain-markdown')}>
|
||||
markdown-no-ref
|
||||
</button>
|
||||
<button type="button" onClick={onAutoFocus}>
|
||||
markdown-autofocus
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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
|
||||
}) => (
|
||||
<div data-testid="code-editor">
|
||||
<span>{`value:${value}`}</span>
|
||||
<span>{`theme:${theme}`}</span>
|
||||
<span>{`language:${language}`}</span>
|
||||
<button type="button" onClick={() => onChange('updated-code')}>
|
||||
code-change
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange(undefined)}>
|
||||
code-clear
|
||||
</button>
|
||||
<button type="button" onClick={onAutoFocus}>
|
||||
code-autofocus
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const monaco = {
|
||||
editor: {
|
||||
setTheme: mocks.setMonacoTheme,
|
||||
},
|
||||
} as unknown as Parameters<OnMount>[1]
|
||||
onMount({} as Parameters<OnMount>[0], monaco)
|
||||
}}
|
||||
>
|
||||
code-mount
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../viewer/media-file-preview', () => ({
|
||||
default: ({ type, src }: { type: 'image' | 'video', src: string }) => (
|
||||
<div data-testid="media-preview">{`${type}|${src}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../viewer/unsupported-file-download', () => ({
|
||||
default: ({ name, size, downloadUrl }: { name: string, size?: number, downloadUrl: string }) => (
|
||||
<div data-testid="unsupported-preview">{`${name}|${String(size)}|${downloadUrl}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/file-utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils/file-utils')>('../../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<string, string>()
|
||||
mocks.workflowState.fileMetadata = new Map<string, Record<string, unknown>>()
|
||||
mocks.workflowState.dirtyMetadataIds = new Set<string>()
|
||||
mocks.workflowActions = createDefaultActions()
|
||||
mocks.nodeMapData = new Map<string, AppAssetTreeView>([['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(<FileContentPanel />)
|
||||
|
||||
// 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(<FileContentPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('workflow.skillSidebar.empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading indicator when file node is resolving', () => {
|
||||
// Arrange
|
||||
mocks.fileNodeViewState = 'resolving'
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render load error when selected file is missing', () => {
|
||||
// Arrange
|
||||
mocks.fileNodeViewState = 'missing'
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
|
||||
// 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(<FileContentPanel />)
|
||||
|
||||
// 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(<FileContentPanel />)
|
||||
|
||||
// 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<string, Record<string, unknown>>([
|
||||
['file-1', {}],
|
||||
])
|
||||
mocks.workflowState.editorAutoFocusFileId = 'file-1'
|
||||
mocks.nodeMapData = new Map<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
|
||||
[FILE_REFERENCE_ID, createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' })],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
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(<FileContentPanel />)
|
||||
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(<FileContentPanel />)
|
||||
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(<FileContentPanel />)
|
||||
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(<FileContentPanel />)
|
||||
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(<FileContentPanel />)
|
||||
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<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'image.png', extension: 'png' })],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
|
||||
// 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<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'archive.bin', extension: 'bin', size: 99 })],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
|
||||
// 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(<FileContentPanel />)
|
||||
|
||||
// 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(<FileContentPanel />)
|
||||
|
||||
// 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(<FileContentPanel />)
|
||||
|
||||
// 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<string, Record<string, unknown>>([
|
||||
['file-1', {
|
||||
files: {
|
||||
[FILE_REFERENCE_ID]: createNode({ id: FILE_REFERENCE_ID, name: 'kb.txt', extension: 'txt' }),
|
||||
},
|
||||
}],
|
||||
])
|
||||
mocks.nodeMapData = new Map<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
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<string, Record<string, unknown>>([
|
||||
['file-1', {
|
||||
files: {
|
||||
[FILE_REFERENCE_ID]: referencedNode,
|
||||
},
|
||||
}],
|
||||
])
|
||||
mocks.nodeMapData = new Map<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
|
||||
[FILE_REFERENCE_ID, referencedNode],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
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<string, Record<string, unknown>>([
|
||||
['file-1', {
|
||||
files: {
|
||||
[FILE_REFERENCE_ID]: existingReferencedNode,
|
||||
},
|
||||
}],
|
||||
])
|
||||
mocks.nodeMapData = new Map<string, AppAssetTreeView>([
|
||||
['file-1', createNode({ name: 'prompt.md', extension: 'md' })],
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FileContentPanel />)
|
||||
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<string, Record<string, unknown>>([
|
||||
['file-1', { language: 'ts' }],
|
||||
])
|
||||
mocks.fileData.fileContent = {
|
||||
content: 'draft-base',
|
||||
metadata: { language: 'ts' },
|
||||
}
|
||||
|
||||
// Act
|
||||
const { unmount } = render(<FileContentPanel />)
|
||||
|
||||
// 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' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLInputElement | null>
|
||||
folderInputRef: React.RefObject<HTMLInputElement | null>
|
||||
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>): 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 (
|
||||
<div data-testid="import-skill-modal">
|
||||
<button type="button" onClick={onClose}>
|
||||
close-import-modal
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(<SidebarSearchAdd />)
|
||||
|
||||
// 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(<SidebarSearchAdd />)
|
||||
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(<SidebarSearchAdd />)
|
||||
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(<SidebarSearchAdd />)
|
||||
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(<SidebarSearchAdd />)
|
||||
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(<SidebarSearchAdd />)
|
||||
|
||||
// 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(<SidebarSearchAdd />)
|
||||
|
||||
// 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(<SidebarSearchAdd />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<ActionCard
|
||||
icon={<span data-testid="action-card-icon">i</span>}
|
||||
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(
|
||||
<ActionCard
|
||||
icon={<span>i</span>}
|
||||
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(
|
||||
<ActionCard
|
||||
icon={<span>i</span>}
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
prepareSkillUploadFile: vi.fn(),
|
||||
toastNotify: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
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<AppSSO>,
|
||||
})
|
||||
mocks.prepareSkillUploadFile.mockImplementation(async (file: File) => file)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render modal title and disable create button when skill name is empty', () => {
|
||||
render(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<CreateBlankSkillModal isOpen onClose={onClose} />)
|
||||
|
||||
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(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<CreateBlankSkillModal isOpen onClose={onClose} />)
|
||||
|
||||
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(<CreateBlankSkillModal isOpen onClose={onClose} />)
|
||||
|
||||
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(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<CreateBlankSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
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<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render create and import action cards when section is mounted', () => {
|
||||
render(<CreateImportSection />)
|
||||
|
||||
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(<CreateImportSection />)
|
||||
|
||||
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(<CreateImportSection />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
extractAndValidateZip: vi.fn(),
|
||||
buildUploadDataFromZip: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
toastNotify: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
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<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render drop zone and keep import button disabled when no file is selected', () => {
|
||||
render(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={onClose} />)
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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(<ImportSkillModal isOpen onClose={vi.fn()} />)
|
||||
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
66
web/app/components/workflow/skill/start-tab/index.spec.tsx
Normal file
66
web/app/components/workflow/skill/start-tab/index.spec.tsx
Normal file
@@ -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<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
openTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
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<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render create/import actions and template list when mounted', () => {
|
||||
const { container } = render(<StartTabContent />)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<SectionHeader
|
||||
title="Templates"
|
||||
description="Choose a template to start quickly"
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<SectionHeader
|
||||
title="Title"
|
||||
description="Desc"
|
||||
className="mt-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('header')).toHaveClass('mt-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render an empty description paragraph when description is empty', () => {
|
||||
const { container } = render(
|
||||
<SectionHeader
|
||||
title="Templates"
|
||||
description=""
|
||||
/>,
|
||||
)
|
||||
|
||||
const paragraph = container.querySelector('p')
|
||||
expect(paragraph).toBeInTheDocument()
|
||||
expect(paragraph).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<typeof vi.fn>
|
||||
setUploadProgress: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type TemplateEntry = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
fileCount: number
|
||||
loadContent: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
templates: [] as TemplateEntry[],
|
||||
buildUploadDataFromTemplate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
emitTreeUpdate: vi.fn(),
|
||||
existingNames: new Set<string>(),
|
||||
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> = {}): 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<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render all templates from registry', () => {
|
||||
render(<SkillTemplatesSection />)
|
||||
|
||||
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(<SkillTemplatesSection />)
|
||||
|
||||
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(<SkillTemplatesSection />)
|
||||
|
||||
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(<SkillTemplatesSection />)
|
||||
|
||||
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(<SkillTemplatesSection />)
|
||||
|
||||
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(<SkillTemplatesSection />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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(
|
||||
<TemplateCard
|
||||
template={createTemplate()}
|
||||
onUse={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<TemplateCard template={template} onUse={onUse} />)
|
||||
|
||||
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(
|
||||
<TemplateCard
|
||||
template={createTemplate()}
|
||||
added
|
||||
onUse={onUse}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<TemplateCard
|
||||
template={createTemplate()}
|
||||
disabled
|
||||
onUse={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workflow\.skill\.startTab\.useThisSkill/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render loading status when loading is true', () => {
|
||||
render(
|
||||
<TemplateCard
|
||||
template={createTemplate()}
|
||||
loading
|
||||
onUse={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(<TemplateSearch onChange={vi.fn()} />)
|
||||
|
||||
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(<TemplateSearch onChange={onChange} />)
|
||||
|
||||
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(<TemplateSearch onChange={onChange} />)
|
||||
|
||||
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, '')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user