mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 05:07:35 +08:00
Compare commits
6 Commits
deploy/age
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
| f331a1f5c6 | |||
| 4419d14887 | |||
| 9ef53a3179 | |||
| 0d7f6f26c8 | |||
| 571a960c18 | |||
| e650109f94 |
@ -365,6 +365,22 @@ describe('PromptEditor', () => {
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should rerender rapidly without triggering a ref update loop', () => {
|
||||
const { rerender } = render(
|
||||
<React.StrictMode>
|
||||
<PromptEditor value="first" />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
rerender(
|
||||
<React.StrictMode>
|
||||
<PromptEditor value="second" />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render hitl block when show=true', () => {
|
||||
render(
|
||||
<PromptEditor
|
||||
|
||||
@ -38,7 +38,7 @@ import {
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
|
||||
import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
|
||||
@ -344,10 +344,12 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
const onRef = (floatingAnchorElement: HTMLDivElement | null) => {
|
||||
if (floatingAnchorElement !== null)
|
||||
setFloatingAnchorElem(floatingAnchorElement)
|
||||
}
|
||||
const onRef = useCallback((floatingAnchorElement: HTMLDivElement | null) => {
|
||||
if (floatingAnchorElement === null)
|
||||
return
|
||||
|
||||
setFloatingAnchorElem(prev => prev === floatingAnchorElement ? prev : floatingAnchorElement)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||
|
||||
@ -19,6 +19,7 @@ const mockUseSubscription = vi.fn()
|
||||
const mockCollaborationSetNodes = vi.fn()
|
||||
const mockCollaborationSetEdges = vi.fn()
|
||||
const mockEmitGraphViewActive = vi.fn()
|
||||
const mockUseCollaboration = vi.fn()
|
||||
|
||||
let appStoreState: {
|
||||
appDetail?: {
|
||||
@ -63,6 +64,8 @@ let searchParamsValue: string | null = null
|
||||
const workflowUiState = {
|
||||
appId: 'app-1',
|
||||
isResponding: false,
|
||||
isRestoring: false,
|
||||
historyWorkflowData: undefined as Record<string, unknown> | undefined,
|
||||
showUpgradeRuntimeModal: false,
|
||||
setShowUpgradeRuntimeModal: mockSetShowUpgradeRuntimeModal,
|
||||
}
|
||||
@ -145,7 +148,7 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration', () => ({
|
||||
useCollaboration: () => undefined,
|
||||
useCollaboration: (...args: unknown[]) => mockUseCollaboration(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
@ -293,6 +296,8 @@ describe('WorkflowApp', () => {
|
||||
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
|
||||
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
|
||||
mockSyncWorkflowDraftImmediately.mockResolvedValue(undefined)
|
||||
workflowUiState.isRestoring = false
|
||||
workflowUiState.historyWorkflowData = undefined
|
||||
})
|
||||
|
||||
it('should render the loading shell while workflow data is still loading', () => {
|
||||
@ -427,4 +432,27 @@ describe('WorkflowApp', () => {
|
||||
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
|
||||
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'the workflow is restoring',
|
||||
workflowState: {
|
||||
isRestoring: true,
|
||||
historyWorkflowData: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'a historical workflow version is selected',
|
||||
workflowState: {
|
||||
isRestoring: false,
|
||||
historyWorkflowData: { id: 'history-1' },
|
||||
},
|
||||
},
|
||||
])('should disable the collaboration session when $description', ({ workflowState }) => {
|
||||
Object.assign(workflowUiState, workflowState)
|
||||
|
||||
render(<WorkflowApp />)
|
||||
|
||||
expect(mockUseCollaboration).toHaveBeenCalledWith('app-1', undefined, false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,9 +14,12 @@ const mockGetNodes = vi.fn()
|
||||
const mockSetNodes = vi.fn()
|
||||
const mockGetEdges = vi.fn()
|
||||
const mockSetEdges = vi.fn()
|
||||
const mockUseCollaboration = vi.fn()
|
||||
|
||||
const workflowUiState = {
|
||||
appId: 'app-1',
|
||||
isRestoring: false,
|
||||
historyWorkflowData: undefined as Record<string, unknown> | undefined,
|
||||
}
|
||||
|
||||
const hookFns = {
|
||||
@ -97,14 +100,17 @@ vi.mock('@/app/components/workflow/collaboration', () => ({
|
||||
onWorkflowUpdate: vi.fn(() => vi.fn()),
|
||||
onSyncRequest: vi.fn(() => vi.fn()),
|
||||
},
|
||||
useCollaboration: () => ({
|
||||
startCursorTracking: mockStartCursorTracking,
|
||||
stopCursorTracking: mockStopCursorTracking,
|
||||
onlineUsers: [],
|
||||
cursors: {},
|
||||
isConnected: false,
|
||||
isEnabled: false,
|
||||
}),
|
||||
useCollaboration: (...args: unknown[]) => {
|
||||
mockUseCollaboration(...args)
|
||||
return {
|
||||
startCursorTracking: mockStartCursorTracking,
|
||||
stopCursorTracking: mockStopCursorTracking,
|
||||
onlineUsers: [],
|
||||
cursors: {},
|
||||
isConnected: false,
|
||||
isEnabled: false,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/context/mcp-tool-availability-context', () => ({
|
||||
@ -215,6 +221,8 @@ describe('WorkflowMain', () => {
|
||||
capturedContextProps = null
|
||||
mockGetNodes.mockReturnValue([])
|
||||
mockGetEdges.mockReturnValue([])
|
||||
workflowUiState.isRestoring = false
|
||||
workflowUiState.historyWorkflowData = undefined
|
||||
})
|
||||
|
||||
it('should render the inner workflow context with children and forwarded graph props', () => {
|
||||
@ -331,4 +339,39 @@ describe('WorkflowMain', () => {
|
||||
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'the workflow is restoring',
|
||||
workflowState: {
|
||||
isRestoring: true,
|
||||
historyWorkflowData: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'viewing workflow history',
|
||||
workflowState: {
|
||||
isRestoring: false,
|
||||
historyWorkflowData: { id: 'history-1' },
|
||||
},
|
||||
},
|
||||
])('should disable collaboration when $description', ({ workflowState }) => {
|
||||
Object.assign(workflowUiState, workflowState)
|
||||
|
||||
render(
|
||||
<WorkflowMain
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseCollaboration).toHaveBeenCalledWith(
|
||||
'app-1',
|
||||
expect.objectContaining({
|
||||
getState: expect.any(Function),
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -50,6 +50,7 @@ const WorkflowMain = ({
|
||||
const featuresStore = useFeaturesStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const appId = useStore(s => s.appId)
|
||||
const isWorkflowCollaborationEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
@ -68,7 +69,7 @@ const WorkflowMain = ({
|
||||
cursors,
|
||||
isConnected,
|
||||
isEnabled: isCollaborationEnabled,
|
||||
} = useCollaboration(appId || '', reactFlowStore)
|
||||
} = useCollaboration(appId || '', reactFlowStore, isWorkflowCollaborationEnabled)
|
||||
const myUserId = useMemo(
|
||||
() => (isCollaborationEnabled && isConnected ? 'current-user' : null),
|
||||
[isCollaborationEnabled, isConnected],
|
||||
|
||||
@ -65,7 +65,8 @@ const SkillMain = dynamic(() => import('@/app/components/workflow/skill/main'),
|
||||
|
||||
const CollaborationSession = () => {
|
||||
const appId = useStore(s => s.appId)
|
||||
useCollaboration(appId || '')
|
||||
const isCollaborationSessionEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||
useCollaboration(appId || '', undefined, isCollaborationSessionEnabled)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@ -19,21 +19,6 @@ const {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
children,
|
||||
popupContent,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupContent: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<span>{popupContent}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
@ -127,7 +112,7 @@ describe('Tabs', () => {
|
||||
render(<Tabs {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('start-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.tabs.startDisabledTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blocks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch tabs through click handlers and render tools content with normalized icons', () => {
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
import { BLOCK_CLASSIFICATIONS } from './constants'
|
||||
@ -93,12 +93,33 @@ const Blocks = ({
|
||||
}
|
||||
{
|
||||
filteredList.map(block => (
|
||||
<Tooltip
|
||||
key={block.metaData.type}
|
||||
position="right"
|
||||
popupClassName="w-[200px] rounded-xl"
|
||||
needsDelay={false}
|
||||
popupContent={(
|
||||
<Popover key={block.metaData.type}>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
key={block.metaData.type}
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(block.metaData.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className="mr-2 shrink-0"
|
||||
type={block.metaData.iconType || block.metaData.type}
|
||||
/>
|
||||
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
|
||||
{
|
||||
block.metaData.type === BlockEnum.LoopEnd && (
|
||||
<Badge
|
||||
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
|
||||
className="ml-2 shrink-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="right" popupClassName="w-[200px] rounded-xl px-3 py-2 text-left">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
@ -108,28 +129,8 @@ const Blocks = ({
|
||||
<div className="mb-1 text-text-primary system-md-medium">{block.metaData.title}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{block.metaData.description}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={block.metaData.type}
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(block.metaData.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className="mr-2 shrink-0"
|
||||
type={block.metaData.iconType || block.metaData.type}
|
||||
/>
|
||||
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
|
||||
{
|
||||
block.metaData.type === BlockEnum.LoopEnd && (
|
||||
<Badge
|
||||
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
|
||||
className="ml-2 shrink-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@ -256,63 +256,69 @@ function FeaturedToolUninstalledItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
position="right"
|
||||
needsDelay={false}
|
||||
popupClassName="!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg"
|
||||
popupContent={(
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
|
||||
render={(
|
||||
<div
|
||||
className="group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex h-full min-w-0 items-center">
|
||||
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
|
||||
<div className="ml-2 min-w-0">
|
||||
<div className="truncate text-text-secondary system-sm-medium">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto inline-grid h-full shrink-0 items-center pl-1">
|
||||
<span
|
||||
className={`col-start-1 row-start-1 text-text-tertiary system-xs-regular ${actionOpen || isActionHovered ? 'invisible' : 'group-hover:invisible'}`}
|
||||
>
|
||||
{installCountLabel}
|
||||
</span>
|
||||
<div
|
||||
className={`col-start-1 row-start-1 flex h-full items-center gap-1 justify-self-end text-components-button-secondary-accent-text system-xs-medium [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen || isActionHovered ? 'visible' : 'invisible group-hover:visible'}`}
|
||||
onMouseEnter={() => setIsActionHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!actionOpen)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
setIsActionHovered(true)
|
||||
}}
|
||||
>
|
||||
{t('installAction', { ns: 'plugin' })}
|
||||
</button>
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={(value) => {
|
||||
setActionOpen(value)
|
||||
setIsActionHovered(value)
|
||||
}}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="right" popupClassName="!w-[224px] !rounded-xl !border-[0.5px] !border-black/5 !p-0 !px-3 !py-2.5 !text-xs !leading-[18px] !text-gray-700 !shadow-lg">
|
||||
<div>
|
||||
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={plugin.icon} />
|
||||
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
|
||||
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
|
||||
</div>
|
||||
)}
|
||||
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
|
||||
>
|
||||
<div
|
||||
className="group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex h-full min-w-0 items-center">
|
||||
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
|
||||
<div className="ml-2 min-w-0">
|
||||
<div className="truncate text-text-secondary system-sm-medium">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex h-full items-center gap-1 pl-1">
|
||||
<span className={`text-text-tertiary system-xs-regular ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
|
||||
<div
|
||||
className={`flex h-full items-center gap-1 text-components-button-secondary-accent-text system-xs-medium [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
|
||||
onMouseEnter={() => setIsActionHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!actionOpen)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
setIsActionHovered(true)
|
||||
}}
|
||||
>
|
||||
{t('installAction', { ns: 'plugin' })}
|
||||
</button>
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={(value) => {
|
||||
setActionOpen(value)
|
||||
setIsActionHovered(value)
|
||||
}}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{isInstallModalOpen && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={plugin.latest_package_identifier}
|
||||
|
||||
@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@ -251,63 +251,69 @@ function FeaturedTriggerUninstalledItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
position="right"
|
||||
needsDelay={false}
|
||||
popupClassName="!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg"
|
||||
popupContent={(
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
|
||||
render={(
|
||||
<div
|
||||
className="group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex h-full min-w-0 items-center">
|
||||
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
|
||||
<div className="ml-2 min-w-0">
|
||||
<div className="truncate text-text-secondary system-sm-medium">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto inline-grid h-full shrink-0 items-center pl-1">
|
||||
<span
|
||||
className={`col-start-1 row-start-1 text-text-tertiary system-xs-regular ${actionOpen || isActionHovered ? 'invisible' : 'group-hover:invisible'}`}
|
||||
>
|
||||
{installCountLabel}
|
||||
</span>
|
||||
<div
|
||||
className={`col-start-1 row-start-1 flex h-full items-center gap-1 justify-self-end text-components-button-secondary-accent-text system-xs-medium [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen || isActionHovered ? 'visible' : 'invisible group-hover:visible'}`}
|
||||
onMouseEnter={() => setIsActionHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!actionOpen)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
setIsActionHovered(true)
|
||||
}}
|
||||
>
|
||||
{t('installAction', { ns: 'plugin' })}
|
||||
</button>
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={(value) => {
|
||||
setActionOpen(value)
|
||||
setIsActionHovered(value)
|
||||
}}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="right" popupClassName="!w-[224px] !rounded-xl !border-[0.5px] !border-black/5 !p-0 !px-3 !py-2.5 !text-xs !leading-[18px] !text-gray-700 !shadow-lg">
|
||||
<div>
|
||||
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
|
||||
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
|
||||
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
|
||||
</div>
|
||||
)}
|
||||
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
|
||||
>
|
||||
<div
|
||||
className="group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex h-full min-w-0 items-center">
|
||||
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
|
||||
<div className="ml-2 min-w-0">
|
||||
<div className="truncate text-text-secondary system-sm-medium">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex h-full items-center gap-1 pl-1">
|
||||
<span className={`text-text-tertiary system-xs-regular ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
|
||||
<div
|
||||
className={`flex h-full items-center gap-1 text-components-button-secondary-accent-text system-xs-medium [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
|
||||
onMouseEnter={() => setIsActionHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!actionOpen)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
setIsActionHovered(true)
|
||||
}}
|
||||
>
|
||||
{t('installAction', { ns: 'plugin' })}
|
||||
</button>
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={(value) => {
|
||||
setActionOpen(value)
|
||||
setIsActionHovered(value)
|
||||
}}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{isInstallModalOpen && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={plugin.latest_package_identifier}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { useAvailableNodesMetaData } from '../../workflow-app/hooks'
|
||||
import BlockIcon from '../block-icon'
|
||||
@ -68,12 +68,29 @@ const StartBlocks = ({
|
||||
}, [isEmpty, onContentStateChange])
|
||||
|
||||
const renderBlock = useCallback((block: typeof START_BLOCKS[number]) => (
|
||||
<Tooltip
|
||||
key={block.type}
|
||||
position="right"
|
||||
popupClassName="w-[224px] rounded-xl"
|
||||
needsDelay={false}
|
||||
popupContent={(
|
||||
<Popover key={block.type}>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(block.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className="mr-2 shrink-0"
|
||||
type={block.type}
|
||||
/>
|
||||
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
|
||||
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
|
||||
{block.type === BlockEnumValues.Start && (
|
||||
<span className="ml-2 shrink-0 text-text-quaternary system-xs-regular">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="right" popupClassName="w-[224px] rounded-xl px-3 py-2 text-left">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
@ -96,24 +113,8 @@ const StartBlocks = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(block.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className="mr-2 shrink-0"
|
||||
type={block.type}
|
||||
/>
|
||||
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
|
||||
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
|
||||
{block.type === BlockEnumValues.Start && (
|
||||
<span className="ml-2 shrink-0 text-text-quaternary system-xs-regular">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
), [availableNodesMetaData, onSelect, t])
|
||||
|
||||
if (isEmpty)
|
||||
|
||||
@ -7,7 +7,7 @@ import type {
|
||||
} from '../types'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools'
|
||||
@ -128,19 +128,21 @@ const TabHeaderItem = ({
|
||||
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position="top"
|
||||
popupClassName="max-w-[200px]"
|
||||
popupContent={disabledTip}
|
||||
>
|
||||
<div
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
<Tooltip key={tab.key}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top" popupClassName="max-w-[200px]">
|
||||
{disabledTip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
@ -58,12 +58,57 @@ const ToolItem: FC<Props> = ({
|
||||
}, [theme, normalizedIcon, normalizedIconDark])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={payload.name}
|
||||
position="right"
|
||||
needsDelay={false}
|
||||
popupClassName="!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg"
|
||||
popupContent={(
|
||||
<Popover key={payload.name}>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
key={payload.name}
|
||||
data-tool-picker-item="true"
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
if (disabled)
|
||||
return
|
||||
const params: Record<string, string> = {}
|
||||
if (payload.parameters) {
|
||||
payload.parameters.forEach((item) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.Tool, {
|
||||
provider_id: provider.id,
|
||||
provider_type: provider.type,
|
||||
provider_name: provider.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
provider_icon: normalizedIcon,
|
||||
provider_icon_dark: normalizedIconDark,
|
||||
tool_name: payload.name,
|
||||
tool_label: payload.label[language],
|
||||
tool_description: payload.description[language],
|
||||
title: payload.label[language],
|
||||
is_team_authorization: provider.is_team_authorization,
|
||||
paramSchemas: payload.parameters,
|
||||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
trackEvent('tool_selected', {
|
||||
tool_name: payload.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="right" popupClassName="!w-[200px] !rounded-xl !border-[0.5px] !border-black/5 !p-0 !px-3 !py-2.5 !text-xs !leading-[18px] !text-gray-700 !shadow-lg">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
@ -74,52 +119,8 @@ const ToolItem: FC<Props> = ({
|
||||
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
|
||||
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={payload.name}
|
||||
data-tool-picker-item="true"
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
if (disabled)
|
||||
return
|
||||
const params: Record<string, string> = {}
|
||||
if (payload.parameters) {
|
||||
payload.parameters.forEach((item) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.Tool, {
|
||||
provider_id: provider.id,
|
||||
provider_type: provider.type,
|
||||
provider_name: provider.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
provider_icon: normalizedIcon,
|
||||
provider_icon_dark: normalizedIconDark,
|
||||
tool_name: payload.name,
|
||||
tool_label: payload.label[language],
|
||||
tool_description: payload.description[language],
|
||||
title: payload.label[language],
|
||||
is_team_authorization: provider.is_team_authorization,
|
||||
paramSchemas: payload.parameters,
|
||||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
trackEvent('tool_selected', {
|
||||
tool_name: payload.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(ToolItem)
|
||||
|
||||
@ -4,7 +4,7 @@ import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import BlockIcon from '../../block-icon'
|
||||
@ -29,12 +29,51 @@ const TriggerPluginActionItem: FC<Props> = ({
|
||||
const language = useGetLanguage()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={payload.name}
|
||||
position="right"
|
||||
needsDelay={false}
|
||||
popupClassName="!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg"
|
||||
popupContent={(
|
||||
<Popover key={payload.name}>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
key={payload.name}
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
if (disabled)
|
||||
return
|
||||
const params: Record<string, string> = {}
|
||||
if (payload.parameters) {
|
||||
payload.parameters.forEach((item: any) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.TriggerPlugin, {
|
||||
plugin_id: provider.plugin_id,
|
||||
provider_id: provider.name,
|
||||
provider_type: provider.type as string,
|
||||
provider_name: provider.name,
|
||||
event_name: payload.name,
|
||||
event_label: payload.label[language],
|
||||
event_description: payload.description[language],
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
title: payload.label[language],
|
||||
is_team_authorization: provider.is_team_authorization,
|
||||
output_schema: payload.output_schema || {},
|
||||
paramSchemas: payload.parameters,
|
||||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="right" popupClassName="!w-[224px] !rounded-xl !border-[0.5px] !border-black/5 !p-0 !px-3 !py-2.5 !text-xs !leading-[18px] !text-gray-700 !shadow-lg">
|
||||
<div>
|
||||
<BlockIcon
|
||||
size="md"
|
||||
@ -45,46 +84,8 @@ const TriggerPluginActionItem: FC<Props> = ({
|
||||
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
|
||||
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={payload.name}
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
if (disabled)
|
||||
return
|
||||
const params: Record<string, string> = {}
|
||||
if (payload.parameters) {
|
||||
payload.parameters.forEach((item: any) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.TriggerPlugin, {
|
||||
plugin_id: provider.plugin_id,
|
||||
provider_id: provider.name,
|
||||
provider_type: provider.type as string,
|
||||
provider_name: provider.name,
|
||||
event_name: payload.name,
|
||||
event_label: payload.label[language],
|
||||
event_description: payload.description[language],
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
title: payload.label[language],
|
||||
is_team_authorization: provider.is_team_authorization,
|
||||
output_schema: payload.output_schema || {},
|
||||
paramSchemas: payload.parameters,
|
||||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(TriggerPluginActionItem)
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
import { CollaborationManager } from '../collaboration-manager'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const socket = {
|
||||
connected: false,
|
||||
emit: vi.fn(),
|
||||
id: 'socket-1',
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
connectSocket: vi.fn(() => socket),
|
||||
disconnectSocket: vi.fn(),
|
||||
emitWithAuthGuard: vi.fn(),
|
||||
getSocket: vi.fn(() => socket),
|
||||
initLoro: vi.fn<() => Promise<void>>(),
|
||||
isConnected: vi.fn(() => false),
|
||||
LoroDoc: vi.fn(function MockLoroDoc(this: { getMap: ReturnType<typeof vi.fn> }) {
|
||||
this.getMap = vi.fn(() => ({
|
||||
get: vi.fn(),
|
||||
keys: vi.fn(() => []),
|
||||
subscribe: vi.fn(),
|
||||
values: vi.fn(() => []),
|
||||
}))
|
||||
}),
|
||||
providerDestroy: vi.fn(),
|
||||
UndoManager: vi.fn(function MockUndoManager(this: { canRedo: ReturnType<typeof vi.fn>, canUndo: ReturnType<typeof vi.fn> }) {
|
||||
this.canRedo = vi.fn(() => false)
|
||||
this.canUndo = vi.fn(() => false)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../loro-web', () => ({
|
||||
default: () => mocks.initLoro(),
|
||||
LoroDoc: mocks.LoroDoc,
|
||||
LoroList: class {},
|
||||
LoroMap: class {},
|
||||
UndoManager: mocks.UndoManager,
|
||||
}))
|
||||
|
||||
vi.mock('../crdt-provider', () => ({
|
||||
CRDTProvider: vi.fn(function MockCRDTProvider(this: { destroy: typeof mocks.providerDestroy }) {
|
||||
this.destroy = mocks.providerDestroy
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../websocket-manager', () => ({
|
||||
emitWithAuthGuard: (...args: unknown[]) => mocks.emitWithAuthGuard(...args),
|
||||
webSocketClient: {
|
||||
connect: mocks.connectSocket,
|
||||
disconnect: (appId?: string) => mocks.disconnectSocket(appId),
|
||||
getSocket: mocks.getSocket,
|
||||
isConnected: mocks.isConnected,
|
||||
},
|
||||
}))
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
activeConnections: Set<string>
|
||||
connectionInitializationPromise: Promise<void> | null
|
||||
currentAppId: string | null
|
||||
doc: unknown
|
||||
}
|
||||
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
}
|
||||
}
|
||||
|
||||
// Covers Loro wasm bootstrapping during workflow collaboration startup.
|
||||
describe('CollaborationManager connect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.initLoro.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('should rollback connection state when Loro initialization fails', async () => {
|
||||
const manager = new CollaborationManager()
|
||||
const internals = getManagerInternals(manager)
|
||||
const initError = new Error('init failed')
|
||||
mocks.initLoro.mockRejectedValueOnce(initError)
|
||||
|
||||
await expect(manager.connect('app-1')).rejects.toThrow(initError)
|
||||
|
||||
expect(mocks.connectSocket).toHaveBeenCalledWith('app-1')
|
||||
expect(mocks.disconnectSocket).toHaveBeenCalledWith('app-1')
|
||||
expect(mocks.LoroDoc).not.toHaveBeenCalled()
|
||||
expect(internals.currentAppId).toBeNull()
|
||||
expect(internals.doc).toBeNull()
|
||||
expect(internals.connectionInitializationPromise).toBeNull()
|
||||
expect(internals.activeConnections.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should reuse the in-flight initialization for concurrent callers', async () => {
|
||||
const manager = new CollaborationManager()
|
||||
const deferred = createDeferred()
|
||||
mocks.initLoro.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const firstConnect = manager.connect('app-1')
|
||||
const secondConnect = manager.connect('app-1')
|
||||
|
||||
expect(mocks.initLoro).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.connectSocket).toHaveBeenCalledTimes(1)
|
||||
|
||||
deferred.resolve()
|
||||
|
||||
await expect(firstConnect).resolves.toMatch(/[a-z0-9]{9}/)
|
||||
await expect(secondConnect).resolves.toMatch(/[a-z0-9]{9}/)
|
||||
expect(mocks.LoroDoc).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.UndoManager).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,8 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type { LoroDocInstance, LoroMapInstance } from '../loro-web'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { LoroDoc } from 'loro-crdt'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { CollaborationManager } from '../collaboration-manager'
|
||||
import initLoro, { LoroDoc } from '../loro-web'
|
||||
|
||||
const NODE_ID = 'node-1'
|
||||
const LLM_NODE_ID = 'llm-node'
|
||||
@ -74,9 +74,9 @@ type ParameterExtractorNodeData = {
|
||||
}
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: LoroDoc
|
||||
nodesMap: LoroMap
|
||||
edgesMap: LoroMap
|
||||
doc: LoroDocInstance
|
||||
nodesMap: LoroMapInstance
|
||||
edgesMap: LoroMapInstance
|
||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||
}
|
||||
|
||||
@ -159,7 +159,7 @@ const createParameterExtractorNode = (parameters: ParameterItem[]): Node<Paramet
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const getManager = (doc: LoroDoc) => {
|
||||
const getManager = (doc: LoroDocInstance) => {
|
||||
const manager = new CollaborationManager()
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.doc = doc
|
||||
@ -177,6 +177,10 @@ const syncNodes = (manager: CollaborationManager, previous: Node[], next: Node[]
|
||||
|
||||
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
|
||||
|
||||
beforeAll(async () => {
|
||||
await initLoro()
|
||||
})
|
||||
|
||||
describe('Loro merge behavior smoke test', () => {
|
||||
it('inspects concurrent edits after merge', () => {
|
||||
const docA = new LoroDoc()
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type { LoroDocInstance, LoroMapInstance } from '../loro-web'
|
||||
import type {
|
||||
NodePanelPresenceMap,
|
||||
NodePanelPresenceUser,
|
||||
} from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types'
|
||||
import { LoroDoc } from 'loro-crdt'
|
||||
import { Position } from 'reactflow'
|
||||
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import initLoro, { LoroDoc } from '../loro-web'
|
||||
|
||||
const NODE_ID = '1760342909316'
|
||||
|
||||
@ -88,12 +88,12 @@ type LLMNodeDataWithUnknownTemplate = Omit<LLMNodeData, 'prompt_template'> & {
|
||||
prompt_template: unknown
|
||||
}
|
||||
|
||||
type ManagerDoc = LoroDoc | { commit: () => void }
|
||||
type ManagerDoc = LoroDocInstance | { commit: () => void }
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: ManagerDoc
|
||||
nodesMap: LoroMap
|
||||
edgesMap: LoroMap
|
||||
nodesMap: LoroMapInstance
|
||||
edgesMap: LoroMapInstance
|
||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||
syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void
|
||||
applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void
|
||||
@ -246,6 +246,10 @@ const setupManager = (): { manager: CollaborationManager, internals: Collaborati
|
||||
return { manager, internals }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initLoro()
|
||||
})
|
||||
|
||||
describe('CollaborationManager syncNodes', () => {
|
||||
let manager: CollaborationManager
|
||||
let internals: CollaborationManagerInternals
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { Value } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
@ -16,11 +15,18 @@ import type {
|
||||
RestoreIntentData,
|
||||
RestoreRequestData,
|
||||
} from '../types/collaboration'
|
||||
import type {
|
||||
LoroDocInstance,
|
||||
LoroListInstance,
|
||||
LoroMapInstance,
|
||||
UndoManagerInstance,
|
||||
Value,
|
||||
} from './loro-web'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt'
|
||||
import { CRDTProvider } from './crdt-provider'
|
||||
import { EventEmitter } from './event-emitter'
|
||||
import initLoro, { LoroDoc, LoroList, LoroMap, UndoManager } from './loro-web'
|
||||
import { emitWithAuthGuard, webSocketClient } from './websocket-manager'
|
||||
|
||||
type NodePanelPresenceEventData = {
|
||||
@ -105,12 +111,28 @@ const SET_NODES_ANOMALY_LOG_LIMIT = 100
|
||||
|
||||
const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value
|
||||
const toLoroRecord = (value: unknown): Record<string, Value> => cloneDeep(value) as Record<string, Value>
|
||||
|
||||
let loroInitializationPromise: Promise<void> | null = null
|
||||
|
||||
const ensureLoroReady = async (): Promise<void> => {
|
||||
if (!loroInitializationPromise) {
|
||||
loroInitializationPromise = Promise.resolve(initLoro())
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
loroInitializationPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
await loroInitializationPromise
|
||||
}
|
||||
|
||||
export class CollaborationManager {
|
||||
private doc: LoroDoc | null = null
|
||||
private undoManager: UndoManager | null = null
|
||||
private doc: LoroDocInstance | null = null
|
||||
private undoManager: UndoManagerInstance | null = null
|
||||
private provider: CRDTProvider | null = null
|
||||
private nodesMap: LoroMap<Record<string, Value>> | null = null
|
||||
private edgesMap: LoroMap<Record<string, Value>> | null = null
|
||||
private nodesMap: LoroMapInstance<Record<string, Value>> | null = null
|
||||
private edgesMap: LoroMapInstance<Record<string, Value>> | null = null
|
||||
private eventEmitter = new EventEmitter()
|
||||
private currentAppId: string | null = null
|
||||
private reactFlowStore: ReactFlowStore | null = null
|
||||
@ -127,6 +149,7 @@ export class CollaborationManager {
|
||||
private graphViewActive: boolean | null = null
|
||||
private graphImportLogs: GraphImportLogEntry[] = []
|
||||
private setNodesAnomalyLogs: SetNodesAnomalyLogEntry[] = []
|
||||
private connectionInitializationPromise: Promise<void> | null = null
|
||||
private pendingImportLog: {
|
||||
timestamp: number
|
||||
sources: Set<'nodes' | 'edges'>
|
||||
@ -191,13 +214,13 @@ export class CollaborationManager {
|
||||
emitWithAuthGuard(socket, 'graph_event', payload, { onUnauthorized: this.handleSessionUnauthorized })
|
||||
}
|
||||
|
||||
private getNodeContainer(nodeId: string): LoroMap<Record<string, Value>> {
|
||||
private getNodeContainer(nodeId: string): LoroMapInstance<Record<string, Value>> {
|
||||
if (!this.nodesMap)
|
||||
throw new Error('Nodes map not initialized')
|
||||
|
||||
let container = this.nodesMap.get(nodeId) as unknown
|
||||
|
||||
const isMapContainer = (value: unknown): value is LoroMap<Record<string, Value>> & LoroContainer => {
|
||||
const isMapContainer = (value: unknown): value is LoroMapInstance<Record<string, Value>> & LoroContainer => {
|
||||
return !!value && typeof (value as LoroContainer).kind === 'function' && (value as LoroContainer).kind?.() === 'Map'
|
||||
}
|
||||
|
||||
@ -207,27 +230,27 @@ export class CollaborationManager {
|
||||
const attached = (newContainer as LoroContainer).getAttached?.() ?? newContainer
|
||||
container = attached
|
||||
if (previousValue && typeof previousValue === 'object')
|
||||
this.populateNodeContainer(container as LoroMap<Record<string, Value>>, previousValue as Node)
|
||||
this.populateNodeContainer(container as LoroMapInstance<Record<string, Value>>, previousValue as Node)
|
||||
}
|
||||
else {
|
||||
const attached = (container as LoroContainer).getAttached?.() ?? container
|
||||
container = attached
|
||||
}
|
||||
|
||||
return container as LoroMap<Record<string, Value>>
|
||||
return container as LoroMapInstance<Record<string, Value>>
|
||||
}
|
||||
|
||||
private ensureDataContainer(nodeContainer: LoroMap<Record<string, Value>>): LoroMap<Record<string, Value>> {
|
||||
private ensureDataContainer(nodeContainer: LoroMapInstance<Record<string, Value>>): LoroMapInstance<Record<string, Value>> {
|
||||
let dataContainer = nodeContainer.get('data') as unknown
|
||||
|
||||
if (!dataContainer || typeof (dataContainer as LoroContainer).kind !== 'function' || (dataContainer as LoroContainer).kind?.() !== 'Map')
|
||||
dataContainer = nodeContainer.setContainer('data', new LoroMap())
|
||||
|
||||
const attached = (dataContainer as LoroContainer).getAttached?.() ?? dataContainer
|
||||
return attached as LoroMap<Record<string, Value>>
|
||||
return attached as LoroMapInstance<Record<string, Value>>
|
||||
}
|
||||
|
||||
private ensureList(nodeContainer: LoroMap<Record<string, Value>>, key: string): LoroList<unknown> {
|
||||
private ensureList(nodeContainer: LoroMapInstance<Record<string, Value>>, key: string): LoroListInstance<unknown> {
|
||||
const dataContainer = this.ensureDataContainer(nodeContainer)
|
||||
let list = dataContainer.get(key) as unknown
|
||||
|
||||
@ -235,7 +258,7 @@ export class CollaborationManager {
|
||||
list = dataContainer.setContainer(key, new LoroList())
|
||||
|
||||
const attached = (list as LoroContainer).getAttached?.() ?? list
|
||||
return attached as LoroList<unknown>
|
||||
return attached as LoroListInstance<unknown>
|
||||
}
|
||||
|
||||
private exportNode(nodeId: string): Node {
|
||||
@ -247,7 +270,7 @@ export class CollaborationManager {
|
||||
}
|
||||
}
|
||||
|
||||
private populateNodeContainer(container: LoroMap<Record<string, Value>>, node: Node): void {
|
||||
private populateNodeContainer(container: LoroMapInstance<Record<string, Value>>, node: Node): void {
|
||||
const listFields = new Set(['variables', 'prompt_template', 'parameters'])
|
||||
container.set('id', node.id)
|
||||
container.set('type', node.type)
|
||||
@ -325,7 +348,7 @@ export class CollaborationManager {
|
||||
return (syncDataAllowList.has(key) || !key.startsWith('_')) && key !== 'selected'
|
||||
}
|
||||
|
||||
private syncList(nodeContainer: LoroMap<Record<string, Value>>, key: string, desired: Array<unknown>): void {
|
||||
private syncList(nodeContainer: LoroMapInstance<Record<string, Value>>, key: string, desired: Array<unknown>): void {
|
||||
const list = this.ensureList(nodeContainer, key)
|
||||
const current = list.toJSON() as Array<unknown>
|
||||
const target = Array.isArray(desired) ? desired : []
|
||||
@ -471,71 +494,107 @@ export class CollaborationManager {
|
||||
if (reactFlowStore)
|
||||
this.reactFlowStore = reactFlowStore
|
||||
|
||||
if (this.connectionInitializationPromise) {
|
||||
try {
|
||||
await this.connectionInitializationPromise
|
||||
return connectionId
|
||||
}
|
||||
catch (error) {
|
||||
this.activeConnections.delete(connectionId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const initializationPromise = this.initializeConnection(appId)
|
||||
this.connectionInitializationPromise = initializationPromise
|
||||
|
||||
try {
|
||||
await initializationPromise
|
||||
}
|
||||
catch (error) {
|
||||
this.activeConnections.delete(connectionId)
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
if (this.connectionInitializationPromise === initializationPromise)
|
||||
this.connectionInitializationPromise = null
|
||||
}
|
||||
|
||||
return connectionId
|
||||
}
|
||||
|
||||
private async initializeConnection(appId: string): Promise<void> {
|
||||
const socket = webSocketClient.connect(appId)
|
||||
|
||||
// Setup event listeners BEFORE any other operations
|
||||
this.setupSocketEventListeners(socket)
|
||||
|
||||
this.doc = new LoroDoc()
|
||||
this.nodesMap = this.doc.getMap('nodes') as LoroMap<Record<string, Value>>
|
||||
this.edgesMap = this.doc.getMap('edges') as LoroMap<Record<string, Value>>
|
||||
try {
|
||||
await ensureLoroReady()
|
||||
|
||||
// Initialize UndoManager for collaborative undo/redo
|
||||
this.undoManager = new UndoManager(this.doc, {
|
||||
maxUndoSteps: 100,
|
||||
mergeInterval: 500, // Merge operations within 500ms
|
||||
excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations
|
||||
onPush: (_isUndo, _range, _event) => {
|
||||
// Store current selection state when an operation is pushed
|
||||
const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data?.selected)
|
||||
this.doc = new LoroDoc()
|
||||
this.nodesMap = this.doc.getMap('nodes') as LoroMapInstance<Record<string, Value>>
|
||||
this.edgesMap = this.doc.getMap('edges') as LoroMapInstance<Record<string, Value>>
|
||||
|
||||
// Emit event to update UI button states when new operation is pushed
|
||||
setTimeout(() => {
|
||||
this.eventEmitter.emit('undoRedoStateChange', {
|
||||
canUndo: this.undoManager?.canUndo() || false,
|
||||
canRedo: this.undoManager?.canRedo() || false,
|
||||
})
|
||||
}, 0)
|
||||
// Initialize UndoManager for collaborative undo/redo
|
||||
this.undoManager = new UndoManager(this.doc, {
|
||||
maxUndoSteps: 100,
|
||||
mergeInterval: 500, // Merge operations within 500ms
|
||||
excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations
|
||||
onPush: (_isUndo, _range, _event) => {
|
||||
// Store current selection state when an operation is pushed
|
||||
const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data?.selected)
|
||||
|
||||
return {
|
||||
value: {
|
||||
selectedNodeId: selectedNode?.id || null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
cursors: [],
|
||||
}
|
||||
},
|
||||
onPop: (_isUndo, value, _counterRange) => {
|
||||
// Restore selection state when undoing/redoing
|
||||
if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) {
|
||||
const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId
|
||||
if (selectedNodeId) {
|
||||
const state = this.reactFlowStore.getState()
|
||||
const { setNodes } = state
|
||||
const nodes = state.getNodes()
|
||||
const newNodes = nodes.map((n: Node) => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
selected: n.id === selectedNodeId,
|
||||
},
|
||||
}))
|
||||
this.captureSetNodesAnomaly(nodes, newNodes, 'reactflow-native:undo-redo-selection-restore')
|
||||
setNodes(newNodes)
|
||||
// Emit event to update UI button states when new operation is pushed
|
||||
setTimeout(() => {
|
||||
this.eventEmitter.emit('undoRedoStateChange', {
|
||||
canUndo: this.undoManager?.canUndo() || false,
|
||||
canRedo: this.undoManager?.canRedo() || false,
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
value: {
|
||||
selectedNodeId: selectedNode?.id || null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
cursors: [],
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
onPop: (_isUndo, value, _counterRange) => {
|
||||
// Restore selection state when undoing/redoing
|
||||
if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) {
|
||||
const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId
|
||||
if (selectedNodeId) {
|
||||
const state = this.reactFlowStore.getState()
|
||||
const { setNodes } = state
|
||||
const nodes = state.getNodes()
|
||||
const newNodes = nodes.map((n: Node) => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
selected: n.id === selectedNodeId,
|
||||
},
|
||||
}))
|
||||
this.captureSetNodesAnomaly(nodes, newNodes, 'reactflow-native:undo-redo-selection-restore')
|
||||
setNodes(newNodes)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
this.provider = new CRDTProvider(socket, this.doc, this.handleSessionUnauthorized)
|
||||
this.provider = new CRDTProvider(socket, this.doc, this.handleSessionUnauthorized)
|
||||
|
||||
this.setupSubscriptions()
|
||||
this.setupSubscriptions()
|
||||
|
||||
// Force user_connect if already connected
|
||||
if (socket.connected)
|
||||
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId }, { onUnauthorized: this.handleSessionUnauthorized })
|
||||
|
||||
return connectionId
|
||||
// Force user_connect if already connected
|
||||
if (socket.connected)
|
||||
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId }, { onUnauthorized: this.handleSessionUnauthorized })
|
||||
}
|
||||
catch (error) {
|
||||
this.forceDisconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
disconnect = (connectionId?: string): void => {
|
||||
@ -564,6 +623,7 @@ export class CollaborationManager {
|
||||
this.onlineUsers = []
|
||||
this.isUndoRedoInProgress = false
|
||||
this.rejoinInProgress = false
|
||||
this.connectionInitializationPromise = null
|
||||
this.clearGraphImportLog()
|
||||
|
||||
// Only reset leader status when actually disconnecting
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import type { LoroDocInstance } from './loro-web'
|
||||
import { emitWithAuthGuard } from './websocket-manager'
|
||||
|
||||
export class CRDTProvider {
|
||||
private doc: LoroDoc
|
||||
private doc: LoroDocInstance
|
||||
private socket: Socket
|
||||
private onUnauthorized?: () => void
|
||||
|
||||
constructor(socket: Socket, doc: LoroDoc, onUnauthorized?: () => void) {
|
||||
constructor(socket: Socket, doc: LoroDocInstance, onUnauthorized?: () => void) {
|
||||
this.socket = socket
|
||||
this.doc = doc
|
||||
this.onUnauthorized = onUnauthorized
|
||||
|
||||
43
web/app/components/workflow/collaboration/core/loro-web.ts
Normal file
43
web/app/components/workflow/collaboration/core/loro-web.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type {
|
||||
Container,
|
||||
LoroDoc as LoroDocShape,
|
||||
LoroList as LoroListShape,
|
||||
LoroMap as LoroMapShape,
|
||||
UndoManager as UndoManagerShape,
|
||||
Value,
|
||||
} from 'loro-crdt'
|
||||
import {
|
||||
LoroDoc as NodeLoroDoc,
|
||||
LoroList as NodeLoroList,
|
||||
LoroMap as NodeLoroMap,
|
||||
UndoManager as NodeUndoManager,
|
||||
} from 'loro-crdt'
|
||||
// eslint-disable-next-line antfu/no-import-node-modules-by-path -- loro-crdt does not export a browser-ready wasm entry, so collaboration must target the web bundle file directly.
|
||||
import initWebLoro, {
|
||||
LoroDoc as WebLoroDoc,
|
||||
LoroList as WebLoroList,
|
||||
LoroMap as WebLoroMap,
|
||||
UndoManager as WebUndoManager,
|
||||
} from '../../../../../node_modules/loro-crdt/web/loro_wasm.js'
|
||||
|
||||
const shouldUseWebLoro = typeof window !== 'undefined' && !import.meta.env?.VITEST
|
||||
|
||||
export type LoroDocInstance<T extends Record<string, Container> = Record<string, Container>> = LoroDocShape<T>
|
||||
export type LoroListInstance<T = unknown> = LoroListShape<T>
|
||||
export type LoroMapInstance<T extends Record<string, unknown> = Record<string, unknown>> = LoroMapShape<T>
|
||||
export type UndoManagerInstance = UndoManagerShape
|
||||
export type { Value }
|
||||
|
||||
export const LoroDoc = (shouldUseWebLoro ? WebLoroDoc : NodeLoroDoc) as typeof NodeLoroDoc
|
||||
export const LoroList = (shouldUseWebLoro ? WebLoroList : NodeLoroList) as typeof NodeLoroList
|
||||
export const LoroMap = (shouldUseWebLoro ? WebLoroMap : NodeLoroMap) as typeof NodeLoroMap
|
||||
export const UndoManager = (shouldUseWebLoro ? WebUndoManager : NodeUndoManager) as typeof NodeUndoManager
|
||||
|
||||
const initLoro = async (): Promise<void> => {
|
||||
if (!shouldUseWebLoro)
|
||||
return
|
||||
|
||||
await initWebLoro()
|
||||
}
|
||||
|
||||
export default initLoro
|
||||
@ -0,0 +1,105 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useCollaboration } from '../use-collaboration'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connect: vi.fn<(appId: string) => Promise<string>>().mockResolvedValue('connection-1'),
|
||||
disconnect: vi.fn<(connectionId?: string) => void>(),
|
||||
onStateChange: vi.fn(() => vi.fn()),
|
||||
onCursorUpdate: vi.fn(() => vi.fn()),
|
||||
onOnlineUsersUpdate: vi.fn(() => vi.fn()),
|
||||
onNodePanelPresenceUpdate: vi.fn(() => vi.fn()),
|
||||
onLeaderChange: vi.fn(() => vi.fn()),
|
||||
setReactFlowStore: vi.fn<(store: unknown) => void>(),
|
||||
isConnected: vi.fn<() => boolean>(() => false),
|
||||
getLeaderId: vi.fn<() => string | null>(() => null),
|
||||
emitCursorMove: vi.fn<(position: unknown) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => T) => selector({
|
||||
systemFeatures: {
|
||||
enable_collaboration_mode: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
connect: (appId: string) => mocks.connect(appId),
|
||||
disconnect: (connectionId?: string) => mocks.disconnect(connectionId),
|
||||
onStateChange: () => mocks.onStateChange(),
|
||||
onCursorUpdate: () => mocks.onCursorUpdate(),
|
||||
onOnlineUsersUpdate: () => mocks.onOnlineUsersUpdate(),
|
||||
onNodePanelPresenceUpdate: () => mocks.onNodePanelPresenceUpdate(),
|
||||
onLeaderChange: () => mocks.onLeaderChange(),
|
||||
setReactFlowStore: (store: unknown) => mocks.setReactFlowStore(store),
|
||||
isConnected: () => mocks.isConnected(),
|
||||
getLeaderId: () => mocks.getLeaderId(),
|
||||
emitCursorMove: (position: unknown) => mocks.emitCursorMove(position),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useCollaboration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should skip collaboration setup when disabled by the caller', async () => {
|
||||
const reactFlowStore = {
|
||||
getState: vi.fn(),
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useCollaboration('app-1', reactFlowStore as never, false))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.setReactFlowStore).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
expect(mocks.connect).not.toHaveBeenCalled()
|
||||
expect(result.current.isEnabled).toBe(false)
|
||||
expect(result.current.onlineUsers).toEqual([])
|
||||
expect(result.current.nodePanelPresence).toEqual({})
|
||||
})
|
||||
|
||||
it('should connect and attach the react flow store when collaboration is enabled', async () => {
|
||||
const reactFlowStore = {
|
||||
getState: vi.fn(),
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useCollaboration('app-1', reactFlowStore as never, true))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.connect).toHaveBeenCalledWith('app-1')
|
||||
expect(mocks.setReactFlowStore).toHaveBeenCalledWith(reactFlowStore)
|
||||
})
|
||||
|
||||
expect(result.current.isEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should disconnect and clear the react flow store when collaboration gets disabled', async () => {
|
||||
const reactFlowStore = {
|
||||
getState: vi.fn(),
|
||||
}
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useCollaboration('app-1', reactFlowStore as never, enabled),
|
||||
{
|
||||
initialProps: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.connect).toHaveBeenCalledWith('app-1')
|
||||
expect(mocks.setReactFlowStore).toHaveBeenCalledWith(reactFlowStore)
|
||||
})
|
||||
|
||||
rerender({ enabled: false })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.disconnect).toHaveBeenCalledWith('connection-1')
|
||||
expect(mocks.setReactFlowStore).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -28,15 +28,17 @@ const initialState: CollaborationViewState = {
|
||||
isLeader: false,
|
||||
}
|
||||
|
||||
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) {
|
||||
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore, enabled = true) {
|
||||
const [state, setState] = useState<CollaborationViewState>(initialState)
|
||||
|
||||
const cursorServiceRef = useRef<CursorService | null>(null)
|
||||
const lastDisconnectReasonRef = useRef<string | null>(null)
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
const isCollaborationFeatureEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
const isCollaborationEnabled = isCollaborationFeatureEnabled && enabled
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled) {
|
||||
lastDisconnectReasonRef.current = null
|
||||
Promise.resolve().then(() => {
|
||||
setState(initialState)
|
||||
})
|
||||
@ -109,14 +111,16 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore)
|
||||
}, [appId, isCollaborationEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!reactFlowStore)
|
||||
if (!isCollaborationEnabled || !reactFlowStore) {
|
||||
collaborationManager.setReactFlowStore(null)
|
||||
return
|
||||
}
|
||||
|
||||
collaborationManager.setReactFlowStore(reactFlowStore)
|
||||
return () => {
|
||||
collaborationManager.setReactFlowStore(null)
|
||||
}
|
||||
}, [reactFlowStore])
|
||||
}, [isCollaborationEnabled, reactFlowStore])
|
||||
|
||||
const prevIsConnected = useRef(false)
|
||||
useEffect(() => {
|
||||
|
||||
@ -0,0 +1,614 @@
|
||||
import { SkillCollaborationManager } from '../skill-collaboration-manager'
|
||||
|
||||
type SocketHandler = (...args: unknown[]) => void
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
class MockLoroText {
|
||||
private readonly getValue: () => string
|
||||
private readonly setValue: (nextValue: string) => void
|
||||
|
||||
constructor(getValue: () => string, setValue: (nextValue: string) => void) {
|
||||
this.getValue = getValue
|
||||
this.setValue = setValue
|
||||
}
|
||||
|
||||
update(nextValue: string) {
|
||||
this.setValue(nextValue)
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.getValue()
|
||||
}
|
||||
}
|
||||
|
||||
class MockLoroDoc {
|
||||
private value = ''
|
||||
private subscribers = new Set<(event: { by?: string }) => void>()
|
||||
static nextImportError: Error | null = null
|
||||
|
||||
getText() {
|
||||
return new MockLoroText(
|
||||
() => this.value,
|
||||
(nextValue: string) => {
|
||||
this.value = nextValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
subscribe(callback: (event: { by?: string }) => void) {
|
||||
this.subscribers.add(callback)
|
||||
}
|
||||
|
||||
commit() {
|
||||
this.subscribers.forEach(callback => callback({ by: 'local' }))
|
||||
}
|
||||
|
||||
export() {
|
||||
return encoder.encode(this.value)
|
||||
}
|
||||
|
||||
import(data: Uint8Array) {
|
||||
if (MockLoroDoc.nextImportError) {
|
||||
const error = MockLoroDoc.nextImportError
|
||||
MockLoroDoc.nextImportError = null
|
||||
throw error
|
||||
}
|
||||
|
||||
this.value = decoder.decode(data)
|
||||
this.subscribers.forEach(callback => callback({ by: 'remote' }))
|
||||
}
|
||||
}
|
||||
|
||||
type MockSocket = {
|
||||
connected: boolean
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
id: string
|
||||
off: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const handlerStore = new Map<string, Map<string, SocketHandler>>()
|
||||
const socketStore = new Map<string, MockSocket>()
|
||||
|
||||
const getOrCreateSocket = (appId: string): MockSocket => {
|
||||
const existing = socketStore.get(appId)
|
||||
if (existing)
|
||||
return existing
|
||||
|
||||
const handlers = new Map<string, SocketHandler>()
|
||||
const socket: MockSocket = {
|
||||
connected: false,
|
||||
emit: vi.fn(),
|
||||
id: `socket-${appId}`,
|
||||
off: vi.fn((event: string, handler?: SocketHandler) => {
|
||||
if (!handler) {
|
||||
handlers.delete(event)
|
||||
return
|
||||
}
|
||||
|
||||
const current = handlers.get(event)
|
||||
if (current === handler)
|
||||
handlers.delete(event)
|
||||
}),
|
||||
on: vi.fn((event: string, handler: SocketHandler) => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
}
|
||||
|
||||
socketStore.set(appId, socket)
|
||||
handlerStore.set(appId, handlers)
|
||||
return socket
|
||||
}
|
||||
|
||||
return {
|
||||
MockLoroDoc,
|
||||
connectSocket: vi.fn((appId: string) => getOrCreateSocket(appId)),
|
||||
emitSocketEvent: (appId: string, event: string, ...args: unknown[]) => {
|
||||
const handler = handlerStore.get(appId)?.get(event)
|
||||
handler?.(...args)
|
||||
},
|
||||
emitWithAuthGuard: vi.fn(),
|
||||
getSocket: (appId: string) => getOrCreateSocket(appId),
|
||||
reset: () => {
|
||||
socketStore.clear()
|
||||
handlerStore.clear()
|
||||
MockLoroDoc.nextImportError = null
|
||||
},
|
||||
setNextImportError: (error: Error) => {
|
||||
MockLoroDoc.nextImportError = error
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('loro-crdt', () => ({
|
||||
LoroDoc: mocks.MockLoroDoc,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
|
||||
emitWithAuthGuard: (...args: Parameters<typeof mocks.emitWithAuthGuard>) => mocks.emitWithAuthGuard(...args),
|
||||
webSocketClient: {
|
||||
connect: (appId: string) => mocks.connectSocket(appId),
|
||||
},
|
||||
}))
|
||||
|
||||
const decodePayload = (data: Uint8Array) => new TextDecoder().decode(data)
|
||||
|
||||
// Scenario: manager-level collaboration state should stay correct across open/close, socket updates, and reconnects.
|
||||
describe('SkillCollaborationManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.reset()
|
||||
})
|
||||
|
||||
// Scenario: lifecycle guards and ref-counted close should avoid leaking state.
|
||||
describe('Lifecycle', () => {
|
||||
it('should ignore invalid open and close requests', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
|
||||
// Act
|
||||
manager.openFile('', 'file-1', 'alpha')
|
||||
manager.openFile('app-1', '', 'alpha')
|
||||
manager.closeFile('')
|
||||
|
||||
// Assert
|
||||
expect(mocks.connectSocket).not.toHaveBeenCalled()
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep state until the last open handle is closed', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
|
||||
// Act
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
manager.openFile('app-1', 'file-1', 'beta')
|
||||
manager.closeFile('file-1')
|
||||
|
||||
// Assert
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(true)
|
||||
expect(manager.getText('file-1')).toBe('alpha')
|
||||
})
|
||||
|
||||
it('should release file state after the final close and allow a clean reopen', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
|
||||
// Act
|
||||
manager.closeFile('file-1')
|
||||
|
||||
// Assert
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(false)
|
||||
expect(manager.getText('file-1')).toBeNull()
|
||||
|
||||
// Act
|
||||
manager.openFile('app-1', 'file-1', 'beta')
|
||||
|
||||
// Assert
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(true)
|
||||
expect(manager.getText('file-1')).toBe('beta')
|
||||
})
|
||||
|
||||
it('should clear previous app state and detach old socket listeners when switching apps', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
const savedCallback = vi.fn()
|
||||
const treeCallback = vi.fn()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
manager.onAnyFileSaved(savedCallback)
|
||||
manager.onTreeUpdate('app-1', treeCallback)
|
||||
const app1Socket = mocks.getSocket('app-1')
|
||||
|
||||
// Act
|
||||
manager.openFile('app-2', 'file-2', 'beta')
|
||||
mocks.emitSocketEvent('app-2', 'collaboration_update', {
|
||||
type: 'skill_file_saved',
|
||||
data: { file_id: 'file-2', content: 'beta' },
|
||||
})
|
||||
mocks.emitSocketEvent('app-2', 'collaboration_update', {
|
||||
type: 'skill_tree_update',
|
||||
data: { kind: 'refresh' },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(false)
|
||||
expect(manager.getText('file-1')).toBeNull()
|
||||
expect(app1Socket.off).toHaveBeenCalledTimes(4)
|
||||
expect(savedCallback).not.toHaveBeenCalled()
|
||||
expect(treeCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: local edits and remote document events should stay in sync with subscribers.
|
||||
describe('Document Sync', () => {
|
||||
it('should emit updates for local text changes and skip unchanged content', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
const socket = mocks.getSocket('app-1')
|
||||
socket.connected = true
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Act
|
||||
manager.updateText('missing-file', 'ignored')
|
||||
manager.updateText('file-1', 'alpha')
|
||||
manager.updateText('file-1', 'beta')
|
||||
|
||||
// Assert
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledTimes(1)
|
||||
const [emittedSocket, emittedEvent, payload] = mocks.emitWithAuthGuard.mock.calls[0] as [
|
||||
typeof socket,
|
||||
string,
|
||||
{ file_id: string, update: Uint8Array },
|
||||
]
|
||||
expect(emittedSocket).toBe(socket)
|
||||
expect(emittedEvent).toBe('skill_event')
|
||||
expect(payload.file_id).toBe('file-1')
|
||||
expect(ArrayBuffer.isView(payload.update)).toBe(true)
|
||||
expect(decodePayload(payload.update)).toBe('beta')
|
||||
})
|
||||
|
||||
it('should deliver remote updates to subscribers and preserve them across snapshot replacement', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
const callback = vi.fn()
|
||||
manager.subscribe('file-1', callback)
|
||||
|
||||
// Act
|
||||
mocks.emitSocketEvent('app-1', 'skill_update', {
|
||||
file_id: 'file-1',
|
||||
update: new TextEncoder().encode('gamma'),
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'skill_update', {
|
||||
file_id: 'file-1',
|
||||
update: new TextEncoder().encode('delta'),
|
||||
is_snapshot: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(callback).toHaveBeenNthCalledWith(1, 'gamma', 'remote')
|
||||
expect(callback).toHaveBeenNthCalledWith(2, 'delta', 'remote')
|
||||
expect(manager.getText('file-1')).toBe('delta')
|
||||
})
|
||||
|
||||
it('should log import failures for malformed updates and snapshots', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const updateError = new Error('update import failed')
|
||||
const snapshotError = new Error('snapshot import failed')
|
||||
|
||||
// Act
|
||||
mocks.setNextImportError(updateError)
|
||||
mocks.emitSocketEvent('app-1', 'skill_update', {
|
||||
file_id: 'file-1',
|
||||
update: new TextEncoder().encode('gamma'),
|
||||
})
|
||||
mocks.setNextImportError(snapshotError)
|
||||
mocks.emitSocketEvent('app-1', 'skill_update', {
|
||||
file_id: 'file-1',
|
||||
update: new TextEncoder().encode('delta'),
|
||||
is_snapshot: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, 'Failed to import skill update:', updateError)
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, 'Failed to import skill snapshot:', snapshotError)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: collaboration socket events should update leader state, cursors, and sync hooks.
|
||||
describe('Socket Events', () => {
|
||||
it('should process leader, file saved, tree update, and cursor events', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
const savedCallback = vi.fn()
|
||||
const treeCallback = vi.fn()
|
||||
const cursorCallback = vi.fn()
|
||||
const unsubscribeCursor = manager.onCursorUpdate('file-1', cursorCallback)
|
||||
manager.onAnyFileSaved(savedCallback)
|
||||
manager.onTreeUpdate('app-1', treeCallback)
|
||||
|
||||
// Act
|
||||
mocks.emitSocketEvent('app-1', 'skill_status', { file_id: 'file-1', isLeader: true })
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_file_saved',
|
||||
data: { file_id: 'file-1', content: 'saved' },
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_tree_update',
|
||||
data: { kind: 'refresh' },
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_cursor',
|
||||
userId: 'user-1',
|
||||
timestamp: 123,
|
||||
data: { file_id: 'file-1', start: 1, end: 4 },
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_cursor',
|
||||
userId: 'user-1',
|
||||
timestamp: 124,
|
||||
data: { file_id: 'file-1', start: null, end: null },
|
||||
})
|
||||
unsubscribeCursor()
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_cursor',
|
||||
userId: 'user-1',
|
||||
timestamp: 125,
|
||||
data: { file_id: 'file-1', start: 2, end: 5 },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(manager.isLeader('file-1')).toBe(true)
|
||||
expect(savedCallback).toHaveBeenCalledWith({ file_id: 'file-1', content: 'saved' })
|
||||
expect(treeCallback).toHaveBeenCalledWith({ kind: 'refresh' })
|
||||
expect(cursorCallback).toHaveBeenNthCalledWith(1, {})
|
||||
expect(cursorCallback).toHaveBeenNthCalledWith(2, {
|
||||
'user-1': { userId: 'user-1', start: 1, end: 4, timestamp: 123 },
|
||||
})
|
||||
expect(cursorCallback).toHaveBeenNthCalledWith(3, {})
|
||||
expect(cursorCallback).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should invoke sync and resync handling only for leaders', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
const socket = mocks.getSocket('app-1')
|
||||
socket.connected = true
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
vi.clearAllMocks()
|
||||
const syncCallback = vi.fn()
|
||||
const unsubscribeSync = manager.onSyncRequest('file-1', syncCallback)
|
||||
|
||||
// Act
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_sync_request',
|
||||
data: { file_id: 'file-1' },
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_resync_request',
|
||||
data: { file_id: 'file-1' },
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'skill_status', { file_id: 'file-1', isLeader: true })
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_sync_request',
|
||||
data: { file_id: 'file-1' },
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_resync_request',
|
||||
data: { file_id: 'file-1' },
|
||||
})
|
||||
unsubscribeSync()
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_sync_request',
|
||||
data: { file_id: 'file-1' },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(syncCallback).toHaveBeenCalledTimes(1)
|
||||
const [emittedSocket, emittedEvent, payload] = mocks.emitWithAuthGuard.mock.calls[0] as [
|
||||
typeof socket,
|
||||
string,
|
||||
{ file_id: string, is_snapshot: boolean, update: Uint8Array },
|
||||
]
|
||||
expect(emittedSocket).toBe(socket)
|
||||
expect(emittedEvent).toBe('skill_event')
|
||||
expect(payload.file_id).toBe('file-1')
|
||||
expect(payload.is_snapshot).toBe(true)
|
||||
expect(ArrayBuffer.isView(payload.update)).toBe(true)
|
||||
})
|
||||
|
||||
it('should ignore malformed socket payloads', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
const cursorCallback = vi.fn()
|
||||
manager.onCursorUpdate('', cursorCallback)
|
||||
|
||||
// Act
|
||||
mocks.emitSocketEvent('app-1', 'skill_update', null)
|
||||
mocks.emitSocketEvent('app-1', 'skill_status', { isLeader: true })
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', { data: { file_id: 'file-1' } })
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_file_saved',
|
||||
data: {},
|
||||
})
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_cursor',
|
||||
timestamp: 123,
|
||||
data: { file_id: 'file-1', start: 1, end: 2 },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(manager.isLeader('file-1')).toBe(false)
|
||||
expect(cursorCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore cursor removals when no cursor exists yet', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
const cursorCallback = vi.fn()
|
||||
manager.onCursorUpdate('file-1', cursorCallback)
|
||||
|
||||
// Act
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_cursor',
|
||||
userId: 'user-1',
|
||||
timestamp: 123,
|
||||
data: { file_id: 'file-1', start: null, end: null },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(cursorCallback).toHaveBeenCalledTimes(1)
|
||||
expect(cursorCallback).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: public emitters should respect connection state and reconnect behavior.
|
||||
describe('Public Emitters', () => {
|
||||
it('should emit cursor, file saved, tree, sync, and active events only when connected', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
const socket = mocks.getSocket('app-1')
|
||||
manager.setActiveFile('app-1', 'file-1', true)
|
||||
|
||||
// Act
|
||||
expect(manager.requestSync('file-1')).toBe(false)
|
||||
manager.emitCursorUpdate('file-1', { start: 1, end: 2 })
|
||||
manager.emitFileSaved('file-1', 'alpha')
|
||||
manager.emitTreeUpdate('', { ignored: true })
|
||||
expect(mocks.emitWithAuthGuard).not.toHaveBeenCalled()
|
||||
|
||||
socket.connected = true
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Act
|
||||
expect(manager.requestSync('file-1')).toBe(true)
|
||||
manager.emitCursorUpdate('file-1', { start: 1, end: 2 })
|
||||
manager.emitCursorUpdate('file-1', null)
|
||||
manager.emitFileSaved('file-1', 'alpha', { author: 'bot' })
|
||||
manager.emitTreeUpdate('app-1', { kind: 'refresh' })
|
||||
manager.setActiveFile('app-1', 'file-1', false)
|
||||
|
||||
// Assert
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_sync_request',
|
||||
data: { file_id: 'file-1' },
|
||||
}),
|
||||
)
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_cursor',
|
||||
data: { file_id: 'file-1', start: 1, end: 2 },
|
||||
}),
|
||||
)
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_cursor',
|
||||
data: { file_id: 'file-1', start: null, end: null },
|
||||
}),
|
||||
)
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_file_saved',
|
||||
data: { file_id: 'file-1', content: 'alpha', metadata: { author: 'bot' } },
|
||||
}),
|
||||
)
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_tree_update',
|
||||
data: { kind: 'refresh' },
|
||||
}),
|
||||
)
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_file_active',
|
||||
data: { file_id: 'file-1', active: false },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should replay active file and pending resync requests after reconnect', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
manager.setActiveFile('app-1', 'file-1', true)
|
||||
const socket = mocks.getSocket('app-1')
|
||||
|
||||
// Act
|
||||
socket.connected = true
|
||||
mocks.emitSocketEvent('app-1', 'connect')
|
||||
vi.clearAllMocks()
|
||||
mocks.emitSocketEvent('app-1', 'connect')
|
||||
|
||||
// Assert
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledOnce()
|
||||
expect(mocks.emitWithAuthGuard).toHaveBeenCalledWith(
|
||||
socket,
|
||||
'collaboration_event',
|
||||
expect.objectContaining({
|
||||
type: 'skill_file_active',
|
||||
data: { file_id: 'file-1', active: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear cursor state on final close', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
const cursorCallback = vi.fn()
|
||||
manager.onCursorUpdate('file-1', cursorCallback)
|
||||
mocks.emitSocketEvent('app-1', 'collaboration_update', {
|
||||
type: 'skill_cursor',
|
||||
userId: 'user-1',
|
||||
timestamp: 123,
|
||||
data: { file_id: 'file-1', start: 1, end: 4 },
|
||||
})
|
||||
|
||||
// Act
|
||||
manager.closeFile('file-1')
|
||||
|
||||
// Assert
|
||||
expect(cursorCallback).toHaveBeenNthCalledWith(3, {})
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return noop unsubscribe handlers for missing files or cleared sync registrations', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
const missingSubscriberOff = manager.subscribe('missing-file', vi.fn())
|
||||
const emptyCursorOff = manager.onCursorUpdate('', vi.fn())
|
||||
const syncOff = manager.onSyncRequest('file-1', vi.fn())
|
||||
manager.openFile('app-1', 'file-1', 'alpha')
|
||||
manager.openFile('app-2', 'file-2', 'beta')
|
||||
|
||||
// Act
|
||||
missingSubscriberOff()
|
||||
emptyCursorOff()
|
||||
syncOff()
|
||||
|
||||
// Assert
|
||||
expect(manager.isFileCollaborative('file-1')).toBe(false)
|
||||
expect(manager.isFileCollaborative('file-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not emit reconnect side effects when there is no active file or pending sync', () => {
|
||||
// Arrange
|
||||
const manager = new SkillCollaborationManager()
|
||||
mocks.getSocket('app-1').connected = true
|
||||
manager.onTreeUpdate('app-1', vi.fn())
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Act
|
||||
mocks.emitSocketEvent('app-1', 'connect')
|
||||
|
||||
// Assert
|
||||
expect(mocks.emitWithAuthGuard).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -43,10 +43,11 @@ type SkillCursorInfo = {
|
||||
|
||||
type SkillCursorMap = Record<string, SkillCursorInfo>
|
||||
|
||||
class SkillCollaborationManager {
|
||||
export class SkillCollaborationManager {
|
||||
private appId: string | null = null
|
||||
private socket: Socket | null = null
|
||||
private docs = new Map<string, SkillDocEntry>()
|
||||
private openCounts = new Map<string, number>()
|
||||
private leaderByFile = new Map<string, boolean>()
|
||||
private syncHandlers = new Map<string, Set<() => void>>()
|
||||
private activeFileId: string | null = null
|
||||
@ -157,6 +158,7 @@ class SkillCollaborationManager {
|
||||
if (this.appId && this.appId !== appId) {
|
||||
this.teardownSocket()
|
||||
this.docs.clear()
|
||||
this.openCounts.clear()
|
||||
this.leaderByFile.clear()
|
||||
this.syncHandlers.clear()
|
||||
this.activeFileId = null
|
||||
@ -201,6 +203,7 @@ class SkillCollaborationManager {
|
||||
return
|
||||
|
||||
const socket = this.ensureSocket(appId)
|
||||
this.openCounts.set(fileId, (this.openCounts.get(fileId) || 0) + 1)
|
||||
|
||||
if (!this.docs.has(fileId)) {
|
||||
const doc = new LoroDoc()
|
||||
@ -227,8 +230,24 @@ class SkillCollaborationManager {
|
||||
if (!fileId)
|
||||
return
|
||||
|
||||
const currentOpenCount = this.openCounts.get(fileId)
|
||||
if (currentOpenCount && currentOpenCount > 1) {
|
||||
this.openCounts.set(fileId, currentOpenCount - 1)
|
||||
return
|
||||
}
|
||||
|
||||
this.openCounts.delete(fileId)
|
||||
|
||||
if (this.activeFileId === fileId)
|
||||
this.activeFileId = null
|
||||
|
||||
this.docs.delete(fileId)
|
||||
this.leaderByFile.delete(fileId)
|
||||
this.syncHandlers.delete(fileId)
|
||||
this.pendingResync.delete(fileId)
|
||||
|
||||
if (this.cursorByFile.delete(fileId))
|
||||
this.cursorEmitter.emit(this.getCursorEventKey(fileId), {})
|
||||
}
|
||||
|
||||
updateText(fileId: string, text: string): void {
|
||||
|
||||
@ -83,7 +83,8 @@ const OnlineUserAvatar = ({
|
||||
const OnlineUsers = () => {
|
||||
const { t } = useTranslation()
|
||||
const appId = useStore(s => s.appId)
|
||||
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string)
|
||||
const isWorkflowCollaborationEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string, undefined, isWorkflowCollaborationEnabled)
|
||||
const { userProfile } = useAppContext()
|
||||
const reactFlow = useReactFlow()
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
|
||||
@ -102,8 +102,9 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const appId = useStore(s => s.appId)
|
||||
const isWorkflowCollaborationEnabled = useStore(s => !s.isRestoring && !s.historyWorkflowData)
|
||||
const { userProfile } = useAppContext()
|
||||
const { isConnected, nodePanelPresence } = useCollaboration(appId as string)
|
||||
const { isConnected, nodePanelPresence } = useCollaboration(appId as string, undefined, isWorkflowCollaborationEnabled)
|
||||
const { showMessageLogModal } = useAppStore(useShallow(state => ({
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
})))
|
||||
|
||||
@ -88,7 +88,8 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
const resolvedWorkflowStore = workflowStore ?? fallbackWorkflowStoreRef.current
|
||||
const appId = useZustandStore(resolvedWorkflowStore, s => s.appId)
|
||||
const controlMode = useZustandStore(resolvedWorkflowStore, s => s.controlMode)
|
||||
const { nodePanelPresence } = useCollaboration(appId as string)
|
||||
const isWorkflowCollaborationEnabled = useZustandStore(resolvedWorkflowStore, s => !s.isRestoring && !s.historyWorkflowData)
|
||||
const { nodePanelPresence } = useCollaboration(appId as string, undefined, isWorkflowCollaborationEnabled)
|
||||
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
|
||||
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
|
||||
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
import type { AgentNodeType } from '../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { VarType as ToolVarType } from '../../tool/types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockUseNodesReadOnly = vi.fn()
|
||||
const mockUseNodeCrud = vi.fn()
|
||||
const mockUseVarList = vi.fn()
|
||||
const mockUseStrategyProviderDetail = vi.fn()
|
||||
const mockUseFetchPluginsInMarketPlaceByIds = vi.fn()
|
||||
const mockUseCheckInstalled = vi.fn()
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||
default: (...args: unknown[]) => mockUseVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-strategy', () => ({
|
||||
useStrategyProviderDetail: (...args: unknown[]) => mockUseStrategyProviderDetail(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFetchPluginsInMarketPlaceByIds: (...args: unknown[]) => mockUseFetchPluginsInMarketPlaceByIds(...args),
|
||||
useCheckInstalled: (...args: unknown[]) => mockUseCheckInstalled(...args),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
type: BlockEnum.Agent,
|
||||
output_schema: {},
|
||||
agent_strategy_provider_name: 'provider/agent',
|
||||
agent_strategy_name: 'react',
|
||||
agent_strategy_label: 'React Agent',
|
||||
agent_parameters: {
|
||||
toolParam: {
|
||||
type: ToolVarType.constant,
|
||||
value: {
|
||||
settings: {},
|
||||
parameters: {},
|
||||
schemas: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createStrategyProviderDetail = () => ({
|
||||
declaration: {
|
||||
strategies: [{
|
||||
identity: {
|
||||
name: 'react',
|
||||
},
|
||||
parameters: [{
|
||||
name: 'toolParam',
|
||||
type: FormTypeEnum.toolSelector,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
describe('agent useConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: false,
|
||||
})
|
||||
mockUseNodeCrud.mockImplementation((_id: string, payload: AgentNodeType) => ({
|
||||
inputs: payload,
|
||||
setInputs: mockSetInputs,
|
||||
}))
|
||||
mockUseVarList.mockReturnValue({
|
||||
handleVarListChange: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
})
|
||||
mockUseStrategyProviderDetail.mockReturnValue({
|
||||
data: createStrategyProviderDetail(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
mockUseFetchPluginsInMarketPlaceByIds.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
plugins: [],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
data: {
|
||||
plugins: [],
|
||||
},
|
||||
})
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [],
|
||||
availableNodesWithParent: [],
|
||||
})
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('should skip legacy migration when the node is read-only', () => {
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: true,
|
||||
})
|
||||
|
||||
renderHook(() => useConfig('agent-node', createPayload()))
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should migrate legacy agent tool data when the node is editable', () => {
|
||||
renderHook(() => useConfig('agent-node', createPayload()))
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tool_node_version: '2',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -129,9 +129,7 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
||||
return res
|
||||
}
|
||||
|
||||
const formattingLegacyData = () => {
|
||||
if (inputs.version || inputs.tool_node_version)
|
||||
return inputs
|
||||
const formattingLegacyData = useCallback(() => {
|
||||
const newData = produce(inputs, (draft) => {
|
||||
const schemas = currentStrategy?.parameters || []
|
||||
Object.keys(draft.agent_parameters || {}).forEach((key) => {
|
||||
@ -144,15 +142,17 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
||||
draft.tool_node_version = '2'
|
||||
})
|
||||
return newData
|
||||
}
|
||||
}, [currentStrategy?.parameters, inputs])
|
||||
|
||||
const shouldFormatLegacyData = Boolean(currentStrategy) && !readOnly && !inputs.version && !inputs.tool_node_version
|
||||
|
||||
// formatting legacy data
|
||||
useEffect(() => {
|
||||
if (!currentStrategy)
|
||||
if (!shouldFormatLegacyData)
|
||||
return
|
||||
const newData = formattingLegacyData()
|
||||
setInputs(newData)
|
||||
}, [currentStrategy])
|
||||
}, [formattingLegacyData, setInputs, shouldFormatLegacyData])
|
||||
|
||||
// vars
|
||||
|
||||
|
||||
@ -0,0 +1,153 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useNodeSkills } from '../use-node-skills'
|
||||
|
||||
type MockNode = {
|
||||
id: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
nodeSkills: vi.fn(),
|
||||
nodeSkillsQueryKey: vi.fn((_input: unknown) => ['console', 'workflowDraft', 'nodeSkills']),
|
||||
store: {
|
||||
getState: vi.fn(),
|
||||
},
|
||||
nodes: [] as MockNode[],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
workflowDraft: {
|
||||
nodeSkills: (input: unknown) => mocks.nodeSkills(input),
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
workflowDraft: {
|
||||
nodeSkills: {
|
||||
queryKey: (input: unknown) => mocks.nodeSkillsQueryKey(input),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => mocks.store,
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNodeSkills', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'first prompt', skill: true }],
|
||||
},
|
||||
},
|
||||
]
|
||||
mocks.store.getState.mockImplementation(() => ({
|
||||
getNodes: () => mocks.nodes,
|
||||
}))
|
||||
mocks.nodeSkills.mockResolvedValue({
|
||||
tool_dependencies: [],
|
||||
})
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
it('should avoid refetching when the request key stays the same', async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ promptTemplateKey }) => useNodeSkills({
|
||||
nodeId: 'node-1',
|
||||
promptTemplateKey,
|
||||
}),
|
||||
{
|
||||
initialProps: { promptTemplateKey: 'prompt-key-1' },
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
mocks.nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'updated prompt', skill: true }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
rerender({ promptTemplateKey: 'prompt-key-1' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refetch with the latest node data when the request key changes', async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ promptTemplateKey }) => useNodeSkills({
|
||||
nodeId: 'node-1',
|
||||
promptTemplateKey,
|
||||
}),
|
||||
{
|
||||
initialProps: { promptTemplateKey: 'prompt-key-1' },
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
mocks.nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'updated prompt', skill: true }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
rerender({ promptTemplateKey: 'prompt-key-2' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.nodeSkills).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(mocks.nodeSkills).toHaveBeenLastCalledWith({
|
||||
params: { appId: 'app-1' },
|
||||
body: {
|
||||
type: 'llm',
|
||||
prompt_template: [{ text: 'updated prompt', skill: true }],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ToolSetting } from '../types'
|
||||
import type { ToolDependency } from '../use-node-skills'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
@ -19,7 +20,10 @@ type Props = {
|
||||
onChange: (enabled: boolean) => void
|
||||
nodeId: string
|
||||
toolSettings?: ToolSetting[]
|
||||
promptTemplateKey: string
|
||||
toolDependencies: ToolDependency[]
|
||||
isNodeSkillsLoading: boolean
|
||||
isNodeSkillsQueryEnabled: boolean
|
||||
hasNodeSkillsData: boolean
|
||||
}
|
||||
|
||||
const ComputerUseConfig: FC<Props> = ({
|
||||
@ -30,7 +34,10 @@ const ComputerUseConfig: FC<Props> = ({
|
||||
onChange,
|
||||
nodeId,
|
||||
toolSettings,
|
||||
promptTemplateKey,
|
||||
toolDependencies,
|
||||
isNodeSkillsLoading,
|
||||
isNodeSkillsQueryEnabled,
|
||||
hasNodeSkillsData,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isDisabled = readonly || isDisabledByStructuredOutput
|
||||
@ -89,7 +96,10 @@ const ComputerUseConfig: FC<Props> = ({
|
||||
isComputerUseEnabled={enabled}
|
||||
nodeId={nodeId}
|
||||
toolSettings={toolSettings}
|
||||
promptTemplateKey={promptTemplateKey}
|
||||
toolDependencies={toolDependencies}
|
||||
isNodeSkillsLoading={isNodeSkillsLoading}
|
||||
isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled}
|
||||
hasNodeSkillsData={hasNodeSkillsData}
|
||||
/>
|
||||
</div>
|
||||
</FieldCollapse>
|
||||
|
||||
@ -13,7 +13,6 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { useNodeCurdKit } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useNodeSkills } from '@/app/components/workflow/nodes/llm/use-node-skills'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
@ -26,7 +25,10 @@ type ReferenceToolConfigProps = {
|
||||
isComputerUseEnabled: boolean
|
||||
nodeId: string
|
||||
toolSettings?: ToolSetting[]
|
||||
promptTemplateKey: string
|
||||
toolDependencies: ToolDependency[]
|
||||
isNodeSkillsLoading: boolean
|
||||
isNodeSkillsQueryEnabled: boolean
|
||||
hasNodeSkillsData: boolean
|
||||
}
|
||||
|
||||
type ToolProviderGroup = {
|
||||
@ -40,7 +42,10 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
|
||||
isComputerUseEnabled,
|
||||
nodeId,
|
||||
toolSettings,
|
||||
promptTemplateKey,
|
||||
toolDependencies,
|
||||
isNodeSkillsLoading,
|
||||
isNodeSkillsQueryEnabled,
|
||||
hasNodeSkillsData,
|
||||
}) => {
|
||||
const isReferenceToolsDisabled = readonly || !isComputerUseEnabled || isDisabledByStructuredOutput
|
||||
const { i18n, t } = useTranslation()
|
||||
@ -52,11 +57,6 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const locale = useMemo(() => getLanguage(i18n.language as Locale), [i18n.language])
|
||||
|
||||
const { toolDependencies, isLoading, isQueryEnabled, hasData } = useNodeSkills({
|
||||
nodeId,
|
||||
promptTemplateKey,
|
||||
})
|
||||
|
||||
const providers = useMemo<ToolProviderGroup[]>(() => {
|
||||
const map = new Map<string, ToolDependency[]>()
|
||||
toolDependencies.forEach((tool) => {
|
||||
@ -185,7 +185,7 @@ const ReferenceToolConfig: FC<ReferenceToolConfigProps> = ({
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const isInitialLoading = isQueryEnabled && isLoading && !hasData
|
||||
const isInitialLoading = isNodeSkillsQueryEnabled && isNodeSkillsLoading && !hasNodeSkillsData
|
||||
const showNoData = !isInitialLoading && providers.length === 0
|
||||
|
||||
const renderProviderIcon = useCallback((providerId: string) => {
|
||||
|
||||
@ -2,9 +2,8 @@ import type { FC } from 'react'
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import { RiAlertFill, RiInformationLine, RiQuestionLine } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddButton2 from '@/app/components/base/button/add-button'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
@ -36,6 +35,7 @@ import { useStructuredOutputMutualExclusion } from './use-structured-output-mutu
|
||||
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
|
||||
|
||||
const i18nPrefix = 'nodes.llm'
|
||||
const SKILL_DEPENDENCY_DEBOUNCE_MS = 800
|
||||
|
||||
const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
id,
|
||||
@ -89,14 +89,29 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
}
|
||||
}, [inputs.prompt_template])
|
||||
const [skillsRefreshKey, setSkillsRefreshKey] = React.useState(promptTemplateKey)
|
||||
const { run: scheduleSkillsRefresh } = useDebounceFn((nextKey: string) => {
|
||||
setSkillsRefreshKey(nextKey)
|
||||
}, { wait: 3000 })
|
||||
const handlePromptEditorBlur = useCallback(() => {
|
||||
scheduleSkillsRefresh(promptTemplateKey)
|
||||
}, [promptTemplateKey, scheduleSkillsRefresh])
|
||||
useEffect(() => {
|
||||
if (skillsRefreshKey === promptTemplateKey)
|
||||
return
|
||||
|
||||
const { toolDependencies } = useNodeSkills({
|
||||
const timerId = window.setTimeout(() => {
|
||||
setSkillsRefreshKey(promptTemplateKey)
|
||||
}, SKILL_DEPENDENCY_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
}, [promptTemplateKey, skillsRefreshKey])
|
||||
|
||||
const handlePromptEditorBlur = useCallback(() => {
|
||||
setSkillsRefreshKey(promptTemplateKey)
|
||||
}, [promptTemplateKey])
|
||||
|
||||
const {
|
||||
toolDependencies,
|
||||
isLoading: isNodeSkillsLoading,
|
||||
isQueryEnabled: isNodeSkillsQueryEnabled,
|
||||
hasData: hasNodeSkillsData,
|
||||
} = useNodeSkills({
|
||||
nodeId: id,
|
||||
promptTemplateKey: skillsRefreshKey,
|
||||
enabled: isSupportSandbox,
|
||||
@ -303,7 +318,10 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
onChange={handleComputerUseChange}
|
||||
nodeId={id}
|
||||
toolSettings={inputs.tool_settings}
|
||||
promptTemplateKey={skillsRefreshKey}
|
||||
toolDependencies={toolDependencies}
|
||||
isNodeSkillsLoading={isNodeSkillsLoading}
|
||||
isNodeSkillsQueryEnabled={isNodeSkillsQueryEnabled}
|
||||
hasNodeSkillsData={hasNodeSkillsData}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
|
||||
@ -21,7 +21,6 @@ type UseNodeSkillsParams = {
|
||||
export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: UseNodeSkillsParams) {
|
||||
const appId = useAppStore(s => s.appDetail?.id)
|
||||
const store = useStoreApi()
|
||||
const nodeData = useReactFlowStore(s => s.getNodes().find(n => n.id === nodeId)?.data)
|
||||
const isQueryEnabled = enabled && !!appId && !!nodeId
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
@ -34,10 +33,9 @@ export function useNodeSkills({ nodeId, promptTemplateKey, enabled = true }: Use
|
||||
}),
|
||||
nodeId,
|
||||
promptTemplateKey,
|
||||
nodeData,
|
||||
store,
|
||||
]
|
||||
}, [appId, nodeId, promptTemplateKey, nodeData, store])
|
||||
}, [appId, nodeId, promptTemplateKey, store])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
|
||||
@ -345,7 +345,7 @@ describe('question-classifier path', () => {
|
||||
|
||||
expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument()
|
||||
await user.hover(screen.getByText(`${longName.slice(0, 50)}...`))
|
||||
expect(screen.getByText(longName)).toBeInTheDocument()
|
||||
expect(await screen.findByText(longName)).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Node
|
||||
|
||||
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import MemoryConfig from '../../_base/components/memory-config'
|
||||
|
||||
@ -48,14 +48,22 @@ const AdvancedSetting: FC<Props> = ({
|
||||
title={(
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="uppercase">{t(`${i18nPrefix}.instruction`, { ns: 'workflow' })}</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<span className="ml-0.5 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent placement="top" popupClassName="p-2">
|
||||
<div className="w-[120px]">
|
||||
{t(`${i18nPrefix}.instructionTip`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
triggerClassName="w-3.5 h-3.5 ml-0.5"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
value={instruction}
|
||||
|
||||
@ -4,7 +4,7 @@ import type { NodeProps } from 'reactflow'
|
||||
import type { QuestionClassifierNodeType } from './types'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
@ -47,15 +47,18 @@ const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId,
|
||||
</div>
|
||||
{shouldShowTooltip
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
nativeButton={false}
|
||||
render={content}
|
||||
/>
|
||||
<PopoverContent placement="top" popupClassName="p-2">
|
||||
<div className="max-w-[300px] break-words">
|
||||
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
: content}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { memo } from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
type TipPopupProps = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
children: ReactElement
|
||||
shortcuts?: string[]
|
||||
}
|
||||
const TipPopup = ({
|
||||
@ -13,21 +14,17 @@ const TipPopup = ({
|
||||
shortcuts,
|
||||
}: TipPopupProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
needsDelay={false}
|
||||
offset={4}
|
||||
popupClassName="p-0 bg-transparent"
|
||||
popupContent={(
|
||||
<Popover>
|
||||
<PopoverTrigger openOnHover nativeButton={false} render={children} />
|
||||
<PopoverContent placement="top" sideOffset={4} popupClassName="border-none bg-transparent p-0 shadow-none">
|
||||
<div className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg backdrop-blur-[5px]">
|
||||
<span className="text-text-secondary system-xs-medium">{title}</span>
|
||||
{
|
||||
shortcuts && <ShortcutsName keys={shortcuts} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -142,18 +142,21 @@ const NodePanel: FC<Props> = ({
|
||||
type={nodeInfo.node_type}
|
||||
toolIcon={((nodeInfo.extras as { icon?: string } | undefined)?.icon || nodeInfo.extras) as string | { content: string, background: string } | undefined}
|
||||
/>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className={cn(
|
||||
'grow truncate text-text-secondary system-xs-semibold-uppercase',
|
||||
hideInfo && '!text-xs',
|
||||
)}
|
||||
>
|
||||
{nodeInfo.title}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="max-w-xs">{nodeInfo.title}</div>
|
||||
}
|
||||
>
|
||||
<div className={cn(
|
||||
'grow truncate text-text-secondary system-xs-semibold-uppercase',
|
||||
hideInfo && '!text-xs',
|
||||
)}
|
||||
>
|
||||
{nodeInfo.title}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!['running', 'paused'].includes(nodeInfo.status) && !hideInfo && (
|
||||
<div className="shrink-0 text-text-tertiary system-xs-regular">
|
||||
|
||||
134
web/app/components/workflow/skill/__tests__/main.spec.tsx
Normal file
134
web/app/components/workflow/skill/__tests__/main.spec.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import SkillMain from '../main'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryFileId: null as string | null,
|
||||
appId: 'app-1',
|
||||
activeTabId: 'file-tab',
|
||||
openTab: vi.fn(),
|
||||
useSkillAutoSave: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
parseAsString: 'parseAsString',
|
||||
useQueryState: () => [mocks.queryFileId, vi.fn()],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: { id: string } | null }) => unknown) => selector({
|
||||
appDetail: mocks.appId ? { id: mocks.appId } : null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { activeTabId: string }) => unknown) => selector({
|
||||
activeTabId: mocks.activeTabId,
|
||||
}),
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
openTab: mocks.openTab,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-skill-auto-save', () => ({
|
||||
useSkillAutoSave: () => mocks.useSkillAutoSave(),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-skill-save-manager', () => ({
|
||||
SkillSaveProvider: ({ appId, children }: { appId: string, children: ReactNode }) => (
|
||||
<div data-testid="skill-save-provider" data-app-id={appId}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../constants', () => ({
|
||||
isArtifactTab: (tabId: string | null | undefined) => tabId === 'artifact-tab',
|
||||
}))
|
||||
|
||||
vi.mock('../file-tree/artifacts/artifacts-section', () => ({
|
||||
default: () => <div data-testid="artifacts-section" />,
|
||||
}))
|
||||
|
||||
vi.mock('../file-tree/tree/file-tree', () => ({
|
||||
default: () => <div data-testid="file-tree" />,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/layout/content-area', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <div data-testid="content-area">{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/layout/content-body', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <div data-testid="content-body">{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/layout/sidebar', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <aside data-testid="sidebar">{children}</aside>,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/layout/skill-page-layout', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <section data-testid="page-layout">{children}</section>,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/panels/artifact-content-panel', () => ({
|
||||
default: () => <div data-testid="artifact-content-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/panels/file-content-panel', () => ({
|
||||
default: () => <div data-testid="file-content-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/sidebar-search-add', () => ({
|
||||
default: () => <div data-testid="sidebar-search-add" />,
|
||||
}))
|
||||
|
||||
vi.mock('../skill-body/tabs/file-tabs', () => ({
|
||||
default: () => <div data-testid="file-tabs" />,
|
||||
}))
|
||||
|
||||
describe('SkillMain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.queryFileId = null
|
||||
mocks.appId = 'app-1'
|
||||
mocks.activeTabId = 'file-tab'
|
||||
})
|
||||
|
||||
it('should render the skill layout and autosave manager', () => {
|
||||
render(<SkillMain />)
|
||||
|
||||
expect(screen.getByTestId('skill-save-provider')).toHaveAttribute('data-app-id', 'app-1')
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('content-area')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('file-content-panel')).toBeInTheDocument()
|
||||
expect(mocks.useSkillAutoSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render the artifact content panel when the active tab is an artifact', () => {
|
||||
mocks.activeTabId = 'artifact-tab'
|
||||
|
||||
render(<SkillMain />)
|
||||
|
||||
expect(screen.getByTestId('artifact-content-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('file-content-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the query-selected file as a pinned tab', () => {
|
||||
mocks.queryFileId = 'file-42'
|
||||
|
||||
render(<SkillMain />)
|
||||
|
||||
expect(mocks.openTab).toHaveBeenCalledWith('file-42', { pinned: true })
|
||||
})
|
||||
|
||||
it('should fall back to an empty app id when app detail is missing', () => {
|
||||
mocks.appId = ''
|
||||
|
||||
render(<SkillMain />)
|
||||
|
||||
expect(screen.getByTestId('skill-save-provider')).toHaveAttribute('data-app-id', '')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,136 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
|
||||
import CodeFileEditor from '../code-file-editor'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const editor = {
|
||||
focus: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
editor,
|
||||
onChange: vi.fn(),
|
||||
onMount: vi.fn(),
|
||||
onAutoFocus: vi.fn(),
|
||||
overlay: null as ReactNode,
|
||||
useSkillCodeCursors: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({
|
||||
onMount,
|
||||
onChange,
|
||||
loading,
|
||||
}: {
|
||||
onMount: (editor: typeof mocks.editor, monaco: { editor: { setTheme: (theme: string) => void } }) => void
|
||||
onChange: (value: string | undefined) => void
|
||||
loading: ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onMount(mocks.editor, { editor: { setTheme: vi.fn() } })}>
|
||||
mount-editor
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange('next value')}>
|
||||
change-editor
|
||||
</button>
|
||||
<div data-testid="editor-loading">{loading}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../code-editor/plugins/remote-cursors', () => ({
|
||||
useSkillCodeCursors: (props: unknown) => {
|
||||
mocks.useSkillCodeCursors(props)
|
||||
return { overlay: mocks.overlay }
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CodeFileEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.overlay = null
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
})
|
||||
|
||||
it('should wire Monaco changes and cursor overlay state', () => {
|
||||
mocks.overlay = <div data-testid="cursor-overlay">overlay</div>
|
||||
|
||||
render(
|
||||
<CodeFileEditor
|
||||
language="typescript"
|
||||
theme="light"
|
||||
value="const a = 1"
|
||||
onChange={mocks.onChange}
|
||||
onMount={mocks.onMount}
|
||||
fileId="file-1"
|
||||
collaborationEnabled
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'change-editor' }).click()
|
||||
})
|
||||
|
||||
expect(mocks.onChange).toHaveBeenCalledWith('next value')
|
||||
expect(screen.getByTestId('cursor-overlay')).toBeInTheDocument()
|
||||
expect(mocks.useSkillCodeCursors).toHaveBeenCalledWith({
|
||||
editor: null,
|
||||
fileId: 'file-1',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should focus the editor after mount when auto focus is enabled', () => {
|
||||
render(
|
||||
<CodeFileEditor
|
||||
language="typescript"
|
||||
theme="light"
|
||||
value="const a = 1"
|
||||
onChange={mocks.onChange}
|
||||
onMount={mocks.onMount}
|
||||
autoFocus
|
||||
onAutoFocus={mocks.onAutoFocus}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'mount-editor' }).click()
|
||||
})
|
||||
|
||||
expect(mocks.onMount).toHaveBeenCalled()
|
||||
expect(mocks.editor.focus).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.onAutoFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should skip auto focus and collaboration overlay in read only mode', () => {
|
||||
render(
|
||||
<CodeFileEditor
|
||||
language="typescript"
|
||||
theme="light"
|
||||
value="const a = 1"
|
||||
onChange={mocks.onChange}
|
||||
onMount={mocks.onMount}
|
||||
autoFocus
|
||||
fileId="file-1"
|
||||
collaborationEnabled
|
||||
readOnly
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'mount-editor' }).click()
|
||||
})
|
||||
|
||||
expect(mocks.editor.focus).not.toHaveBeenCalled()
|
||||
expect(mocks.useSkillCodeCursors).toHaveBeenCalledWith({
|
||||
editor: null,
|
||||
fileId: 'file-1',
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,100 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
|
||||
import MarkdownFileEditor from '../markdown-file-editor'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onChange: vi.fn(),
|
||||
onAutoFocus: vi.fn(),
|
||||
skillEditorProps: [] as Array<Record<string, unknown>>,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../skill-editor', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.skillEditorProps.push(props)
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => (props.onChange as (value: string) => void)('updated')}>
|
||||
emit-change
|
||||
</button>
|
||||
<button type="button" onClick={() => (props.onChange as (value: string) => void)(String(props.value))}>
|
||||
emit-same-value
|
||||
</button>
|
||||
<div data-testid="skill-editor-props" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('MarkdownFileEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.skillEditorProps.length = 0
|
||||
})
|
||||
|
||||
it('should pass editable collaboration props and only emit changed values', () => {
|
||||
render(
|
||||
<MarkdownFileEditor
|
||||
instanceId="file-1"
|
||||
value="hello"
|
||||
onChange={mocks.onChange}
|
||||
autoFocus
|
||||
onAutoFocus={mocks.onAutoFocus}
|
||||
collaborationEnabled
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'emit-change' }).click()
|
||||
})
|
||||
|
||||
expect(mocks.onChange).toHaveBeenCalledWith('updated')
|
||||
expect(mocks.skillEditorProps[0]).toMatchObject({
|
||||
instanceId: 'file-1',
|
||||
value: 'hello',
|
||||
editable: true,
|
||||
autoFocus: true,
|
||||
collaborationEnabled: true,
|
||||
showLineNumbers: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable editing features and placeholder in read only mode', () => {
|
||||
render(
|
||||
<MarkdownFileEditor
|
||||
value="hello"
|
||||
onChange={mocks.onChange}
|
||||
autoFocus
|
||||
collaborationEnabled
|
||||
readOnly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mocks.skillEditorProps[0]).toMatchObject({
|
||||
editable: false,
|
||||
autoFocus: false,
|
||||
collaborationEnabled: false,
|
||||
placeholder: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore editor updates that do not change the value', () => {
|
||||
render(
|
||||
<MarkdownFileEditor
|
||||
value="hello"
|
||||
onChange={mocks.onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'emit-same-value' }).click()
|
||||
})
|
||||
|
||||
expect(mocks.onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,35 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useSkillCodeCursors } from '../remote-cursors'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
onOnlineUsersUpdate: () => vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
|
||||
skillCollaborationManager: {
|
||||
onCursorUpdate: () => vi.fn(),
|
||||
emitCursorUpdate: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useSkillCodeCursors', () => {
|
||||
it('should return a null overlay when the hook is disabled', () => {
|
||||
const { result } = renderHook(() => useSkillCodeCursors({
|
||||
editor: null,
|
||||
fileId: 'file-1',
|
||||
enabled: false,
|
||||
}))
|
||||
|
||||
expect(result.current.overlay).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,220 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SkillEditor from '../index'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const rootElement = document.createElement('div')
|
||||
rootElement.focus = vi.fn()
|
||||
|
||||
return {
|
||||
initialConfig: null as Record<string, unknown> | null,
|
||||
filePreviewValues: [] as Array<Record<string, unknown>>,
|
||||
onBlurProps: [] as Array<Record<string, unknown>>,
|
||||
updateBlockProps: [] as Array<Record<string, unknown>>,
|
||||
localCursorProps: [] as Array<Record<string, unknown>>,
|
||||
remoteCursorProps: [] as Array<Record<string, unknown>>,
|
||||
toolPickerScopes: [] as string[],
|
||||
onChangeCalls: 0,
|
||||
rootElement,
|
||||
editor: {
|
||||
focus: (callback: () => void) => callback(),
|
||||
getRootElement: () => rootElement,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposer', () => ({
|
||||
LexicalComposer: ({ initialConfig, children }: { initialConfig: Record<string, unknown>, children: React.ReactNode }) => {
|
||||
mocks.initialConfig = initialConfig
|
||||
return <div data-testid="lexical-composer">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: () => [mocks.editor],
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalContentEditable', () => ({
|
||||
ContentEditable: (props: Record<string, unknown>) => <div data-testid="content-editable">{JSON.stringify(props)}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalRichTextPlugin', () => ({
|
||||
RichTextPlugin: ({ contentEditable, placeholder }: { contentEditable: React.ReactNode, placeholder: React.ReactNode }) => (
|
||||
<div data-testid="rich-text-plugin">
|
||||
{contentEditable}
|
||||
{placeholder}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalOnChangePlugin', () => ({
|
||||
OnChangePlugin: ({ onChange }: { onChange: (editorState: { read: (reader: () => unknown) => unknown }) => void }) => {
|
||||
React.useEffect(() => {
|
||||
if (mocks.onChangeCalls === 0) {
|
||||
mocks.onChangeCalls += 1
|
||||
onChange({
|
||||
read: reader => reader(),
|
||||
})
|
||||
}
|
||||
}, [onChange])
|
||||
return <div data-testid="on-change-plugin" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalErrorBoundary', () => ({
|
||||
LexicalErrorBoundary: () => <div data-testid="error-boundary" />,
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalHistoryPlugin', () => ({
|
||||
HistoryPlugin: () => <div data-testid="history-plugin" />,
|
||||
}))
|
||||
|
||||
vi.mock('lexical', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('lexical')>()
|
||||
return {
|
||||
...actual,
|
||||
$getRoot: () => ({
|
||||
getChildren: () => [
|
||||
{ getTextContent: () => 'first line' },
|
||||
{ getTextContent: () => 'second line' },
|
||||
],
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/plugins/placeholder', () => ({
|
||||
default: ({ value }: { value: React.ReactNode }) => <div data-testid="placeholder">{value}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/plugins/on-blur-or-focus-block', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.onBlurProps.push(props)
|
||||
return <div data-testid="on-blur-block" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/plugins/update-block', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.updateBlockProps.push(props)
|
||||
return <div data-testid="update-block" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/utils', () => ({
|
||||
textToEditorState: (value: string) => `editor-state:${value}`,
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/file-picker-block', () => ({
|
||||
default: () => <div data-testid="file-picker-block" />,
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/file-reference-block/preview-context', () => ({
|
||||
FilePreviewContextProvider: ({ value, children }: { value: Record<string, unknown>, children: React.ReactNode }) => {
|
||||
mocks.filePreviewValues.push(value)
|
||||
return <div data-testid="file-preview-context">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/file-reference-block/replacement-block', () => ({
|
||||
default: () => <div data-testid="file-reference-replacement-block" />,
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/remote-cursors', () => ({
|
||||
LocalCursorPlugin: (props: Record<string, unknown>) => {
|
||||
mocks.localCursorProps.push(props)
|
||||
return <div data-testid="local-cursor-plugin" />
|
||||
},
|
||||
SkillRemoteCursors: (props: Record<string, unknown>) => {
|
||||
mocks.remoteCursorProps.push(props)
|
||||
return <div data-testid="remote-cursor-plugin" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/tool-block', () => ({
|
||||
ToolBlock: () => <div data-testid="tool-block-plugin" />,
|
||||
ToolBlockNode: class MockToolBlockNode {},
|
||||
ToolBlockReplacementBlock: () => <div data-testid="tool-block-replacement-block" />,
|
||||
ToolGroupBlockNode: class MockToolGroupBlockNode {},
|
||||
ToolGroupBlockReplacementBlock: () => <div data-testid="tool-group-block-replacement-block" />,
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/tool-block/tool-picker-block', () => ({
|
||||
default: ({ scope }: { scope: string }) => {
|
||||
mocks.toolPickerScopes.push(scope)
|
||||
return <div data-testid="tool-picker-block">{scope}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.initialConfig = null
|
||||
mocks.filePreviewValues.length = 0
|
||||
mocks.onBlurProps.length = 0
|
||||
mocks.updateBlockProps.length = 0
|
||||
mocks.localCursorProps.length = 0
|
||||
mocks.remoteCursorProps.length = 0
|
||||
mocks.toolPickerScopes.length = 0
|
||||
mocks.onChangeCalls = 0
|
||||
vi.mocked(mocks.rootElement.focus).mockClear()
|
||||
})
|
||||
|
||||
describe('SkillEditor', () => {
|
||||
it('should build the lexical config and render editable plugins', async () => {
|
||||
const onChange = vi.fn()
|
||||
const onAutoFocus = vi.fn()
|
||||
|
||||
render(
|
||||
<SkillEditor
|
||||
instanceId="file-1"
|
||||
value="hello"
|
||||
editable
|
||||
autoFocus
|
||||
collaborationEnabled
|
||||
toolPickerScope="selection"
|
||||
placeholder="Type here"
|
||||
onChange={onChange}
|
||||
onAutoFocus={onAutoFocus}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mocks.initialConfig).toMatchObject({
|
||||
namespace: 'skill-editor',
|
||||
editable: true,
|
||||
editorState: 'editor-state:hello',
|
||||
})
|
||||
expect(mocks.filePreviewValues[0]).toEqual({ enabled: false })
|
||||
expect(screen.getByTestId('file-picker-block')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-picker-block')).toHaveTextContent('selection')
|
||||
expect(mocks.toolPickerScopes).toEqual(['selection'])
|
||||
expect(mocks.updateBlockProps[0]).toMatchObject({ instanceId: 'file-1' })
|
||||
expect(mocks.localCursorProps[0]).toMatchObject({ fileId: 'file-1', enabled: true })
|
||||
expect(mocks.remoteCursorProps[0]).toMatchObject({ fileId: 'file-1', enabled: true })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith('first line\nsecond line')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(onAutoFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mocks.rootElement.focus).toHaveBeenCalledWith({ preventScroll: true })
|
||||
})
|
||||
|
||||
it('should skip editable-only plugins in readonly mode', () => {
|
||||
render(
|
||||
<SkillEditor
|
||||
instanceId="file-2"
|
||||
value="readonly"
|
||||
editable={false}
|
||||
collaborationEnabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mocks.initialConfig).toMatchObject({
|
||||
editable: false,
|
||||
editorState: 'editor-state:readonly',
|
||||
})
|
||||
expect(screen.queryByTestId('file-picker-block')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tool-picker-block')).not.toBeInTheDocument()
|
||||
expect(mocks.localCursorProps[0]).toMatchObject({ fileId: 'file-2', enabled: false })
|
||||
expect(mocks.remoteCursorProps[0]).toMatchObject({ fileId: 'file-2', enabled: false })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,111 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { $createParagraphNode, $getRoot } from 'lexical'
|
||||
import {
|
||||
createLexicalTestEditor,
|
||||
} from '@/app/components/base/prompt-editor/plugins/test-helpers'
|
||||
import {
|
||||
$createFileReferenceNode,
|
||||
$isFileReferenceNode,
|
||||
FileReferenceNode,
|
||||
} from '../node'
|
||||
import { buildFileReferenceToken } from '../utils'
|
||||
|
||||
vi.mock('../component', () => ({
|
||||
default: ({ nodeKey, resourceId }: { nodeKey: string, resourceId: string }) => (
|
||||
<div data-testid="file-reference-block">{`${nodeKey}:${resourceId}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const firstResourceId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondResourceId = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
describe('FileReferenceNode', () => {
|
||||
it('should expose lexical metadata and serialize its payload', () => {
|
||||
const editor = createLexicalTestEditor('file-reference-node-metadata-test', [FileReferenceNode])
|
||||
let node!: FileReferenceNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createFileReferenceNode({ resourceId: firstResourceId })
|
||||
})
|
||||
})
|
||||
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(FileReferenceNode.getType()).toBe('file-reference-block')
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'file-reference-block',
|
||||
version: 1,
|
||||
resourceId: firstResourceId,
|
||||
})
|
||||
expect(node.getTextContent()).toBe(buildFileReferenceToken(firstResourceId))
|
||||
expect($isFileReferenceNode(node)).toBe(true)
|
||||
expect($isFileReferenceNode(null)).toBe(false)
|
||||
expect($isFileReferenceNode(undefined)).toBe(false)
|
||||
expect(dom.tagName).toBe('SPAN')
|
||||
expect(dom).toHaveClass('inline-flex', 'items-center', 'align-middle')
|
||||
})
|
||||
|
||||
it('should clone and import serialized nodes', () => {
|
||||
const editor = createLexicalTestEditor('file-reference-node-clone-test', [FileReferenceNode])
|
||||
let original!: FileReferenceNode
|
||||
let cloned!: FileReferenceNode
|
||||
let imported!: FileReferenceNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
original = $createFileReferenceNode({ resourceId: firstResourceId })
|
||||
cloned = FileReferenceNode.clone(original)
|
||||
imported = FileReferenceNode.importJSON({
|
||||
type: 'file-reference-block',
|
||||
version: 1,
|
||||
resourceId: secondResourceId,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.exportJSON()).toEqual(original.exportJSON())
|
||||
expect(imported.exportJSON()).toEqual({
|
||||
type: 'file-reference-block',
|
||||
version: 1,
|
||||
resourceId: secondResourceId,
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate and update its resource id inside editor state', () => {
|
||||
const editor = createLexicalTestEditor('file-reference-node-test', [FileReferenceNode])
|
||||
let node!: FileReferenceNode
|
||||
let updatedText = ''
|
||||
let updatedResourceId = ''
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createFileReferenceNode({ resourceId: firstResourceId })
|
||||
})
|
||||
})
|
||||
|
||||
render(node.decorate())
|
||||
|
||||
expect(screen.getByTestId('file-reference-block')).toHaveTextContent(firstResourceId)
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
const paragraph = $createParagraphNode()
|
||||
const lexicalNode = $createFileReferenceNode({ resourceId: firstResourceId })
|
||||
|
||||
paragraph.append(lexicalNode)
|
||||
root.append(paragraph)
|
||||
lexicalNode.setResourceId(secondResourceId)
|
||||
updatedText = lexicalNode.getTextContent()
|
||||
updatedResourceId = lexicalNode.exportJSON().resourceId
|
||||
})
|
||||
})
|
||||
|
||||
expect(updatedText).toBe(buildFileReferenceToken(secondResourceId))
|
||||
expect(updatedResourceId).toBe(secondResourceId)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,50 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
FilePreviewContextProvider,
|
||||
useFilePreviewContext,
|
||||
} from '../preview-context'
|
||||
|
||||
describe('FilePreviewContextProvider', () => {
|
||||
it('should fall back to the default disabled state without a provider', () => {
|
||||
const { result } = renderHook(() => useFilePreviewContext(context => context.enabled))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose the full context value and update subscribers', () => {
|
||||
let enabled = true
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FilePreviewContextProvider value={{ enabled }}>
|
||||
{children}
|
||||
</FilePreviewContextProvider>
|
||||
)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useFilePreviewContext(context => context.enabled),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
|
||||
enabled = false
|
||||
rerender()
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat an undefined provider value as disabled', () => {
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FilePreviewContextProvider>
|
||||
{children}
|
||||
</FilePreviewContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFilePreviewContext(context => context.enabled),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
|
||||
import {
|
||||
getNodeCount,
|
||||
renderLexicalEditor,
|
||||
setEditorRootText,
|
||||
waitForEditorReady,
|
||||
} from '@/app/components/base/prompt-editor/plugins/test-helpers'
|
||||
import {
|
||||
FileReferenceNode,
|
||||
} from '../node'
|
||||
import FileReferenceReplacementBlock from '../replacement-block'
|
||||
import { buildFileReferenceToken } from '../utils'
|
||||
|
||||
vi.mock('../component', () => ({
|
||||
default: ({ resourceId }: { resourceId: string }) => <div>{resourceId}</div>,
|
||||
}))
|
||||
|
||||
const resourceId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const renderReplacementPlugin = () => {
|
||||
return renderLexicalEditor({
|
||||
namespace: 'file-reference-replacement-plugin-test',
|
||||
nodes: [CustomTextNode, FileReferenceNode],
|
||||
children: <FileReferenceReplacementBlock />,
|
||||
})
|
||||
}
|
||||
|
||||
describe('FileReferenceReplacementBlock', () => {
|
||||
it('should replace serialized file reference tokens with file reference nodes', async () => {
|
||||
const { getEditor } = renderReplacementPlugin()
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(
|
||||
editor,
|
||||
`prefix ${buildFileReferenceToken(resourceId)} suffix`,
|
||||
text => new CustomTextNode(text),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeCount(editor, FileReferenceNode)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave plain text untouched when no token is present', async () => {
|
||||
const { getEditor } = renderReplacementPlugin()
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(editor, 'plain text without tokens', text => new CustomTextNode(text))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeCount(editor, FileReferenceNode)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw when the file reference node is not registered', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'file-reference-replacement-plugin-missing-node-test',
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [CustomTextNode],
|
||||
}}
|
||||
>
|
||||
<FileReferenceReplacementBlock />
|
||||
</LexicalComposer>,
|
||||
)
|
||||
}).toThrow('FileReferenceReplacementBlock: FileReferenceNode not registered on editor')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,28 @@
|
||||
import {
|
||||
buildFileReferenceToken,
|
||||
getFileReferenceTokenRegexString,
|
||||
parseFileReferenceToken,
|
||||
} from '../utils'
|
||||
|
||||
const resourceId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
describe('file-reference utils', () => {
|
||||
it('should expose a regex that matches serialized file tokens', () => {
|
||||
const regex = new RegExp(`^${getFileReferenceTokenRegexString()}$`)
|
||||
|
||||
expect(regex.test(buildFileReferenceToken(resourceId))).toBe(true)
|
||||
expect(regex.test('§[file].[app].[invalid]§')).toBe(false)
|
||||
})
|
||||
|
||||
it('should parse valid file tokens and reject invalid content', () => {
|
||||
expect(parseFileReferenceToken(buildFileReferenceToken(resourceId))).toEqual({
|
||||
resourceId,
|
||||
})
|
||||
expect(parseFileReferenceToken('plain-text')).toBeNull()
|
||||
expect(parseFileReferenceToken('§[file].[app].[short-id]§')).toBeNull()
|
||||
})
|
||||
|
||||
it('should build file reference tokens from resource ids', () => {
|
||||
expect(buildFileReferenceToken(resourceId)).toBe(`§[file].[app].[${resourceId}]§`)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,73 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import {
|
||||
BLUR_COMMAND,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
FOCUS_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useEditorBlur } from '../use-editor-blur'
|
||||
|
||||
describe('useEditorBlur', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should register blur and focus handlers and toggle visibility state', () => {
|
||||
const blurUnregister = vi.fn()
|
||||
const focusUnregister = vi.fn()
|
||||
const registerCommand = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(blurUnregister)
|
||||
.mockReturnValueOnce(focusUnregister)
|
||||
const editor = {
|
||||
registerCommand,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const { result, unmount } = renderHook(() => useEditorBlur(editor))
|
||||
|
||||
expect(result.current.blurHidden).toBe(false)
|
||||
expect(registerCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
BLUR_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(registerCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
FOCUS_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
|
||||
const blurHandler = registerCommand.mock.calls[0][1] as () => boolean
|
||||
const focusHandler = registerCommand.mock.calls[1][1] as () => boolean
|
||||
|
||||
act(() => {
|
||||
expect(blurHandler()).toBe(false)
|
||||
vi.advanceTimersByTime(199)
|
||||
})
|
||||
expect(result.current.blurHidden).toBe(false)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
expect(result.current.blurHidden).toBe(true)
|
||||
|
||||
act(() => {
|
||||
expect(focusHandler()).toBe(false)
|
||||
})
|
||||
expect(result.current.blurHidden).toBe(false)
|
||||
|
||||
act(() => {
|
||||
blurHandler()
|
||||
})
|
||||
unmount()
|
||||
|
||||
expect(blurUnregister).toHaveBeenCalledTimes(1)
|
||||
expect(focusUnregister).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,56 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { LocalCursorPlugin, SkillRemoteCursors } from '../index'
|
||||
|
||||
const mockEditor = {
|
||||
registerCommand: vi.fn(),
|
||||
registerUpdateListener: vi.fn(),
|
||||
getRootElement: vi.fn(() => null),
|
||||
getEditorState: vi.fn(() => ({
|
||||
read: (reader: () => unknown) => reader(),
|
||||
})),
|
||||
}
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: () => [mockEditor],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
onOnlineUsersUpdate: () => vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
|
||||
skillCollaborationManager: {
|
||||
onCursorUpdate: () => vi.fn(),
|
||||
emitCursorUpdate: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('skill editor remote cursors', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should not register local cursor listeners when collaboration is disabled', () => {
|
||||
const { container } = render(<LocalCursorPlugin fileId="file-1" enabled={false} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(mockEditor.registerCommand).not.toHaveBeenCalled()
|
||||
expect(mockEditor.registerUpdateListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render no overlay when remote cursors are disabled', () => {
|
||||
const { container } = render(<SkillRemoteCursors fileId="file-1" enabled={false} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,108 @@
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
import {
|
||||
DELETE_TOOL_BLOCK_COMMAND,
|
||||
INSERT_TOOL_BLOCK_COMMAND,
|
||||
} from '../commands'
|
||||
import {
|
||||
ToolBlock,
|
||||
} from '../index'
|
||||
|
||||
const mockInsertNodes = vi.hoisted(() => vi.fn())
|
||||
const mockCreateToolBlockNode = vi.hoisted(() => vi.fn((payload: unknown) => ({ kind: 'tool-block-node', payload })))
|
||||
const mockRegisterCommand = vi.hoisted(() => vi.fn())
|
||||
const mockEditor = vi.hoisted(() => ({
|
||||
hasNodes: vi.fn(() => true),
|
||||
registerCommand: mockRegisterCommand,
|
||||
}))
|
||||
const unregisterFns = vi.hoisted(() => [vi.fn(), vi.fn()])
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: () => [mockEditor],
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/utils', () => ({
|
||||
mergeRegister: (...callbacks: Array<() => void>) => () => callbacks.forEach(callback => callback()),
|
||||
}))
|
||||
|
||||
vi.mock('lexical', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('lexical')>()
|
||||
return {
|
||||
...actual,
|
||||
$insertNodes: mockInsertNodes,
|
||||
COMMAND_PRIORITY_EDITOR: 100,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../node', () => ({
|
||||
ToolBlockNode: class MockToolBlockNode {},
|
||||
$createToolBlockNode: mockCreateToolBlockNode,
|
||||
}))
|
||||
|
||||
describe('ToolBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEditor.hasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand
|
||||
.mockReturnValueOnce(unregisterFns[0])
|
||||
.mockReturnValueOnce(unregisterFns[1])
|
||||
})
|
||||
|
||||
it('should register insert and delete handlers with the editor', () => {
|
||||
render(<ToolBlock />)
|
||||
|
||||
expect(mockEditor.hasNodes).toHaveBeenCalled()
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
INSERT_TOOL_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
100,
|
||||
)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DELETE_TOOL_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
100,
|
||||
)
|
||||
})
|
||||
|
||||
it('should create and insert a tool block node when the insert handler runs', () => {
|
||||
render(<ToolBlock />)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (payload: unknown) => boolean
|
||||
const payload = {
|
||||
provider: 'openai/tools',
|
||||
tool: 'search',
|
||||
configId: '11111111-1111-4111-8111-111111111111',
|
||||
}
|
||||
|
||||
expect(insertHandler(payload)).toBe(true)
|
||||
expect(mockCreateToolBlockNode).toHaveBeenCalledWith(payload)
|
||||
expect(mockInsertNodes).toHaveBeenCalledWith([
|
||||
{ kind: 'tool-block-node', payload },
|
||||
])
|
||||
})
|
||||
|
||||
it('should return true from the delete handler', () => {
|
||||
render(<ToolBlock />)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
|
||||
expect(deleteHandler()).toBe(true)
|
||||
})
|
||||
|
||||
it('should unregister command handlers on unmount', () => {
|
||||
const { unmount } = render(<ToolBlock />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(unregisterFns[0]).toHaveBeenCalledTimes(1)
|
||||
expect(unregisterFns[1]).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should throw when the tool block node is not registered', () => {
|
||||
mockEditor.hasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => render(<ToolBlock />)).toThrow('ToolBlockPlugin: ToolBlockNode not registered on editor')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,103 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
createLexicalTestEditor,
|
||||
} from '@/app/components/base/prompt-editor/plugins/test-helpers'
|
||||
import {
|
||||
$createToolBlockNode,
|
||||
$isToolBlockNode,
|
||||
ToolBlockNode,
|
||||
} from '../node'
|
||||
import { buildToolToken } from '../utils'
|
||||
|
||||
vi.mock('../component', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-testid="tool-block-component">{JSON.stringify(props)}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const payload = {
|
||||
provider: 'openai/tools',
|
||||
tool: 'search',
|
||||
configId: '11111111-1111-4111-8111-111111111111',
|
||||
label: 'Search',
|
||||
icon: 'ri-search-line',
|
||||
iconDark: {
|
||||
content: 'moon',
|
||||
background: '#000000',
|
||||
},
|
||||
}
|
||||
|
||||
describe('ToolBlockNode', () => {
|
||||
it('should expose lexical metadata and serialize its payload', () => {
|
||||
const editor = createLexicalTestEditor('tool-block-node-metadata-test', [ToolBlockNode])
|
||||
let node!: ToolBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createToolBlockNode(payload)
|
||||
})
|
||||
})
|
||||
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(ToolBlockNode.getType()).toBe('tool-block')
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'tool-block',
|
||||
version: 1,
|
||||
...payload,
|
||||
})
|
||||
expect(node.getTextContent()).toBe(buildToolToken(payload))
|
||||
expect($isToolBlockNode(node)).toBe(true)
|
||||
expect($isToolBlockNode(null)).toBe(false)
|
||||
expect(dom.tagName).toBe('SPAN')
|
||||
expect(dom).toHaveClass('inline-flex', 'items-center', 'align-middle')
|
||||
})
|
||||
|
||||
it('should clone and import serialized nodes', () => {
|
||||
const editor = createLexicalTestEditor('tool-block-node-clone-test', [ToolBlockNode])
|
||||
let original!: ToolBlockNode
|
||||
let cloned!: ToolBlockNode
|
||||
let imported!: ToolBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
original = $createToolBlockNode(payload)
|
||||
cloned = ToolBlockNode.clone(original)
|
||||
imported = ToolBlockNode.importJSON({
|
||||
type: 'tool-block',
|
||||
version: 1,
|
||||
...payload,
|
||||
label: 'Imported',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.exportJSON()).toEqual(original.exportJSON())
|
||||
expect(imported.exportJSON()).toEqual({
|
||||
type: 'tool-block',
|
||||
version: 1,
|
||||
...payload,
|
||||
label: 'Imported',
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate the node with the tool block component payload', () => {
|
||||
const editor = createLexicalTestEditor('tool-block-node-decorate-test', [ToolBlockNode])
|
||||
let node!: ToolBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createToolBlockNode(payload)
|
||||
})
|
||||
})
|
||||
|
||||
render(node.decorate())
|
||||
|
||||
expect(screen.getByTestId('tool-block-component')).toHaveTextContent('"provider":"openai/tools"')
|
||||
expect(screen.getByTestId('tool-block-component')).toHaveTextContent('"tool":"search"')
|
||||
expect(screen.getByTestId('tool-block-component')).toHaveTextContent('"label":"Search"')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,56 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
ToolBlockContextProvider,
|
||||
useToolBlockContext,
|
||||
} from '../tool-block-context'
|
||||
|
||||
describe('ToolBlockContextProvider', () => {
|
||||
it('should fall back to a null context without a provider', () => {
|
||||
const { result } = renderHook(() => useToolBlockContext())
|
||||
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
|
||||
it('should expose selected values and update subscribers', () => {
|
||||
let value = {
|
||||
nodeId: 'node-1',
|
||||
useModal: true,
|
||||
disableToolBlocks: false,
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<ToolBlockContextProvider value={value}>
|
||||
{children}
|
||||
</ToolBlockContextProvider>
|
||||
)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useToolBlockContext(context => context?.nodeId),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe('node-1')
|
||||
|
||||
value = {
|
||||
nodeId: 'node-2',
|
||||
useModal: false,
|
||||
disableToolBlocks: true,
|
||||
}
|
||||
rerender()
|
||||
|
||||
expect(result.current).toBe('node-2')
|
||||
})
|
||||
|
||||
it('should treat an undefined provider value as null', () => {
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<ToolBlockContextProvider>
|
||||
{children}
|
||||
</ToolBlockContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useToolBlockContext(), { wrapper })
|
||||
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
|
||||
import {
|
||||
getNodeCount,
|
||||
renderLexicalEditor,
|
||||
setEditorRootText,
|
||||
waitForEditorReady,
|
||||
} from '@/app/components/base/prompt-editor/plugins/test-helpers'
|
||||
import {
|
||||
ToolBlockNode,
|
||||
} from '../node'
|
||||
import ToolBlockReplacementBlock from '../tool-block-replacement-block'
|
||||
import { buildToolToken } from '../utils'
|
||||
|
||||
vi.mock('../component', () => ({
|
||||
default: ({ tool }: { tool: string }) => <div>{tool}</div>,
|
||||
}))
|
||||
|
||||
const token = buildToolToken({
|
||||
provider: 'openai/tools',
|
||||
tool: 'search',
|
||||
configId: '11111111-1111-4111-8111-111111111111',
|
||||
})
|
||||
|
||||
const renderReplacementPlugin = () => {
|
||||
return renderLexicalEditor({
|
||||
namespace: 'tool-block-replacement-plugin-test',
|
||||
nodes: [CustomTextNode, ToolBlockNode],
|
||||
children: <ToolBlockReplacementBlock />,
|
||||
})
|
||||
}
|
||||
|
||||
describe('ToolBlockReplacementBlock', () => {
|
||||
it('should replace serialized tool tokens with tool block nodes', async () => {
|
||||
const { getEditor } = renderReplacementPlugin()
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(editor, `prefix ${token} suffix`, text => new CustomTextNode(text))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeCount(editor, ToolBlockNode)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave plain text untouched when no token is present', async () => {
|
||||
const { getEditor } = renderReplacementPlugin()
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(editor, 'plain text without tokens', text => new CustomTextNode(text))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeCount(editor, ToolBlockNode)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw when the tool block node is not registered', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'tool-block-replacement-plugin-missing-node-test',
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [CustomTextNode],
|
||||
}}
|
||||
>
|
||||
<ToolBlockReplacementBlock />
|
||||
</LexicalComposer>,
|
||||
)
|
||||
}).toThrow('ToolBlockReplacementBlock: ToolBlockNode not registered on editor')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
createLexicalTestEditor,
|
||||
} from '@/app/components/base/prompt-editor/plugins/test-helpers'
|
||||
import {
|
||||
$createToolGroupBlockNode,
|
||||
$isToolGroupBlockNode,
|
||||
ToolGroupBlockNode,
|
||||
} from '../tool-group-block-node'
|
||||
import { buildToolTokenList } from '../utils'
|
||||
|
||||
vi.mock('../tool-group-block-component', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-testid="tool-group-block-component">{JSON.stringify(props)}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const tools = [
|
||||
{
|
||||
provider: 'openai/tools',
|
||||
tool: 'search',
|
||||
configId: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
tool: 'browse',
|
||||
configId: '22222222-2222-4222-8222-222222222222',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ToolGroupBlockNode', () => {
|
||||
it('should expose lexical metadata and serialize its payload', () => {
|
||||
const editor = createLexicalTestEditor('tool-group-block-node-metadata-test', [ToolGroupBlockNode])
|
||||
let node!: ToolGroupBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createToolGroupBlockNode({ tools })
|
||||
})
|
||||
})
|
||||
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(ToolGroupBlockNode.getType()).toBe('tool-group-block')
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'tool-group-block',
|
||||
version: 1,
|
||||
tools,
|
||||
})
|
||||
expect(node.getTextContent()).toBe(buildToolTokenList(tools))
|
||||
expect($isToolGroupBlockNode(node)).toBe(true)
|
||||
expect($isToolGroupBlockNode(null)).toBe(false)
|
||||
expect(dom.tagName).toBe('SPAN')
|
||||
expect(dom).toHaveClass('inline-flex', 'items-center', 'align-middle')
|
||||
})
|
||||
|
||||
it('should clone and import serialized nodes', () => {
|
||||
const editor = createLexicalTestEditor('tool-group-block-node-clone-test', [ToolGroupBlockNode])
|
||||
let original!: ToolGroupBlockNode
|
||||
let cloned!: ToolGroupBlockNode
|
||||
let imported!: ToolGroupBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
original = $createToolGroupBlockNode({ tools })
|
||||
cloned = ToolGroupBlockNode.clone(original)
|
||||
imported = ToolGroupBlockNode.importJSON({
|
||||
type: 'tool-group-block',
|
||||
version: 1,
|
||||
tools: tools.slice(0, 1),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.exportJSON()).toEqual(original.exportJSON())
|
||||
expect(imported.exportJSON()).toEqual({
|
||||
type: 'tool-group-block',
|
||||
version: 1,
|
||||
tools: tools.slice(0, 1),
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate the node with the tool group payload', () => {
|
||||
const editor = createLexicalTestEditor('tool-group-block-node-decorate-test', [ToolGroupBlockNode])
|
||||
let node!: ToolGroupBlockNode
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
node = $createToolGroupBlockNode({ tools })
|
||||
})
|
||||
})
|
||||
|
||||
render(node.decorate())
|
||||
|
||||
expect(screen.getByTestId('tool-group-block-component')).toHaveTextContent('"provider":"openai/tools"')
|
||||
expect(screen.getByTestId('tool-group-block-component')).toHaveTextContent('"provider":"anthropic"')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
|
||||
import {
|
||||
getNodeCount,
|
||||
renderLexicalEditor,
|
||||
setEditorRootText,
|
||||
waitForEditorReady,
|
||||
} from '@/app/components/base/prompt-editor/plugins/test-helpers'
|
||||
import { ToolGroupBlockNode } from '../tool-group-block-node'
|
||||
import ToolGroupBlockReplacementBlock from '../tool-group-block-replacement-block'
|
||||
import { buildToolTokenList } from '../utils'
|
||||
|
||||
vi.mock('../tool-group-block-component', () => ({
|
||||
default: ({ tools }: { tools: Array<{ tool: string }> }) => <div>{tools.map(tool => tool.tool).join(',')}</div>,
|
||||
}))
|
||||
|
||||
const tokenList = buildToolTokenList([
|
||||
{
|
||||
provider: 'openai/tools',
|
||||
tool: 'search',
|
||||
configId: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
tool: 'browse',
|
||||
configId: '22222222-2222-4222-8222-222222222222',
|
||||
},
|
||||
])
|
||||
|
||||
const renderReplacementPlugin = () => {
|
||||
return renderLexicalEditor({
|
||||
namespace: 'tool-group-block-replacement-plugin-test',
|
||||
nodes: [CustomTextNode, ToolGroupBlockNode],
|
||||
children: <ToolGroupBlockReplacementBlock />,
|
||||
})
|
||||
}
|
||||
|
||||
describe('ToolGroupBlockReplacementBlock', () => {
|
||||
it('should replace serialized tool token lists with tool group nodes', async () => {
|
||||
const { getEditor } = renderReplacementPlugin()
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(editor, `prefix ${tokenList} suffix`, text => new CustomTextNode(text))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeCount(editor, ToolGroupBlockNode)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave plain text untouched when no tool token list is present', async () => {
|
||||
const { getEditor } = renderReplacementPlugin()
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(editor, 'plain text without tokens', text => new CustomTextNode(text))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeCount(editor, ToolGroupBlockNode)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw when the tool group block node is not registered', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'tool-group-block-replacement-plugin-missing-node-test',
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [CustomTextNode],
|
||||
}}
|
||||
>
|
||||
<ToolGroupBlockReplacementBlock />
|
||||
</LexicalComposer>,
|
||||
)
|
||||
}).toThrow('ToolGroupBlockReplacementBlock: ToolGroupBlockNode not registered on editor')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ToolHeader from '../tool-header'
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-testid="app-icon">{JSON.stringify(props)}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ToolHeader', () => {
|
||||
it('should render labels and close action without an icon', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<ToolHeader
|
||||
icon={undefined}
|
||||
providerLabel="Provider"
|
||||
toolLabel="Tool"
|
||||
description="Description"
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
expect(screen.getByText('Description')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render a remote icon and invoke the back action', () => {
|
||||
const onBack = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<ToolHeader
|
||||
icon="https://cdn.example.com/icon.png"
|
||||
providerLabel="Provider"
|
||||
toolLabel="Tool"
|
||||
description="Description"
|
||||
onClose={vi.fn()}
|
||||
onBack={onBack}
|
||||
backLabel="Back to tools"
|
||||
/>,
|
||||
)
|
||||
|
||||
const remoteIcon = container.querySelector('[style*="background-image"]')
|
||||
|
||||
expect(remoteIcon).toHaveStyle({ backgroundImage: 'url(https://cdn.example.com/icon.png)' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to tools' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render app icons for both icon names and emoji payloads', () => {
|
||||
const { rerender } = render(
|
||||
<ToolHeader
|
||||
icon="ri-search-line"
|
||||
providerLabel="Provider"
|
||||
toolLabel="Tool"
|
||||
description="Description"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-icon')).toHaveTextContent('"icon":"ri-search-line"')
|
||||
|
||||
rerender(
|
||||
<ToolHeader
|
||||
icon={{ content: 'moon', background: '#000000' }}
|
||||
providerLabel="Provider"
|
||||
toolLabel="Tool"
|
||||
description="Description"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-icon')).toHaveTextContent('"icon":"moon"')
|
||||
expect(screen.getByTestId('app-icon')).toHaveTextContent('"background":"#000000"')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
import {
|
||||
buildToolToken,
|
||||
buildToolTokenList,
|
||||
getToolTokenListRegexString,
|
||||
getToolTokenRegexString,
|
||||
parseToolToken,
|
||||
parseToolTokenList,
|
||||
} from '../utils'
|
||||
|
||||
const firstToken = {
|
||||
provider: 'openai/tools',
|
||||
tool: 'search',
|
||||
configId: '11111111-1111-4111-8111-111111111111',
|
||||
}
|
||||
|
||||
const secondToken = {
|
||||
provider: 'anthropic',
|
||||
tool: 'browse',
|
||||
configId: '22222222-2222-4222-8222-222222222222',
|
||||
}
|
||||
|
||||
describe('tool-block utils', () => {
|
||||
it('should expose regexes that match tool tokens and token lists', () => {
|
||||
const tokenRegex = new RegExp(`^${getToolTokenRegexString()}$`)
|
||||
const listRegex = new RegExp(`^${getToolTokenListRegexString()}$`)
|
||||
|
||||
expect(tokenRegex.test(buildToolToken(firstToken))).toBe(true)
|
||||
expect(tokenRegex.test('§[tool].[bad token]§')).toBe(false)
|
||||
expect(listRegex.test(buildToolTokenList([firstToken, secondToken]))).toBe(true)
|
||||
})
|
||||
|
||||
it('should parse tool tokens and token lists', () => {
|
||||
expect(parseToolToken(buildToolToken(firstToken))).toEqual(firstToken)
|
||||
expect(parseToolToken('plain-text')).toBeNull()
|
||||
expect(parseToolTokenList(buildToolTokenList([firstToken, secondToken]))).toEqual([
|
||||
firstToken,
|
||||
secondToken,
|
||||
])
|
||||
expect(parseToolTokenList('[plain-text')).toBeNull()
|
||||
expect(parseToolTokenList('[]')).toBeNull()
|
||||
expect(parseToolTokenList('[ , ]')).toBeNull()
|
||||
expect(parseToolTokenList('[plain-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should build serialized tool tokens and lists', () => {
|
||||
expect(buildToolToken(firstToken)).toBe('§[tool].[openai/tools].[search].[11111111-1111-4111-8111-111111111111]§')
|
||||
expect(buildToolTokenList([firstToken, secondToken])).toBe(
|
||||
`[${buildToolToken(firstToken)},${buildToolToken(secondToken)}]`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
|
||||
import ToolSettingsSection from '../tool-settings-section'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
formProps: [] as Array<Record<string, unknown>>,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <div data-testid="divider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocks.formProps.push(props)
|
||||
const variable = (props.schemas as Array<{ variable: string }>)[0]?.variable ?? 'unknown'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`reasoning-form-${variable}`}
|
||||
onClick={() => (props.onChange as (value: Record<string, unknown>) => void)({
|
||||
[variable]: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: 'constant',
|
||||
value: 'updated',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
{variable}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolParametersToFormSchemas: (params: Array<{ name: string, type: string, default?: unknown }>) => {
|
||||
return params.map(param => ({
|
||||
variable: param.name,
|
||||
type: param.type,
|
||||
default: param.default,
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.formProps.length = 0
|
||||
})
|
||||
|
||||
describe('ToolSettingsSection', () => {
|
||||
it('should return null when the provider is not team-authorized or when there are no schemas', () => {
|
||||
const { rerender, container } = render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={{ is_team_authorization: false } as never}
|
||||
currentTool={{
|
||||
parameters: [{ name: 'temperature', form: 'basic', type: FormTypeEnum.textInput }],
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(
|
||||
<ToolSettingsSection
|
||||
currentProvider={{ is_team_authorization: true } as never}
|
||||
currentTool={{ parameters: [] } as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should build safe config values and merge settings and params changes', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={{ is_team_authorization: true } as never}
|
||||
currentTool={{
|
||||
parameters: [
|
||||
{ name: 'model', form: 'basic', type: FormTypeEnum.modelSelector, default: 'gpt-4.1' },
|
||||
{ name: 'attachment', form: 'llm', type: FormTypeEnum.file, default: null },
|
||||
],
|
||||
} as never}
|
||||
value={{
|
||||
provider_id: 'provider-1',
|
||||
tool_name: 'tool-1',
|
||||
settings: {},
|
||||
parameters: {},
|
||||
} as never}
|
||||
enableVariableReference
|
||||
nodesOutputVars={[]}
|
||||
availableNodes={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('divider')).toBeInTheDocument()
|
||||
expect(screen.getByText('detailPanel.toolSelector.reasoningConfig')).toBeInTheDocument()
|
||||
expect(mocks.formProps).toHaveLength(2)
|
||||
expect(mocks.formProps[0]).toMatchObject({
|
||||
nodeId: 'workflow',
|
||||
disableVariableReference: false,
|
||||
value: {
|
||||
model: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.constant,
|
||||
value: 'gpt-4.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mocks.formProps[1]).toMatchObject({
|
||||
nodeId: 'workflow',
|
||||
disableVariableReference: false,
|
||||
value: {
|
||||
attachment: {
|
||||
auto: 1,
|
||||
value: {
|
||||
type: VarKindType.variable,
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('reasoning-form-model'))
|
||||
fireEvent.click(screen.getByTestId('reasoning-form-attachment'))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
settings: {
|
||||
model: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: 'constant',
|
||||
value: 'updated',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
parameters: {
|
||||
attachment: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: 'constant',
|
||||
value: 'updated',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import type { SandboxFileDownloadTicket, SandboxFileNode } from '@/types/sandbox-file'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ArtifactsSection from './artifacts-section'
|
||||
import ArtifactsSection from '.././artifacts-section'
|
||||
|
||||
type MockStoreState = {
|
||||
appId: string | undefined
|
||||
@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
appId: 'app-1',
|
||||
selectedArtifactPath: null,
|
||||
} as MockStoreState,
|
||||
flatData: [] as SandboxFileNode[],
|
||||
flatData: undefined as SandboxFileNode[] | undefined,
|
||||
isLoading: false,
|
||||
isDownloading: false,
|
||||
selectArtifact: vi.fn(),
|
||||
@ -66,7 +66,7 @@ describe('ArtifactsSection', () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.storeState.appId = 'app-1'
|
||||
mocks.storeState.selectedArtifactPath = null
|
||||
mocks.flatData = []
|
||||
mocks.flatData = undefined
|
||||
mocks.isLoading = false
|
||||
mocks.isDownloading = false
|
||||
mocks.fetchDownloadUrl.mockResolvedValue({
|
||||
@ -119,6 +119,7 @@ describe('ArtifactsSection', () => {
|
||||
// Covers expanded branches for empty and loading states.
|
||||
describe('Expanded content', () => {
|
||||
it('should render empty state when expanded and there are no files', () => {
|
||||
mocks.flatData = []
|
||||
render(<ArtifactsSection />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
|
||||
@ -134,6 +135,15 @@ describe('ArtifactsSection', () => {
|
||||
|
||||
expect(screen.queryByText('workflow.skillSidebar.artifacts.emptyState')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should treat an undefined query result as an empty tree', () => {
|
||||
render(<ArtifactsSection />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
|
||||
|
||||
expect(screen.getByText('workflow.skillSidebar.artifacts.emptyState')).toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-state-accent-solid')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers real tree integration for selecting and downloading artifacts.
|
||||
@ -1,6 +1,6 @@
|
||||
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ArtifactsTree from './artifacts-tree'
|
||||
import ArtifactsTree from '.././artifacts-tree'
|
||||
|
||||
const createNode = (overrides: Partial<SandboxFileTreeNode> = {}): SandboxFileTreeNode => ({
|
||||
id: 'node-1',
|
||||
@ -0,0 +1,62 @@
|
||||
import { buildTreeFromFlatList } from '../utils'
|
||||
|
||||
describe('artifacts utils', () => {
|
||||
it('should build nested tree nodes from a flat node list', () => {
|
||||
const tree = buildTreeFromFlatList([
|
||||
{
|
||||
path: 'folder',
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime: 1,
|
||||
extension: null,
|
||||
},
|
||||
{
|
||||
path: 'folder/readme.md',
|
||||
is_dir: false,
|
||||
size: 12,
|
||||
mtime: 2,
|
||||
extension: 'md',
|
||||
},
|
||||
{
|
||||
path: 'logo.png',
|
||||
is_dir: false,
|
||||
size: 3,
|
||||
mtime: 3,
|
||||
extension: 'png',
|
||||
},
|
||||
])
|
||||
|
||||
expect(tree).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'folder',
|
||||
node_type: 'folder',
|
||||
children: [
|
||||
expect.objectContaining({
|
||||
id: 'folder/readme.md',
|
||||
node_type: 'file',
|
||||
extension: 'md',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'logo.png',
|
||||
node_type: 'file',
|
||||
children: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should skip nodes whose parent path does not exist in the flat list', () => {
|
||||
const tree = buildTreeFromFlatList([
|
||||
{
|
||||
path: 'missing-parent/readme.md',
|
||||
is_dir: false,
|
||||
size: 7,
|
||||
mtime: 1,
|
||||
extension: 'md',
|
||||
},
|
||||
])
|
||||
|
||||
expect(tree).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ROOT_ID } from '../../constants'
|
||||
import DragActionTooltip from './drag-action-tooltip'
|
||||
import { ROOT_ID } from '../../../constants'
|
||||
import DragActionTooltip from '.././drag-action-tooltip'
|
||||
|
||||
type MockWorkflowState = {
|
||||
dragOverFolderId: string | null
|
||||
@ -18,7 +18,7 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
vi.mock('../../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }),
|
||||
}))
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { HTMLAttributes, ReactNode, Ref } from 'react'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ROOT_ID } from '../../constants'
|
||||
import FileTree from './file-tree'
|
||||
import { ROOT_ID } from '../../../constants'
|
||||
import FileTree from '.././file-tree'
|
||||
|
||||
type MockWorkflowState = {
|
||||
expandedFolderIds: Set<string>
|
||||
@ -216,66 +216,66 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
vi.mock('../../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetTreeData: () => mocks.skillAssetTreeData,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeCollaboration: () => mocks.useSkillTreeCollaboration(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/dnd/use-root-file-drop', () => ({
|
||||
vi.mock('../../../hooks/file-tree/dnd/use-root-file-drop', () => ({
|
||||
useRootFileDrop: () => mocks.rootDropHandlers,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/interaction/use-inline-create-node', () => ({
|
||||
vi.mock('../../../hooks/file-tree/interaction/use-inline-create-node', () => ({
|
||||
useInlineCreateNode: () => mocks.inlineCreateNode,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/interaction/use-skill-shortcuts', () => ({
|
||||
vi.mock('../../../hooks/file-tree/interaction/use-skill-shortcuts', () => ({
|
||||
useSkillShortcuts: (args: unknown) => mocks.useSkillShortcuts(args),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/interaction/use-sync-tree-with-active-tab', () => ({
|
||||
vi.mock('../../../hooks/file-tree/interaction/use-sync-tree-with-active-tab', () => ({
|
||||
useSyncTreeWithActiveTab: (args: unknown) => mocks.useSyncTreeWithActiveTab(args),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/operations/use-node-move', () => ({
|
||||
vi.mock('../../../hooks/file-tree/operations/use-node-move', () => ({
|
||||
useNodeMove: () => ({ executeMoveNode: mocks.executeMoveNode }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/operations/use-node-reorder', () => ({
|
||||
vi.mock('../../../hooks/file-tree/operations/use-node-reorder', () => ({
|
||||
useNodeReorder: () => ({ executeReorderNode: mocks.executeReorderNode }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/operations/use-paste-operation', () => ({
|
||||
vi.mock('../../../hooks/file-tree/operations/use-paste-operation', () => ({
|
||||
usePasteOperation: (args: unknown) => mocks.usePasteOperation(args),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/tree-utils', () => ({
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
isDescendantOf: (parentId: string, nodeId: string, treeChildren: AppAssetTreeView[]) =>
|
||||
mocks.isDescendantOf(parentId, nodeId, treeChildren),
|
||||
}))
|
||||
|
||||
vi.mock('./search-result-list', () => ({
|
||||
vi.mock('.././search-result-list', () => ({
|
||||
default: ({ searchTerm }: { searchTerm: string }) => (
|
||||
<div data-testid="search-result-list">{searchTerm}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./drag-action-tooltip', () => ({
|
||||
vi.mock('.././drag-action-tooltip', () => ({
|
||||
default: ({ action }: { action: string }) => (
|
||||
<div data-testid="drag-action-tooltip">{action}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./upload-status-tooltip', () => ({
|
||||
vi.mock('.././upload-status-tooltip', () => ({
|
||||
default: ({ fallback }: { fallback?: ReactNode }) => (
|
||||
<div data-testid="upload-status-tooltip">{fallback}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./tree-context-menu', () => ({
|
||||
vi.mock('.././tree-context-menu', () => ({
|
||||
default: ({
|
||||
children,
|
||||
treeRef,
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MenuItemProps } from './menu-item'
|
||||
import type { MenuItemProps } from '.././menu-item'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ContextMenu,
|
||||
@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import MenuItem from './menu-item'
|
||||
import MenuItem from '.././menu-item'
|
||||
|
||||
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
|
||||
|
||||
@ -67,6 +67,12 @@ describe('MenuItem', () => {
|
||||
expect(item).not.toHaveClass('w-full')
|
||||
})
|
||||
|
||||
it('should render inside the context menu variant', () => {
|
||||
renderMenuItem({ menuType: 'context', label: 'Reveal' })
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: /reveal/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply destructive variant styles when variant is destructive', () => {
|
||||
// Arrange
|
||||
renderMenuItem({ variant: 'destructive', label: 'Delete' })
|
||||
@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import NodeDeleteConfirmDialog from '../node-delete-confirm-dialog'
|
||||
|
||||
describe('NodeDeleteConfirmDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the file deletion copy and call confirm/cancel handlers', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<NodeDeleteConfirmDialog
|
||||
nodeType="file"
|
||||
open
|
||||
isDeleting={false}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render the folder deletion copy and disable confirm while deleting', () => {
|
||||
render(
|
||||
<NodeDeleteConfirmDialog
|
||||
nodeType="folder"
|
||||
open
|
||||
isDeleting
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmContent')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.confirm/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -10,8 +10,8 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { NODE_MENU_TYPE } from '../../constants'
|
||||
import NodeMenu from './node-menu'
|
||||
import { NODE_MENU_TYPE } from '../../../constants'
|
||||
import NodeMenu from '.././node-menu'
|
||||
|
||||
type MockWorkflowState = {
|
||||
hasClipboard: () => boolean
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SearchResultList from './search-result-list'
|
||||
import SearchResultList from '.././search-result-list'
|
||||
|
||||
type MockWorkflowState = {
|
||||
activeTabId: string | null
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ROOT_ID } from '../../constants'
|
||||
import TreeContextMenu from './tree-context-menu'
|
||||
import { ROOT_ID } from '../../../constants'
|
||||
import TreeContextMenu from '.././tree-context-menu'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
selectedNodeIds: new Set<string>(),
|
||||
@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getNode: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
useFileOperations: vi.fn(),
|
||||
dynamicImporters: [] as Array<() => Promise<unknown>>,
|
||||
fileOperations: {
|
||||
fileInputRef: { current: null },
|
||||
folderInputRef: { current: null },
|
||||
@ -38,8 +39,9 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: (loader: () => Promise<unknown>) => {
|
||||
mocks.dynamicImporters.push(loader)
|
||||
const MockImportSkillModal = ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
@ -57,15 +59,30 @@ vi.mock('next/dynamic', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
|
||||
vi.mock('../../start-tab/import-skill-modal', () => ({
|
||||
default: ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="resolved-import-skill-modal">
|
||||
<button type="button" onClick={onClose}>
|
||||
resolved-close-import-modal
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks/file-tree/operations/use-file-operations', () => ({
|
||||
useFileOperations: (...args: unknown[]) => {
|
||||
mocks.useFileOperations(...args)
|
||||
return mocks.fileOperations
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./node-menu', () => ({
|
||||
default: ({ type, menuType, nodeId, actionNodeIds, onImportSkills }: { type: string, menuType: string, nodeId?: string, actionNodeIds?: string[], onImportSkills?: () => void }) => (
|
||||
vi.mock('.././node-menu', () => ({
|
||||
default: ({ type, menuType, nodeId, actionNodeIds, onImportSkills, onClose }: { type: string, menuType: string, nodeId?: string, actionNodeIds?: string[], onImportSkills?: () => void, onClose?: () => void }) => (
|
||||
<div
|
||||
data-testid={`node-menu-${menuType}`}
|
||||
data-type={type}
|
||||
@ -77,6 +94,11 @@ vi.mock('./node-menu', () => ({
|
||||
open-import-skill-modal
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose}>
|
||||
close-node-menu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -196,6 +218,20 @@ describe('TreeContextMenu', () => {
|
||||
expect(screen.queryByTestId('import-skill-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should wire the dynamic import loader and the menu close callback', async () => {
|
||||
render(
|
||||
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll } as never }}>
|
||||
<div>blank area</div>
|
||||
</TreeContextMenu>,
|
||||
)
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('blank area'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close-node-menu' }))
|
||||
|
||||
expect(mocks.dynamicImporters).toHaveLength(1)
|
||||
await expect(mocks.dynamicImporters[0]()).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('should keep delete confirmation dialog mounted for item context actions', () => {
|
||||
mocks.fileOperations.showDeleteConfirm = true
|
||||
|
||||
@ -212,5 +248,51 @@ describe('TreeContextMenu', () => {
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should confirm folder deletion through the mounted dialog', () => {
|
||||
mocks.fileOperations.showDeleteConfirm = true
|
||||
mocks.getNode.mockReturnValue({
|
||||
select: mocks.selectNode,
|
||||
data: { name: 'docs' },
|
||||
})
|
||||
|
||||
render(
|
||||
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll, get: mocks.getNode } as never }}>
|
||||
<div data-skill-tree-node-id="folder-1" data-skill-tree-node-type="folder" role="treeitem">
|
||||
docs
|
||||
</div>
|
||||
</TreeContextMenu>,
|
||||
)
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('treeitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i, hidden: true }))
|
||||
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.skillSidebar.menu.deleteConfirmContent')).toBeInTheDocument()
|
||||
expect(mocks.fileOperations.handleDeleteConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should ignore context targets without a valid node id or menu type', () => {
|
||||
render(
|
||||
<TreeContextMenu treeRef={{ current: { deselectAll: mocks.deselectAll, get: mocks.getNode } as never }}>
|
||||
<div>
|
||||
<div data-skill-tree-node-id="" data-skill-tree-node-type="file" role="treeitem">
|
||||
invalid-file
|
||||
</div>
|
||||
<div data-skill-tree-node-id="file-2" data-skill-tree-node-type="unknown" role="treeitem">
|
||||
unknown-type
|
||||
</div>
|
||||
</div>
|
||||
</TreeContextMenu>,
|
||||
)
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('invalid-file'))
|
||||
fireEvent.contextMenu(screen.getByText('unknown-type'))
|
||||
|
||||
expect(mocks.getNode).not.toHaveBeenCalled()
|
||||
expect(mocks.setSelectedNodeIds).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-type', 'root')
|
||||
expect(screen.getByTestId('node-menu-context')).toHaveAttribute('data-node-id', ROOT_ID)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../type'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import TreeEditInput from './tree-edit-input'
|
||||
import TreeEditInput from '.././tree-edit-input'
|
||||
|
||||
type MockNodeApi = Pick<NodeApi<TreeNodeData>, 'data' | 'reset' | 'tree'>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import TreeGuideLines from './tree-guide-lines'
|
||||
import TreeGuideLines from '.././tree-guide-lines'
|
||||
|
||||
describe('TreeGuideLines', () => {
|
||||
beforeEach(() => {
|
||||
@ -1,11 +1,11 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TreeNodeIcon } from './tree-node-icon'
|
||||
import { TreeNodeIcon } from '.././tree-node-icon'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFileIconType: vi.fn(() => 'document'),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/file-utils', () => ({
|
||||
vi.mock('../../../utils/file-utils', () => ({
|
||||
getFileIconType: mocks.getFileIconType,
|
||||
}))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { NodeApi, NodeRendererProps, TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../type'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import TreeNode from './tree-node'
|
||||
import TreeNode from '.././tree-node'
|
||||
|
||||
type MockWorkflowSelectorState = {
|
||||
dirtyContents: Set<string>
|
||||
@ -87,7 +87,7 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
|
||||
vi.mock('../../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
|
||||
useTreeNodeHandlers: () => ({
|
||||
handleClick: handlerMocks.handleClick,
|
||||
handleDoubleClick: handlerMocks.handleDoubleClick,
|
||||
@ -96,7 +96,7 @@ vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
|
||||
vi.mock('../../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
|
||||
useFolderFileDrop: () => ({
|
||||
isDragOver: dndMocks.isDragOver,
|
||||
isBlinking: dndMocks.isBlinking,
|
||||
@ -109,11 +109,11 @@ vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
|
||||
vi.mock('../../../hooks/file-tree/operations/use-file-operations', () => ({
|
||||
useFileOperations: () => fileOperationMocks,
|
||||
}))
|
||||
|
||||
vi.mock('./node-menu', () => ({
|
||||
vi.mock('.././node-menu', () => ({
|
||||
default: ({
|
||||
type,
|
||||
menuType,
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import UploadStatusTooltip from './upload-status-tooltip'
|
||||
import UploadStatusTooltip from '.././upload-status-tooltip'
|
||||
|
||||
type MockWorkflowState = {
|
||||
uploadStatus: 'idle' | 'uploading' | 'success' | 'partial_error'
|
||||
@ -0,0 +1,56 @@
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useFetchTextContent } from '../use-fetch-text-content'
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
|
||||
describe('use-fetch-text-content', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('should fetch and cache text content when a download url is provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
text: vi.fn().mockResolvedValue('hello world'),
|
||||
} as unknown as Response)
|
||||
|
||||
const { result } = renderHook(() => useFetchTextContent('https://example.com/file.txt'), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.data).toBe('hello world'))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.com/file.txt')
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should stay idle when the download url is missing', () => {
|
||||
const { result } = renderHook(() => useFetchTextContent(undefined), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.current.fetchStatus).toBe('idle')
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useFileNodeViewState } from './use-file-node-view-state'
|
||||
import { useFileNodeViewState as useFileNodeViewPhase } from '.././use-file-node-view-state'
|
||||
|
||||
type HookProps = {
|
||||
fileTabId: string | null
|
||||
@ -21,7 +21,7 @@ const createProps = (overrides: Partial<HookProps> = {}): HookProps => ({
|
||||
describe('useFileNodeViewState', () => {
|
||||
describe('resolution lifecycle', () => {
|
||||
it('should return ready when there is no active file tab', () => {
|
||||
const { result } = renderHook(() => useFileNodeViewState(createProps({
|
||||
const { result } = renderHook(() => useFileNodeViewPhase(createProps({
|
||||
fileTabId: null,
|
||||
})))
|
||||
|
||||
@ -29,14 +29,14 @@ describe('useFileNodeViewState', () => {
|
||||
})
|
||||
|
||||
it('should return resolving during initial node resolution', () => {
|
||||
const { result } = renderHook(() => useFileNodeViewState(createProps()))
|
||||
const { result } = renderHook(() => useFileNodeViewPhase(createProps()))
|
||||
|
||||
expect(result.current).toBe('resolving')
|
||||
})
|
||||
|
||||
it('should return missing when query settles without a matching node', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(props: HookProps) => useFileNodeViewState(props),
|
||||
(props: HookProps) => useFileNodeViewPhase(props),
|
||||
{ initialProps: createProps() },
|
||||
)
|
||||
|
||||
@ -51,7 +51,7 @@ describe('useFileNodeViewState', () => {
|
||||
|
||||
it('should stay missing during background refetch after missing is resolved', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(props: HookProps) => useFileNodeViewState(props),
|
||||
(props: HookProps) => useFileNodeViewPhase(props),
|
||||
{ initialProps: createProps() },
|
||||
)
|
||||
|
||||
@ -76,7 +76,7 @@ describe('useFileNodeViewState', () => {
|
||||
|
||||
it('should become ready once the target node appears', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(props: HookProps) => useFileNodeViewState(props),
|
||||
(props: HookProps) => useFileNodeViewPhase(props),
|
||||
{ initialProps: createProps() },
|
||||
)
|
||||
|
||||
@ -92,7 +92,7 @@ describe('useFileNodeViewState', () => {
|
||||
|
||||
it('should reset to resolving when switching to another file tab', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(props: HookProps) => useFileNodeViewState(props),
|
||||
(props: HookProps) => useFileNodeViewPhase(props),
|
||||
{ initialProps: createProps({
|
||||
isNodeMapLoading: false,
|
||||
isNodeMapFetching: false,
|
||||
@ -0,0 +1,63 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useFileTypeInfo } from '../use-file-type-info'
|
||||
|
||||
describe('useFileTypeInfo', () => {
|
||||
it('should return a non-previewable default state when the file node is missing', () => {
|
||||
const { result } = renderHook(() => useFileTypeInfo(undefined))
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isMarkdown: false,
|
||||
isCodeOrText: false,
|
||||
isImage: false,
|
||||
isVideo: false,
|
||||
isPdf: false,
|
||||
isSQLite: false,
|
||||
isEditable: false,
|
||||
isMediaFile: false,
|
||||
isPreviewable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should classify markdown and editable files from their file name', () => {
|
||||
const { result } = renderHook(() => useFileTypeInfo({
|
||||
name: 'README.md',
|
||||
}))
|
||||
|
||||
expect(result.current.isMarkdown).toBe(true)
|
||||
expect(result.current.isEditable).toBe(true)
|
||||
expect(result.current.isCodeOrText).toBe(false)
|
||||
expect(result.current.isPreviewable).toBe(true)
|
||||
})
|
||||
|
||||
it('should use an explicit extension override when provided', () => {
|
||||
const { result } = renderHook(() => useFileTypeInfo({
|
||||
name: 'README',
|
||||
extension: '.PDF',
|
||||
}))
|
||||
|
||||
expect(result.current.isPdf).toBe(true)
|
||||
expect(result.current.isPreviewable).toBe(true)
|
||||
expect(result.current.isEditable).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to the file name when the explicit extension is null', () => {
|
||||
const { result } = renderHook(() => useFileTypeInfo({
|
||||
name: 'clip.mp4',
|
||||
extension: null,
|
||||
}))
|
||||
|
||||
expect(result.current.isVideo).toBe(true)
|
||||
expect(result.current.isMediaFile).toBe(true)
|
||||
expect(result.current.isPreviewable).toBe(true)
|
||||
})
|
||||
|
||||
it('should classify sqlite files as non-editable previews', () => {
|
||||
const { result } = renderHook(() => useFileTypeInfo({
|
||||
name: 'data.sqlite',
|
||||
}))
|
||||
|
||||
expect(result.current.isSQLite).toBe(true)
|
||||
expect(result.current.isEditable).toBe(false)
|
||||
expect(result.current.isPreviewable).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,11 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useSkillAutoSave } from './use-skill-auto-save'
|
||||
import { useSkillAutoSave } from '.././use-skill-auto-save'
|
||||
|
||||
const { mockSaveAllDirty } = vi.hoisted(() => ({
|
||||
mockSaveAllDirty: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./skill-save-context', () => ({
|
||||
vi.mock('.././skill-save-context', () => ({
|
||||
useSkillSaveManager: () => ({
|
||||
saveAllDirty: mockSaveAllDirty,
|
||||
}),
|
||||
@ -1,5 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useSkillFileData } from './use-skill-file-data'
|
||||
import { useSkillFileData } from '.././use-skill-file-data'
|
||||
|
||||
const { mockUseQuery, mockContentOptions, mockDownloadUrlOptions } = vi.hoisted(() => ({
|
||||
mockUseQuery: vi.fn(),
|
||||
@ -5,9 +5,9 @@ import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { START_TAB_ID } from '../constants'
|
||||
import { useSkillSaveManager } from './skill-save-context'
|
||||
import { SkillSaveProvider } from './use-skill-save-manager'
|
||||
import { START_TAB_ID } from '../../constants'
|
||||
import { useSkillSaveManager } from '.././skill-save-context'
|
||||
import { SkillSaveProvider } from '.././use-skill-save-manager'
|
||||
|
||||
const {
|
||||
mockMutateAsync,
|
||||
@ -42,7 +42,7 @@ vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../collaboration/skills/skill-collaboration-manager', () => ({
|
||||
vi.mock('../../../collaboration/skills/skill-collaboration-manager', () => ({
|
||||
skillCollaborationManager: {
|
||||
isFileCollaborative: (fileId: string) => mockIsFileCollaborative(fileId),
|
||||
isLeader: (fileId: string) => mockIsLeader(fileId),
|
||||
@ -64,11 +64,11 @@ const createWrapper = (params: { appId: string, store: ReturnType<typeof createW
|
||||
const { appId, store, queryClient } = params
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowContext.Provider value={store}>
|
||||
<WorkflowContext value={store}>
|
||||
<SkillSaveProvider appId={appId}>
|
||||
{children}
|
||||
</SkillSaveProvider>
|
||||
</WorkflowContext.Provider>
|
||||
</WorkflowContext>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,330 @@
|
||||
import {
|
||||
act,
|
||||
renderHook,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class MemoryVFS {
|
||||
name = 'memory'
|
||||
mapNameToFile = new Map<string, {
|
||||
name: string
|
||||
flags: number
|
||||
size: number
|
||||
data: ArrayBuffer
|
||||
}>()
|
||||
|
||||
constructor() {
|
||||
mocks.vfsInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetch: vi.fn<typeof fetch>(),
|
||||
sqliteFactory: vi.fn(),
|
||||
sqliteESMFactory: vi.fn(async () => ({ wasm: true })),
|
||||
execWithParams: vi.fn(),
|
||||
openV2: vi.fn(),
|
||||
close: vi.fn(),
|
||||
vfsRegister: vi.fn(),
|
||||
vfsInstances: [] as MemoryVFS[],
|
||||
MemoryVFS,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('wa-sqlite/dist/wa-sqlite.mjs', () => ({
|
||||
default: () => mocks.sqliteESMFactory(),
|
||||
}))
|
||||
|
||||
vi.mock('wa-sqlite', () => ({
|
||||
Factory: () => ({
|
||||
execWithParams: mocks.execWithParams,
|
||||
open_v2: mocks.openV2,
|
||||
close: mocks.close,
|
||||
vfs_register: mocks.vfsRegister,
|
||||
}),
|
||||
SQLITE_OPEN_READONLY: 1,
|
||||
}))
|
||||
|
||||
vi.mock('wa-sqlite/src/examples/MemoryVFS.js', () => ({
|
||||
MemoryVFS: mocks.MemoryVFS,
|
||||
}))
|
||||
|
||||
describe('useSQLiteDatabase', () => {
|
||||
type HookProps = {
|
||||
url: string | undefined
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.vfsInstances.length = 0
|
||||
vi.stubGlobal('fetch', mocks.fetch)
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => 'uuid-1',
|
||||
})
|
||||
|
||||
mocks.openV2.mockResolvedValue(99)
|
||||
mocks.close.mockResolvedValue(undefined)
|
||||
mocks.execWithParams.mockImplementation(async (_db: number, sql: string) => {
|
||||
if (sql.includes('sqlite_master')) {
|
||||
return {
|
||||
columns: ['name'],
|
||||
rows: [['users'], ['empty']],
|
||||
}
|
||||
}
|
||||
|
||||
if (sql === 'SELECT * FROM "users" LIMIT 5') {
|
||||
return {
|
||||
columns: ['id', 'name'],
|
||||
rows: [[1, 'Ada']],
|
||||
}
|
||||
}
|
||||
|
||||
if (sql === 'SELECT * FROM "users" LIMIT 2') {
|
||||
return {
|
||||
columns: ['id', 'name'],
|
||||
rows: [[1, 'Ada']],
|
||||
}
|
||||
}
|
||||
|
||||
if (sql === 'SELECT * FROM "empty"') {
|
||||
return {
|
||||
columns: [],
|
||||
rows: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (sql === 'PRAGMA table_info("empty")') {
|
||||
return {
|
||||
columns: ['cid', 'name'],
|
||||
rows: [[0, 'id'], [1, 'name']],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
columns: ['id'],
|
||||
rows: [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const importHook = async () => {
|
||||
vi.resetModules()
|
||||
const hookModule = await import('../use-sqlite-database')
|
||||
const constantsModule = await import('../sqlite/constants')
|
||||
return {
|
||||
useSQLiteDatabase: hookModule.useSQLiteDatabase,
|
||||
TABLES_QUERY: constantsModule.TABLES_QUERY,
|
||||
}
|
||||
}
|
||||
|
||||
it('should load tables and query cached table data', async () => {
|
||||
mocks.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
|
||||
const { useSQLiteDatabase, TABLES_QUERY } = await importHook()
|
||||
|
||||
const { result } = renderHook(() => useSQLiteDatabase('https://example.com/demo.db'))
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('https://example.com/demo.db', { signal: expect.any(AbortSignal) })
|
||||
expect(mocks.execWithParams).toHaveBeenCalledWith(99, TABLES_QUERY, [])
|
||||
|
||||
const first = await act(async () => result.current.queryTable('users', 5))
|
||||
expect(first).toEqual({
|
||||
columns: ['id', 'name'],
|
||||
values: [[1, 'Ada']],
|
||||
})
|
||||
|
||||
const second = await act(async () => result.current.queryTable('users', 5))
|
||||
expect(second).toEqual(first)
|
||||
expect(mocks.execWithParams).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should derive columns from pragma output when a table has no selected columns', async () => {
|
||||
mocks.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result } = renderHook(() => useSQLiteDatabase('https://example.com/demo.db'))
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toContain('empty'))
|
||||
|
||||
const data = await act(async () => result.current.queryTable('empty'))
|
||||
|
||||
expect(data).toEqual({
|
||||
columns: ['id', 'name'],
|
||||
values: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when querying an unknown table or before initialization', async () => {
|
||||
mocks.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result } = renderHook(() => useSQLiteDatabase('https://example.com/demo.db'))
|
||||
|
||||
expect(await act(async () => result.current.queryTable('users'))).toBeNull()
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
|
||||
expect(await act(async () => result.current.queryTable('missing'))).toBeNull()
|
||||
})
|
||||
|
||||
it('should surface fetch errors and reset when the url is removed', async () => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
arrayBuffer: vi.fn(),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ url }: HookProps) => useSQLiteDatabase(url),
|
||||
{
|
||||
initialProps: {
|
||||
url: 'https://example.com/broken.db',
|
||||
} as HookProps,
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.error?.message).toBe('Failed to fetch database: 500'))
|
||||
|
||||
rerender({ url: 'https://example.com/demo.db' } as HookProps)
|
||||
await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
|
||||
|
||||
rerender({ url: undefined } as HookProps)
|
||||
await waitFor(() => expect(result.current.tables).toEqual([]))
|
||||
expect(mocks.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use a fallback temporary file name when crypto.randomUUID is unavailable', async () => {
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
mocks.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result } = renderHook(() => useSQLiteDatabase('https://example.com/fallback.db'))
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual(['users', 'empty']))
|
||||
expect(mocks.openV2.mock.calls[0][0]).toMatch(/^preview-1700000000000-/)
|
||||
})
|
||||
|
||||
it('should ignore a resolved fetch when the hook is cancelled before the response arrives', async () => {
|
||||
let resolveFetch!: (value: Response) => void
|
||||
const fetchPromise = new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
mocks.fetch.mockReturnValue(fetchPromise)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result, rerender } = renderHook(
|
||||
({ url }: HookProps) => useSQLiteDatabase(url),
|
||||
{ initialProps: { url: 'https://example.com/cancel.db' } as HookProps },
|
||||
)
|
||||
|
||||
rerender({ url: undefined } as HookProps)
|
||||
resolveFetch({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual([]))
|
||||
expect(mocks.openV2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore an array buffer that resolves after cancellation', async () => {
|
||||
let resolveArrayBuffer!: (value: ArrayBuffer) => void
|
||||
const arrayBufferMock = vi.fn().mockImplementation(() => new Promise<ArrayBuffer>((resolve) => {
|
||||
resolveArrayBuffer = resolve
|
||||
}))
|
||||
mocks.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: arrayBufferMock,
|
||||
} as unknown as Response)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result, rerender } = renderHook(
|
||||
({ url }: HookProps) => useSQLiteDatabase(url),
|
||||
{ initialProps: { url: 'https://example.com/cancel-buffer.db' } as HookProps },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(arrayBufferMock).toHaveBeenCalledTimes(1))
|
||||
rerender({ url: undefined } as HookProps)
|
||||
resolveArrayBuffer(new ArrayBuffer(8))
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual([]))
|
||||
expect(mocks.openV2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close a database opened after cancellation and remove its temp file', async () => {
|
||||
let resolveOpen!: (value: number) => void
|
||||
mocks.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as unknown as Response)
|
||||
mocks.openV2.mockImplementationOnce(() => new Promise<number>((resolve) => {
|
||||
resolveOpen = resolve
|
||||
}))
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result, rerender } = renderHook(
|
||||
({ url }: HookProps) => useSQLiteDatabase(url),
|
||||
{ initialProps: { url: 'https://example.com/cancel-open.db' } as HookProps },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mocks.openV2).toHaveBeenCalledTimes(1))
|
||||
rerender({ url: undefined } as HookProps)
|
||||
resolveOpen(777)
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual([]))
|
||||
expect(mocks.close).toHaveBeenCalledWith(777)
|
||||
expect(mocks.vfsInstances.at(-1)?.mapNameToFile.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should wrap non-error failures when initialization rejects', async () => {
|
||||
mocks.fetch.mockRejectedValue('network failure')
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result } = renderHook(() => useSQLiteDatabase('https://example.com/non-error.db'))
|
||||
|
||||
await waitFor(() => expect(result.current.error?.message).toBe('network failure'))
|
||||
expect(result.current.error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('should ignore rejected initialization after the hook has been cancelled', async () => {
|
||||
let rejectFetch!: (reason?: unknown) => void
|
||||
const fetchPromise = new Promise<Response>((_, reject) => {
|
||||
rejectFetch = reject
|
||||
})
|
||||
mocks.fetch.mockReturnValue(fetchPromise)
|
||||
|
||||
const { useSQLiteDatabase } = await importHook()
|
||||
const { result, rerender } = renderHook(
|
||||
({ url }: HookProps) => useSQLiteDatabase(url),
|
||||
{ initialProps: { url: 'https://example.com/cancel-error.db' } as HookProps },
|
||||
)
|
||||
|
||||
rerender({ url: undefined } as HookProps)
|
||||
rejectFetch(new Error('late failure'))
|
||||
|
||||
await waitFor(() => expect(result.current.tables).toEqual([]))
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -7,7 +7,7 @@ import {
|
||||
useExistingSkillNames,
|
||||
useSkillAssetNodeMap,
|
||||
useSkillAssetTreeData,
|
||||
} from './use-skill-asset-tree'
|
||||
} from '.././use-skill-asset-tree'
|
||||
|
||||
const { mockUseQuery, mockAppAssetTreeOptions } = vi.hoisted(() => ({
|
||||
mockUseQuery: vi.fn(),
|
||||
@ -11,7 +11,7 @@ import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useSkillTreeCollaboration,
|
||||
useSkillTreeUpdateEmitter,
|
||||
} from './use-skill-tree-collaboration'
|
||||
} from '.././use-skill-tree-collaboration'
|
||||
|
||||
const {
|
||||
mockEmitTreeUpdate,
|
||||
@ -176,6 +176,45 @@ describe('useFileDrop', () => {
|
||||
expect(store.getState().uploadStatus).toBe('idle')
|
||||
})
|
||||
|
||||
it('should ignore non-file items and null files from the drop payload', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const event = createDragEvent({
|
||||
items: [
|
||||
createDataTransferItem({ kind: 'string' }),
|
||||
createDataTransferItem({ file: null }),
|
||||
],
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-null')
|
||||
})
|
||||
|
||||
expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled()
|
||||
expect(mockUploadMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle drag payloads with missing item collections', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
types: ['Files'],
|
||||
dropEffect: 'none',
|
||||
},
|
||||
} as unknown as React.DragEvent
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(event, null)
|
||||
})
|
||||
|
||||
expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled()
|
||||
expect(mockUploadMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload valid files while rejecting directories in a mixed drop payload', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants'
|
||||
import { useFolderFileDrop } from './use-folder-file-drop'
|
||||
import { INTERNAL_NODE_DRAG_TYPE } from '../../../../constants'
|
||||
import { useFolderFileDrop } from '.././use-folder-file-drop'
|
||||
|
||||
const {
|
||||
mockHandleDragOver,
|
||||
@ -16,7 +16,7 @@ const {
|
||||
mockHandleDrop: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-unified-drag', () => ({
|
||||
vi.mock('.././use-unified-drag', () => ({
|
||||
useUnifiedDrag: () => ({
|
||||
handleDragOver: mockHandleDragOver,
|
||||
handleDrop: mockHandleDrop,
|
||||
@ -25,17 +25,21 @@ vi.mock('./use-unified-drag', () => ({
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
<WorkflowContext value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
</WorkflowContext>
|
||||
)
|
||||
}
|
||||
|
||||
type MutableNodeApi = NodeApi<TreeNodeData> & {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const createNode = (params: {
|
||||
id?: string
|
||||
nodeType: 'file' | 'folder'
|
||||
isOpen?: boolean
|
||||
}): NodeApi<TreeNodeData> => {
|
||||
}): MutableNodeApi => {
|
||||
const node = {
|
||||
data: {
|
||||
id: params.id ?? 'node-1',
|
||||
@ -50,7 +54,7 @@ const createNode = (params: {
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
return node as unknown as NodeApi<TreeNodeData>
|
||||
return node as unknown as MutableNodeApi
|
||||
}
|
||||
|
||||
const createDragEvent = (types: string[]): React.DragEvent => {
|
||||
@ -112,6 +116,27 @@ describe('useFolderFileDrop', () => {
|
||||
|
||||
// Scenario: drag handlers delegate only for supported drag events on folder nodes.
|
||||
describe('drag handlers', () => {
|
||||
it('should track supported drag enter and leave events for folder nodes', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const node = createNode({ id: 'folder-enter', nodeType: 'folder' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
const event = createDragEvent(['Files'])
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDragEnter(event)
|
||||
result.current.dragHandlers.onDragLeave(event)
|
||||
})
|
||||
|
||||
expect(mockHandleDragOver).not.toHaveBeenCalled()
|
||||
expect(mockHandleDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should delegate drag over and drop for supported file drag events', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const node = createNode({ id: 'folder-2', nodeType: 'folder' })
|
||||
@ -181,6 +206,24 @@ describe('useFolderFileDrop', () => {
|
||||
isFolder: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore drop events on non-folder nodes', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const node = createNode({ id: 'file-2', nodeType: 'file' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDrop(createDragEvent(['Files']))
|
||||
})
|
||||
|
||||
expect(mockHandleDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: auto-expand lifecycle should blink first, expand later, and cleanup when drag state changes.
|
||||
@ -238,5 +281,31 @@ describe('useFolderFileDrop', () => {
|
||||
})
|
||||
expect(node.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip opening the folder when it becomes open before the expand timer fires', () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setDragOverFolderId('folder-7')
|
||||
const node = createNode({ id: 'folder-7', nodeType: 'folder', isOpen: false })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.isBlinking).toBe(true)
|
||||
|
||||
node.isOpen = true
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(result.current.isBlinking).toBe(false)
|
||||
expect(node.open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,8 +5,8 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../../../constants'
|
||||
import { useRootFileDrop } from './use-root-file-drop'
|
||||
import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../../../../constants'
|
||||
import { useRootFileDrop } from '.././use-root-file-drop'
|
||||
|
||||
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
|
||||
mockUploadMutateAsync: vi.fn(),
|
||||
@ -39,9 +39,9 @@ const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEve
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
<WorkflowContext value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
</WorkflowContext>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants'
|
||||
import { useUnifiedDrag } from './use-unified-drag'
|
||||
import { INTERNAL_NODE_DRAG_TYPE } from '../../../../constants'
|
||||
import { useUnifiedDrag } from '.././use-unified-drag'
|
||||
|
||||
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
|
||||
mockUploadMutateAsync: vi.fn(),
|
||||
@ -38,9 +38,9 @@ const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEve
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
<WorkflowContext value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
</WorkflowContext>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useDelayedClick } from './use-delayed-click'
|
||||
import { useDelayedClick } from '.././use-delayed-click'
|
||||
|
||||
describe('useDelayedClick', () => {
|
||||
beforeEach(() => {
|
||||
@ -1,13 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { START_TAB_ID } from '../../../constants'
|
||||
import { useInlineCreateNode } from './use-inline-create-node'
|
||||
import { START_TAB_ID } from '../../../../constants'
|
||||
import { useInlineCreateNode } from '.././use-inline-create-node'
|
||||
|
||||
const {
|
||||
mockUploadMutate,
|
||||
@ -37,7 +37,7 @@ vi.mock('@/service/use-app-asset', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate,
|
||||
}))
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils/common'
|
||||
import { useSkillShortcuts } from './use-skill-shortcuts'
|
||||
import { useSkillShortcuts } from '.././use-skill-shortcuts'
|
||||
|
||||
const {
|
||||
mockUseKeyPress,
|
||||
@ -91,6 +91,25 @@ describe('useSkillShortcuts', () => {
|
||||
expect(mockCutNodes).toHaveBeenCalledWith(['file-1', 'file-2'])
|
||||
})
|
||||
|
||||
it('should ignore cut shortcut when the tree ref is missing even inside the tree container', () => {
|
||||
const treeRef = { current: null }
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-skill-tree-container', '')
|
||||
const target = document.createElement('button')
|
||||
container.appendChild(target)
|
||||
const event = createShortcutEvent(target)
|
||||
|
||||
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[cutShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockCutNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cut selected nodes even when event target is outside tree container', () => {
|
||||
const treeRef = createTreeRef(['file-3'])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
@ -123,6 +142,25 @@ describe('useSkillShortcuts', () => {
|
||||
expect(mockCutNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore cut shortcut when there is no selection inside the tree container', () => {
|
||||
const treeRef = createTreeRef([])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-skill-tree-container', '')
|
||||
const target = document.createElement('button')
|
||||
container.appendChild(target)
|
||||
const event = createShortcutEvent(target)
|
||||
|
||||
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[cutShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockCutNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore cut shortcut when shortcuts are disabled', () => {
|
||||
const treeRef = createTreeRef(['file-1'])
|
||||
const { rerender } = renderHook(
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { START_TAB_ID } from '../../../constants'
|
||||
import { useSyncTreeWithActiveTab } from './use-sync-tree-with-active-tab'
|
||||
import { START_TAB_ID } from '../../../../constants'
|
||||
import { useSyncTreeWithActiveTab } from '.././use-sync-tree-with-active-tab'
|
||||
|
||||
type MockTreeNode = {
|
||||
id: string
|
||||
@ -18,9 +18,9 @@ type MockTreeNode = {
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
<WorkflowContext value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
</WorkflowContext>
|
||||
)
|
||||
}
|
||||
|
||||
@ -38,6 +38,46 @@ describe('useSyncTreeWithActiveTab', () => {
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('should skip syncing when there is no active tab', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll: vi.fn(),
|
||||
get: vi.fn(),
|
||||
openParents: vi.fn(),
|
||||
select: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: null,
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(requestAnimationFrameSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip syncing while the tree is still loading', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll: vi.fn(),
|
||||
get: vi.fn(),
|
||||
openParents: vi.fn(),
|
||||
select: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: 'file-1',
|
||||
isTreeLoading: true,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(requestAnimationFrameSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear tree selection when active tab is start tab', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const deselectAll = vi.fn()
|
||||
@ -59,6 +99,38 @@ describe('useSyncTreeWithActiveTab', () => {
|
||||
expect(deselectAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should leave selection untouched for artifact tabs when nothing is selected', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const deselectAll = vi.fn()
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll,
|
||||
get: vi.fn(),
|
||||
openParents: vi.fn(),
|
||||
select: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: 'artifact:file.png',
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(deselectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop when the tree ref is not attached yet', () => {
|
||||
const store = createWorkflowStore({})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef: { current: null },
|
||||
activeTabId: 'file-1',
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(store.getState().expandedFolderIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should reveal ancestors and select active file node when node exists', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const openParents = vi.fn()
|
||||
@ -127,6 +199,39 @@ describe('useSyncTreeWithActiveTab', () => {
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should avoid reopening parents when every ancestor is already open', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const openParents = vi.fn()
|
||||
const select = vi.fn()
|
||||
|
||||
const root: MockTreeNode = { id: 'root', isRoot: true, parent: null }
|
||||
const folder: MockTreeNode = { id: 'folder-a', isRoot: false, parent: root, isOpen: true }
|
||||
const fileNode: MockTreeNode = {
|
||||
id: 'file-1',
|
||||
isRoot: false,
|
||||
parent: folder,
|
||||
isSelected: false,
|
||||
isFocused: false,
|
||||
}
|
||||
|
||||
const treeRef = createTreeRef({
|
||||
selectedNodes: [],
|
||||
deselectAll: vi.fn(),
|
||||
get: vi.fn(() => fileNode),
|
||||
openParents,
|
||||
select,
|
||||
})
|
||||
|
||||
renderHook(() => useSyncTreeWithActiveTab({
|
||||
treeRef,
|
||||
activeTabId: 'file-1',
|
||||
isTreeLoading: false,
|
||||
}), { wrapper: createWrapper(store) })
|
||||
|
||||
expect(openParents).not.toHaveBeenCalled()
|
||||
expect(select).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should retry syncing on syncSignal change when node appears later', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const select = vi.fn()
|
||||
@ -1,7 +1,7 @@
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTreeNodeHandlers } from './use-tree-node-handlers'
|
||||
import { useTreeNodeHandlers } from '.././use-tree-node-handlers'
|
||||
|
||||
const {
|
||||
mockClearArtifactSelection,
|
||||
@ -2,7 +2,7 @@ import type { StoreApi } from 'zustand'
|
||||
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useCreateOperations } from './use-create-operations'
|
||||
import { useCreateOperations } from '.././use-create-operations'
|
||||
|
||||
type UploadMutationPayload = {
|
||||
appId: string
|
||||
@ -48,11 +48,11 @@ vi.mock('@/service/use-app-asset', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/skill-upload-utils', () => ({
|
||||
vi.mock('../../../../utils/skill-upload-utils', () => ({
|
||||
prepareSkillUploadFile: mocks.prepareSkillUploadFile,
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useDownloadOperation } from './use-download-operation'
|
||||
import { useDownloadOperation } from '.././use-download-operation'
|
||||
|
||||
type DownloadRequest = {
|
||||
params: {
|
||||
@ -130,6 +130,25 @@ describe('useDownloadOperation', () => {
|
||||
expect(result.current.isDownloading).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve raw content when parsed text payload has no content field', async () => {
|
||||
mockGetFileContent.mockResolvedValueOnce({ content: '{"title":"Skill"}' })
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useDownloadOperation({
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-raw',
|
||||
fileName: 'config.json',
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDownload()
|
||||
})
|
||||
|
||||
const downloadedBlob = mockDownloadBlob.mock.calls.at(-1)?.[0].data
|
||||
await expect(downloadedBlob?.text()).resolves.toBe('{"title":"Skill"}')
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should download binary file from download url when file is not text', async () => {
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useDownloadOperation({
|
||||
@ -1,11 +1,11 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useFileOperations } from './use-file-operations'
|
||||
import { useFileOperations } from '.././use-file-operations'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
@ -127,17 +127,17 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mocks.workflowStore,
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-asset-tree', () => ({
|
||||
vi.mock('../../data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetTreeData: () => ({
|
||||
data: mocks.treeData,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
vi.mock('../../../../utils/tree-utils', () => ({
|
||||
toApiParentId: mocks.toApiParentId,
|
||||
}))
|
||||
|
||||
vi.mock('./use-create-operations', () => ({
|
||||
vi.mock('.././use-create-operations', () => ({
|
||||
useCreateOperations: (options: {
|
||||
parentId: string | null
|
||||
appId: string
|
||||
@ -146,7 +146,7 @@ vi.mock('./use-create-operations', () => ({
|
||||
}) => mocks.createOpsHook(options),
|
||||
}))
|
||||
|
||||
vi.mock('./use-modify-operations', () => ({
|
||||
vi.mock('.././use-modify-operations', () => ({
|
||||
useModifyOperations: (options: {
|
||||
nodeId: string
|
||||
node?: NodeApi<TreeNodeData>
|
||||
@ -158,7 +158,7 @@ vi.mock('./use-modify-operations', () => ({
|
||||
}) => mocks.modifyOpsHook(options),
|
||||
}))
|
||||
|
||||
vi.mock('./use-download-operation', () => ({
|
||||
vi.mock('.././use-download-operation', () => ({
|
||||
useDownloadOperation: (options: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
@ -1,11 +1,11 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useModifyOperations } from './use-modify-operations'
|
||||
import { useModifyOperations } from '.././use-modify-operations'
|
||||
|
||||
type DeleteMutationPayload = {
|
||||
appId: string
|
||||
@ -29,11 +29,11 @@ vi.mock('@/service/use-app-asset', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
vi.mock('../../../../utils/tree-utils', () => ({
|
||||
getAllDescendantFileIds: mocks.getAllDescendantFileIds,
|
||||
isDescendantOf: mocks.isDescendantOf,
|
||||
}))
|
||||
@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useNodeMove } from './use-node-move'
|
||||
import { useNodeMove } from '.././use-node-move'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
@ -38,11 +38,11 @@ vi.mock('@/service/use-app-asset', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
vi.mock('../../../../utils/tree-utils', () => ({
|
||||
toApiParentId: mocks.toApiParentId,
|
||||
}))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useNodeReorder } from './use-node-reorder'
|
||||
import { useNodeReorder } from '.././use-node-reorder'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
@ -37,7 +37,7 @@ vi.mock('@/service/use-app-asset', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { TreeNodeData } from '../../../../type'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { usePasteOperation } from './use-paste-operation'
|
||||
import { usePasteOperation } from '.././use-paste-operation'
|
||||
|
||||
type MoveMutationPayload = {
|
||||
appId: string
|
||||
@ -83,11 +83,11 @@ vi.mock('@/service/use-app-asset', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
vi.mock('../../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
vi.mock('../../../../utils/tree-utils', () => ({
|
||||
getTargetFolderIdFromSelection: mocks.getTargetFolderIdFromSelection,
|
||||
toApiParentId: mocks.toApiParentId,
|
||||
findNodeById: mocks.findNodeById,
|
||||
@ -37,6 +37,7 @@ export function useDownloadOperation({
|
||||
const { content } = await consoleClient.appAsset.getFileContent({
|
||||
params: { appId, nodeId },
|
||||
})
|
||||
const textFileName = fileName!
|
||||
let rawText = content
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { content?: string }
|
||||
@ -48,7 +49,7 @@ export function useDownloadOperation({
|
||||
|
||||
downloadBlob({
|
||||
data: new Blob([rawText], { type: 'text/plain;charset=utf-8' }),
|
||||
fileName: fileName || 'download.txt',
|
||||
fileName: textFileName,
|
||||
})
|
||||
}
|
||||
else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user