chore(web): comprehensive unit tests

This commit is contained in:
yyh
2026-02-09 16:46:53 +08:00
parent 3a1eefa477
commit 363802aa66
18 changed files with 2651 additions and 0 deletions

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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' }),
}),
)
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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)
})
})
})
})

View File

@@ -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()
})
})
})
})

View File

@@ -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',
})
})
})
})

View 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')
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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, '')
})
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})
})