This commit is contained in:
Stephen Zhou 2026-03-25 19:25:22 +08:00
parent f0041ec619
commit a7178b4d5c
No known key found for this signature in database
40 changed files with 652 additions and 156 deletions

View File

@ -12,6 +12,10 @@ import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index'
const { mockNotify } = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
vi.mock('ahooks', () => ({
useDebounceFn: <T extends (...args: unknown[]) => unknown>(fn: T) => {
const run = (...args: Parameters<T>) => fn(...args)
@ -31,6 +35,12 @@ vi.mock('@/app/components/base/amplitude', () => ({
vi.mock('@/service/apps', () => ({
createApp: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (message: string) => mockNotify({ type: 'success', message }),
error: (message: string) => mockNotify({ type: 'error', message }),
},
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
@ -47,7 +57,6 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
const mockNotify = vi.fn()
const mockUseRouter = vi.mocked(useRouter)
const mockPush = vi.fn()
const mockCreateApp = vi.mocked(createApp)

View File

@ -264,7 +264,7 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, longUrl)
fireEvent.change(input, { target: { value: longUrl } })
expect(input).toHaveValue(longUrl)
})
@ -275,7 +275,7 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, unicodeUrl)
fireEvent.change(input, { target: { value: unicodeUrl } })
expect(input).toHaveValue(unicodeUrl)
})
@ -285,7 +285,10 @@ describe('UrlInput', () => {
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
fireEvent.change(input, { target: { value: 'h' } })
fireEvent.change(input, { target: { value: 'ht' } })
fireEvent.change(input, { target: { value: 'https://' } })
fireEvent.change(input, { target: { value: 'https://rapid.com' } })
expect(input).toHaveValue('https://rapid.com')
})

View File

@ -48,7 +48,7 @@ export const useGotoAnythingResults = (
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Actions intentionally excluded: contains non-serializable functions; actionKeys provides stable representation
queryKey: [
'goto-anything',
'search-result',

View File

@ -79,15 +79,19 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
},
},
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
IS_DEV: false,
IS_CE_EDITION: false,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
IS_DEV: false,
IS_CE_EDITION: false,
}
})
vi.mock('@/env', () => mockEnv)
const baseAppContextValue: AppContextValue = {

View File

@ -405,7 +405,7 @@ describe('Popup', () => {
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
payload: 'model-provider',
})
})
@ -425,7 +425,7 @@ describe('Popup', () => {
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
payload: 'model-provider',
})
})

View File

@ -1,7 +1,6 @@
import type { Model, ModelItem } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@ -29,12 +28,32 @@ vi.mock('@/utils/tool-call', () => ({
supportFunctionCall: mockSupportFunctionCall,
}))
const mockMarketplacePlugins = vi.hoisted(() => ({
current: [] as Array<{ plugin_id: string, latest_package_identifier: string }>,
isLoading: false,
}))
const mockContextModelProviders = vi.hoisted(() => ({
current: [] as Array<{
provider: string
label: Record<string, string>
icon_small: Record<string, string>
icon_small_dark?: Record<string, string>
custom_configuration?: { status?: string }
system_configuration?: { enabled?: boolean }
}>,
}))
const mockTrialModels = vi.hoisted(() => ({
current: ['test-openai', 'test-anthropic'] as string[],
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => mockLanguage,
useMarketplaceAllPlugins: () => [],
useMarketplaceAllPlugins: () => ({
plugins: mockMarketplacePlugins.current,
isLoading: mockMarketplacePlugins.isLoading,
}),
}
})
@ -42,6 +61,74 @@ vi.mock('./popup-item', () => ({
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: { trial_models: mockTrialModels.current },
}),
}))
const mockTrialCredits = vi.hoisted(() => ({
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined as number | undefined,
}))
vi.mock('../provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('../provider-added-card/model-auth-dropdown/credits-exhausted-alert', () => ({
default: () => <div data-testid="credits-exhausted-alert" />,
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
}))
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({ refreshPluginList: mockRefreshPluginList }),
}))
const mockCheck = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({ check: mockCheck }),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.example.com'),
}))
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils')
return {
...actual,
MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'],
providerIconMap: {
'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>,
'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>,
},
modelNameMap: {
'test-openai': 'TestOpenAI',
'test-anthropic': 'TestAnthropic',
},
providerKeyToPluginId: {
'test-openai': 'langgenius/openai',
'test-anthropic': 'langgenius/anthropic',
},
}
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@ -54,22 +141,30 @@ const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
})
const makeModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
provider: 'custom-provider',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
models: [makeModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('Popup', () => {
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockSupportFunctionCall.mockReturnValue(true)
closeActiveTooltipSpy = vi.spyOn(tooltipManager, 'closeActiveTooltip')
mockMarketplacePlugins.current = []
mockMarketplacePlugins.isLoading = false
mockContextModelProviders.current = []
mockTrialModels.current = ['test-openai', 'test-anthropic']
Object.assign(mockTrialCredits, {
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined,
})
})
it('should filter models by search and allow clearing search', () => {
@ -81,7 +176,7 @@ describe('Popup', () => {
/>,
)
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('custom-provider')).toBeInTheDocument()
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'not-found' } })
@ -89,7 +184,7 @@ describe('Popup', () => {
fireEvent.change(input, { target: { value: '' } })
expect((input as HTMLInputElement).value).toBe('')
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('custom-provider')).toBeInTheDocument()
})
it('should filter by scope features including toolCall and non-toolCall checks', () => {
@ -120,7 +215,7 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('custom-provider')).toBeInTheDocument()
unmount2()
const { unmount: unmount3 } = renderWithProviders(
@ -131,7 +226,7 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('custom-provider')).toBeInTheDocument()
// When features are missing, non-toolCall feature checks should fail.
unmount3()
@ -162,7 +257,7 @@ describe('Popup', () => {
{ target: { value: 'gpt' } },
)
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('No model found for “gpt”')).toBeInTheDocument()
})
it('should filter out model when features array exists but does not include required scopeFeature', () => {
@ -180,11 +275,11 @@ describe('Popup', () => {
)
// The model item should be filtered out because it has toolCall but not vision
expect(screen.queryByText('openai')).not.toBeInTheDocument()
expect(screen.queryByText('custom-provider')).not.toBeInTheDocument()
})
it('should close tooltip on scroll', () => {
const { container } = renderWithProviders(
it('should render marketplace providers that are not installed yet', () => {
renderWithProviders(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -192,8 +287,8 @@ describe('Popup', () => {
/>,
)
fireEvent.scroll(container.firstElementChild as HTMLElement)
expect(closeActiveTooltipSpy).toHaveBeenCalled()
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
})
it('should open provider settings when clicking footer link', () => {
@ -205,7 +300,7 @@ describe('Popup', () => {
/>,
)
fireEvent.click(screen.getByText('common.model.settingsLink'))
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'model-provider',
@ -222,7 +317,7 @@ describe('Popup', () => {
/>,
)
fireEvent.click(screen.getByText('common.model.settingsLink'))
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
expect(mockOnHide).toHaveBeenCalled()
})
@ -240,6 +335,6 @@ describe('Popup', () => {
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'gpt' } })
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('No model found for “gpt”')).toBeInTheDocument()
})
})

View File

@ -8,16 +8,26 @@ const mockSetShowInputsPanel = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockDebouncedCancel = vi.fn()
const mockSetShowUpgradeRuntimeModal = vi.fn()
const mockSetNeedsRuntimeUpgrade = vi.fn()
const mockFetchRunDetail = vi.fn()
const mockInitialNodes = vi.fn()
const mockInitialEdges = vi.fn()
const mockGetWorkflowRunAndTraceUrl = vi.fn()
const mockSyncWorkflowDraftImmediately = vi.fn()
const mockUseSubscription = vi.fn()
const mockCollaborationSetNodes = vi.fn()
const mockCollaborationSetEdges = vi.fn()
const mockEmitGraphViewActive = vi.fn()
let appStoreState: {
appDetail?: {
id: string
mode: string
name?: string
runtime_type?: string
}
setNeedsRuntimeUpgrade: typeof mockSetNeedsRuntimeUpgrade
}
let workflowInitState: {
@ -50,6 +60,12 @@ let appTriggersState: {
}
let searchParamsValue: string | null = null
const workflowUiState = {
appId: 'app-1',
isResponding: false,
showUpgradeRuntimeModal: false,
setShowUpgradeRuntimeModal: mockSetShowUpgradeRuntimeModal,
}
const mockWorkflowStore = {
setState: mockWorkflowStoreSetState,
@ -68,6 +84,7 @@ vi.mock('@/app/components/app/store', () => ({
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: typeof workflowUiState) => T) => selector(workflowUiState),
useWorkflowStore: () => mockWorkflowStore,
}))
@ -87,6 +104,14 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [null, vi.fn()],
}
})
vi.mock('@/service/log', () => ({
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
}))
@ -99,12 +124,38 @@ vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({
useWorkflowInit: () => workflowInitState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
syncWorkflowDraftImmediately: mockSyncWorkflowDraftImmediately,
}),
}))
vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({
useGetRunAndTraceUrl: () => ({
getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: mockUseSubscription,
},
}),
}))
vi.mock('@/app/components/workflow/collaboration', () => ({
useCollaboration: () => undefined,
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
setNodes: (...args: unknown[]) => mockCollaborationSetNodes(...args),
setEdges: (...args: unknown[]) => mockCollaborationSetEdges(...args),
emitGraphViewActive: (...args: unknown[]) => mockEmitGraphViewActive(...args),
},
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
@ -118,19 +169,25 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/features', () => ({
FeaturesProvider: ({
features,
children,
}: {
features: Record<string, unknown>
children: ReactNode
}) => (
<div data-testid="features-provider" data-features={JSON.stringify(features)}>
{children}
</div>
),
}))
vi.mock('@/app/components/base/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/features')>()
return {
...actual,
FeaturesProvider: ({
features,
children,
}: {
features: Record<string, unknown>
children: ReactNode
}) => (
<div data-testid="features-provider" data-features={JSON.stringify(features)}>
<actual.FeaturesProvider features={features as never}>
{children}
</actual.FeaturesProvider>
</div>
),
}
})
vi.mock('@/app/components/workflow', () => ({
default: ({
@ -178,6 +235,26 @@ vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
),
}))
vi.mock('@/app/components/workflow/header', () => ({
HeaderShell: ({ children }: { children: ReactNode }) => <div data-testid="header-shell">{children}</div>,
}))
vi.mock('@/app/components/workflow/header/online-users', () => ({
default: () => <div data-testid="online-users" />,
}))
vi.mock('@/app/components/workflow/view-picker', () => ({
default: () => <div data-testid="view-picker" />,
}))
vi.mock('../components/sandbox-migration-modal', () => ({
default: () => <div data-testid="sandbox-migration-modal" />,
}))
vi.mock('../components/upgraded-from-banner', () => ({
default: () => <div data-testid="upgraded-from-banner" />,
}))
describe('WorkflowApp', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -185,7 +262,9 @@ describe('WorkflowApp', () => {
appDetail: {
id: 'app-1',
mode: 'workflow',
name: 'Workflow App',
},
setNeedsRuntimeUpgrade: mockSetNeedsRuntimeUpgrade,
}
workflowInitState = {
data: {
@ -213,6 +292,7 @@ describe('WorkflowApp', () => {
mockInitialNodes.mockReturnValue([{ id: 'node-1' }])
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
mockSyncWorkflowDraftImmediately.mockResolvedValue(undefined)
})
it('should render the loading shell while workflow data is still loading', () => {

View File

@ -8,6 +8,16 @@ import WorkflowMain from '../workflow-main'
const mockSetFeatures = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockStartCursorTracking = vi.fn()
const mockStopCursorTracking = vi.fn()
const mockGetNodes = vi.fn()
const mockSetNodes = vi.fn()
const mockGetEdges = vi.fn()
const mockSetEdges = vi.fn()
const workflowUiState = {
appId: 'app-1',
}
const hookFns = {
doSyncWorkflowDraft: vi.fn(),
@ -54,6 +64,7 @@ type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' |
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: <T,>(selector: (state: { features: { sandbox?: { enabled: boolean } } }) => T) => selector({ features: {} }),
useFeaturesStore: () => ({
getState: () => ({
setFeatures: mockSetFeatures,
@ -62,6 +73,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: typeof workflowUiState) => T) => selector(workflowUiState),
useWorkflowStore: () => ({
getState: () => ({
setConversationVariables: mockSetConversationVariables,
@ -70,6 +82,35 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
getEdges: mockGetEdges,
setEdges: mockSetEdges,
}),
}))
vi.mock('@/app/components/workflow/collaboration', () => ({
collaborationManager: {
onVarsAndFeaturesUpdate: vi.fn(() => vi.fn()),
onWorkflowUpdate: vi.fn(() => vi.fn()),
onSyncRequest: vi.fn(() => vi.fn()),
},
useCollaboration: () => ({
startCursorTracking: mockStartCursorTracking,
stopCursorTracking: mockStopCursorTracking,
onlineUsers: [],
cursors: {},
isConnected: false,
isEnabled: false,
}),
}))
vi.mock('@/app/components/workflow/block-selector/context/mcp-tool-availability-context', () => ({
MCPToolAvailabilityProvider: ({ children }: { children: ReactNode, sandboxEnabled: boolean }) => <>{children}</>,
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
nodes,
@ -172,6 +213,8 @@ describe('WorkflowMain', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedContextProps = null
mockGetNodes.mockReturnValue([])
mockGetEdges.mockReturnValue([])
})
it('should render the inner workflow context with children and forwarded graph props', () => {
@ -207,9 +250,18 @@ describe('WorkflowMain', () => {
fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i }))
expect(mockSetFeatures).toHaveBeenCalledWith({ file: { enabled: true } })
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-1' }])
expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({
file: expect.objectContaining({
enabled: true,
}),
sandbox: { enabled: false },
}))
expect(mockSetConversationVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'conversation-1' }),
])
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'env-1' }),
])
})
it('should only update the workflow store slices present in the payload', () => {
@ -223,7 +275,9 @@ describe('WorkflowMain', () => {
fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i }))
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-only' }])
expect(mockSetConversationVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'conversation-only' }),
])
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})

View File

@ -6,6 +6,7 @@ const mockNotify = vi.fn()
const mockEmit = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockExportAppConfig = vi.fn()
const mockExportAppBundle = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDownloadBlob = vi.fn()
@ -39,6 +40,7 @@ vi.mock('../use-nodes-sync-draft', () => ({
}))
vi.mock('@/service/apps', () => ({
exportAppBundle: (...args: unknown[]) => mockExportAppBundle(...args),
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
}))
@ -69,6 +71,7 @@ describe('useDSL', () => {
}
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
mockExportAppBundle.mockResolvedValue(undefined)
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
})
@ -79,17 +82,19 @@ describe('useDSL', () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
workflowID: undefined,
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
workflowID: undefined,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
data: expect.any(Blob),
fileName: 'Workflow App.yaml',
}))
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
data: expect.any(Blob),
fileName: 'Workflow App.yml',
}))
})
it('should forward include and workflow id arguments when exporting dsl directly', async () => {
@ -120,6 +125,7 @@ describe('useDSL', () => {
type: DSL_EXPORT_CHECK,
payload: {
data: secretVars,
sandboxed: false,
},
})
expect(mockExportAppConfig).not.toHaveBeenCalled()

View File

@ -3,11 +3,24 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
const mockUseIsChatMode = vi.fn()
const appStoreState = {
appDetail: {
runtime_type: 'default',
},
}
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: <T>(selector: (state: { features: { sandbox?: { enabled: boolean } } }) => T) => selector({ features: {} }),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `/docs${path}`,
}))

View File

@ -70,8 +70,20 @@ vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p),
}))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) }))
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
vi.mock('@/service/fetch', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/fetch')>()
return {
...actual,
postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args),
}
})
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
API_PREFIX: '/api',
}
})
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('@/app/components/workflow-app/hooks', () => ({
@ -207,9 +219,11 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
await result.current.doSyncWorkflowDraft(false, callbacks)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
url: '/apps/app-1/workflows/draft',
params: {
canNotSaveEmpty: true,
params: expect.objectContaining({
_is_collaborative: false,
graph: {
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', label: 'Start' } }],
edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }],
@ -224,12 +238,13 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: true },
sandbox: undefined,
},
environment_variables: [{ id: 'env-1', value: 'env' }],
conversation_variables: [{ id: 'conversation-1', value: 'conversation' }],
hash: 'latest-hash',
},
})
}),
}))
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1)
expect(callbacks.onSuccess).toHaveBeenCalled()

View File

@ -52,7 +52,7 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
debouncedSyncWorkflowDraft: { cancel: mockCancel },
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
@ -65,33 +65,38 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
it('should update canvas by default (notUpdateCanvas omitted)', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
await act(async () => {
act(() => {
result.current.handleRefreshWorkflowDraft()
})
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledTimes(1)
})
})
it('should update canvas when notUpdateCanvas=false', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
await act(async () => {
act(() => {
result.current.handleRefreshWorkflowDraft(false)
})
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledTimes(1)
})
})
it('should NOT update canvas when notUpdateCanvas=true', async () => {
// This is the key change: when called from a 409 error during editing,
// canvas must not be overwritten with server state.
const { result } = renderHook(() => useWorkflowRefreshDraft())
await act(async () => {
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
})
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
it('should still update hash even when notUpdateCanvas=true', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
await act(async () => {
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {

View File

@ -76,8 +76,8 @@ describe('useWorkflowRun utils', () => {
expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.All)).toBe('')
expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('')
expect(validateWorkflowRunRequest(TriggerType.All)).toBe('handleRun: all trigger run requires node ids')
expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('handleRun: all trigger run requires node ids')
})
it('should return empty trigger urls when app id is missing and keep user-input urls empty outside workflow debug', () => {

View File

@ -69,6 +69,7 @@ const mocks = vi.hoisted(() => {
mockFetchInspectVars: vi.fn(),
mockInvalidateAllLastRun: vi.fn(),
mockInvalidateRunHistory: vi.fn(),
mockInvalidateSandboxFiles: vi.fn(),
mockSsePost: vi.fn(),
mockSseGet: vi.fn(),
mockHandleStream: vi.fn(),
@ -181,6 +182,10 @@ vi.mock('@/service/workflow', () => ({
stopWorkflowRun: mocks.mockStopWorkflowRun,
}))
vi.mock('@/service/use-sandbox-file', () => ({
useInvalidateSandboxFiles: () => mocks.mockInvalidateSandboxFiles,
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mocks.mockFetchInspectVars,
@ -340,6 +345,14 @@ describe('useWorkflowRun', () => {
getAbortController: expect.any(Function),
}),
)
const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
callbacks: {
onWorkflowFinished?: (params: { workflow_run_id: string }) => void
}
}
baseCallbackFactoryContext.callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' })
expect(mocks.mockInvalidateSandboxFiles).toHaveBeenCalledTimes(1)
})
it.each([
@ -546,15 +559,34 @@ describe('useWorkflowRun', () => {
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith(expect.objectContaining({
features: expect.objectContaining({
opening: expect.objectContaining({
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
}),
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
annotationReply: { enabled: false },
sandbox: { enabled: false },
file: expect.objectContaining({
enabled: true,
allowed_file_types: ['image'],
allowed_file_upload_methods: ['local_file', 'remote_url'],
number_limits: 3,
fileUploadConfig: undefined,
image: {
enabled: false,
number_limits: 3,
transfer_methods: ['local_file', 'remote_url'],
},
}),
file: { enabled: true },
}),
})
}))
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }])
})
@ -581,12 +613,34 @@ describe('useWorkflowRun', () => {
} as never)
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith(expect.objectContaining({
features: expect.objectContaining({
opening: expect.objectContaining({ enabled: false }),
file: { enabled: false },
opening: {
enabled: false,
opening_statement: '',
suggested_questions: [],
},
suggested: { enabled: false },
text2speech: { enabled: false },
speech2text: { enabled: false },
citation: { enabled: false },
moderation: { enabled: false },
annotationReply: { enabled: false },
sandbox: { enabled: false },
file: expect.objectContaining({
enabled: false,
allowed_file_types: ['image'],
allowed_file_upload_methods: ['local_file', 'remote_url'],
number_limits: 3,
fileUploadConfig: undefined,
image: {
enabled: false,
number_limits: 3,
transfer_methods: ['local_file', 'remote_url'],
},
}),
}),
})
}))
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([])
})
})

View File

@ -76,7 +76,7 @@ describe('useWorkflowTemplate', () => {
expect(generateNewNodeCalls[2].data).toMatchObject({
type: 'answer',
title: 'workflow.blocks.answer',
answer: '{{#llm.text#}}',
answer: '{{#llm.generation#}}',
})
})
})

View File

@ -20,8 +20,7 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { usePathname } from '@/next/navigation'
import { handleStream, post, sseGet, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { ssePost } from '@/service/base'
import { useInvalidateSandboxFiles } from '@/service/use-sandbox-file'
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
@ -380,7 +379,6 @@ export const useWorkflowRun = () => {
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {
const {
workflowRunningData,
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,

View File

@ -52,12 +52,12 @@ import {
} from './hooks/use-workflow-init'
import { parseAsViewType, WORKFLOW_VIEW_PARAM_KEY } from './search-params'
import { createWorkflowSlice } from './store/workflow/workflow-slice'
import { getSandboxMigrationDismissed, setSandboxMigrationDismissed } from './utils/sandbox-migration-storage'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from './utils'
import { getSandboxMigrationDismissed, setSandboxMigrationDismissed } from './utils/sandbox-migration-storage'
const SkillMain = dynamic(() => import('@/app/components/workflow/skill/main'), {
ssr: false,

View File

@ -9,6 +9,7 @@ const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockBlockSelector = vi.hoisted(() => vi.fn())
const mockGradientRender = vi.hoisted(() => vi.fn())
const mockUseHooksStore = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
BaseEdge: (props: {
@ -44,6 +45,10 @@ vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => mockUseNodesInteractions(),
}))
vi.mock('../hooks-store', () => ({
useHooksStore: (selector: (state: { interactionMode: string }) => unknown) => mockUseHooksStore(selector),
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: (props: {
@ -87,6 +92,9 @@ describe('CustomEdge', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseHooksStore.mockImplementation((selector: (state: { interactionMode: string }) => unknown) => selector({
interactionMode: 'default',
}))
mockUseNodesInteractions.mockReturnValue({
handleNodeAdd: mockHandleNodeAdd,
})

View File

@ -10,6 +10,7 @@ import { renderWorkflowFlowComponent } from './workflow-test-env'
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleAddVariable = vi.fn()
const mockUpdateFeatures = vi.hoisted(() => vi.fn())
let mockIsChatMode = true
let mockNodesReadOnly = false
@ -34,6 +35,24 @@ vi.mock('../nodes/start/use-config', () => ({
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: { runtime_type?: string } }) => unknown) => selector({
appDetail: {
runtime_type: 'sandboxed',
},
}),
}))
vi.mock('@/service/workflow', () => ({
updateFeatures: mockUpdateFeatures,
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: () => null,
},
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: ({
show,
@ -112,21 +131,29 @@ const DelayedFeatures = () => {
return <Features />
}
const renderFeatures = (options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>) =>
renderWorkflowFlowComponent(
const renderFeatures = (options?: Omit<NonNullable<Parameters<typeof renderWorkflowFlowComponent>[1]>, 'nodes' | 'edges'>) => {
const { initialStoreState, ...rest } = options ?? {}
return renderWorkflowFlowComponent(
<DelayedFeatures />,
{
nodes: [startNode],
edges: [],
...options,
initialStoreState: {
appId: 'app-1',
...initialStoreState,
},
...rest,
},
)
}
describe('Features', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = true
mockNodesReadOnly = false
mockUpdateFeatures.mockResolvedValue(undefined)
})
describe('Rendering', () => {

View File

@ -10,6 +10,8 @@ const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
const mockUseOperator = vi.hoisted(() => vi.fn())
const mockUseDSL = vi.hoisted(() => vi.fn())
const mockUseWorkflowMoveMode = vi.hoisted(() => vi.fn())
const mockUseFeatures = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
@ -32,12 +34,17 @@ vi.mock('@/app/components/workflow/hooks', () => ({
usePanelInteractions: () => mockUsePanelInteractions(),
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
useDSL: () => mockUseDSL(),
useWorkflowMoveMode: () => mockUseWorkflowMoveMode(),
}))
vi.mock('@/app/components/workflow/operator/hooks', () => ({
useOperator: () => mockUseOperator(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { sandbox?: { enabled?: boolean } } }) => unknown) => mockUseFeatures(selector),
}))
vi.mock('@/app/components/workflow/operator/add-block', () => ({
__esModule: true,
default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
@ -62,14 +69,19 @@ describe('PanelContextmenu', () => {
const mockHandleAddNote = vi.fn()
const mockExportCheck = vi.fn()
const mockSetShowImportDSLModal = vi.fn()
const mockSetShowUpgradeRuntimeModal = vi.fn()
const mockSetCommentPlacing = vi.fn()
const mockSetCommentQuickAdd = vi.fn()
let panelMenu: { left: number, top: number } | undefined
let clipboardElements: unknown[]
let pipelineId: string | undefined
let clickAwayHandler: (() => void) | undefined
beforeEach(() => {
vi.clearAllMocks()
panelMenu = undefined
clipboardElements = []
pipelineId = 'pipeline-1'
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
@ -82,10 +94,20 @@ describe('PanelContextmenu', () => {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
setShowImportDSLModal: (visible: boolean) => void
setShowUpgradeRuntimeModal: (visible: boolean) => void
pendingComment?: unknown
setCommentPlacing: (placing: boolean) => void
setCommentQuickAdd: (placing: boolean) => void
pipelineId?: string
}) => unknown) => selector({
panelMenu,
clipboardElements,
setShowImportDSLModal: mockSetShowImportDSLModal,
setShowUpgradeRuntimeModal: mockSetShowUpgradeRuntimeModal,
pendingComment: undefined,
setCommentPlacing: mockSetCommentPlacing,
setCommentQuickAdd: mockSetCommentQuickAdd,
pipelineId,
}))
mockUseNodesInteractions.mockReturnValue({
handleNodesPaste: mockHandleNodesPaste,
@ -102,6 +124,16 @@ describe('PanelContextmenu', () => {
mockUseDSL.mockReturnValue({
exportCheck: mockExportCheck,
})
mockUseWorkflowMoveMode.mockReturnValue({
isCommentModeAvailable: false,
})
mockUseFeatures.mockImplementation((selector: (state: { features: { sandbox?: { enabled?: boolean } } }) => unknown) => selector({
features: {
sandbox: {
enabled: false,
},
},
}))
})
it('should stay hidden when the panel menu is absent', () => {

View File

@ -95,16 +95,9 @@ describe('renderWorkflowComponent', () => {
expect(screen.getByTestId('hooks-reader')).toHaveTextContent('test-123')
})
it('should throw when HooksStoreContext is not provided', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
expect(() => {
renderWorkflowComponent(React.createElement(HooksStoreReader))
}).toThrow('Missing HooksStoreContext.Provider')
}
finally {
consoleSpy.mockRestore()
}
it('should provide a default HooksStoreContext when hooksStoreProps are omitted', () => {
renderWorkflowComponent(React.createElement(HooksStoreReader))
expect(screen.getByTestId('hooks-reader')).toHaveTextContent('none')
})
it('should forward extra render options (container)', () => {

View File

@ -72,6 +72,7 @@ import * as React from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { temporal } from 'zundo'
import { create } from 'zustand'
import { FeaturesProvider } from '@/app/components/base/features/context'
import { WorkflowContext } from '../context'
import { HooksStoreContext } from '../hooks-store/provider'
import { createHooksStore } from '../hooks-store/store'
@ -138,14 +139,12 @@ type WorkflowProviderOptions = {
type StoreInstances = {
store: WorkflowStore
hooksStore?: HooksStore
hooksStore: HooksStore
}
function createStoresFromOptions(options: WorkflowProviderOptions): StoreInstances {
const store = createTestWorkflowStore(options.initialStoreState)
const hooksStore = options.hooksStoreProps !== undefined
? createTestHooksStore(options.hooksStoreProps)
: undefined
const hooksStore = createTestHooksStore(options.hooksStoreProps)
return { store, hooksStore }
}
@ -175,21 +174,23 @@ function createWorkflowWrapper(
)
}
if (stores.hooksStore) {
inner = React.createElement(
HooksStoreContext.Provider,
{ value: stores.hooksStore },
inner,
)
}
inner = React.createElement(
HooksStoreContext.Provider,
{ value: stores.hooksStore },
inner,
)
return React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
FeaturesProvider,
null,
React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
),
),
)
}

View File

@ -5,10 +5,10 @@ import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import HeaderInRestoring from '../header-in-restoring'
const mockRestoreWorkflow = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockRequestRestore = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
@ -30,9 +30,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../hooks', () => ({
@ -42,6 +39,18 @@ vi.mock('../../hooks', () => ({
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
useLeaderRestore: () => ({
requestRestore: mockRequestRestore,
}),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string, name: string } }) => unknown) => selector({
userProfile: {
id: 'user-1',
name: 'Alice',
},
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
@ -73,6 +82,10 @@ const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory
describe('HeaderInRestoring', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRequestRestore.mockImplementation((_payload, callbacks) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
})
it('should disable restore when the flow id is not ready yet', () => {

View File

@ -14,7 +14,7 @@ const mockHandleNodeSelect = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockCloseAllInputFieldPanels = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockRequestRestore = vi.fn()
const mockNotify = vi.fn()
const mockRunAndHistory = vi.fn()
const mockViewHistory = vi.fn()
@ -39,6 +39,9 @@ vi.mock('../../hooks', () => ({
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
useLeaderRestore: () => ({
requestRestore: mockRequestRestore,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
@ -55,8 +58,14 @@ vi.mock('@/hooks/use-theme', () => ({
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string, name: string } }) => unknown) => selector({
userProfile: {
id: 'user-1',
name: 'Tester',
},
}),
}))
@ -77,6 +86,10 @@ vi.mock('../scroll-to-selected-node-button', () => ({
default: () => <div>scroll-button</div>,
}))
vi.mock('../online-users', () => ({
default: () => <div>online-users</div>,
}))
vi.mock('../env-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
}))
@ -162,7 +175,10 @@ describe('Header layout components', () => {
mockNodesReadOnly = false
mockTheme = 'light'
mockUseNodes.mockReturnValue([])
mockRestoreWorkflow.mockResolvedValue(undefined)
mockRequestRestore.mockImplementation((_payload, callbacks) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
})
describe('HeaderInNormal', () => {
@ -267,11 +283,18 @@ describe('Header layout components', () => {
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
expect(mockRequestRestore).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
initiatorUserId: 'user-1',
initiatorName: 'Tester',
}), expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
onSettled: expect.any(Function),
}))
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
expect(store.getState().isRestoring).toBe(false)
expect(store.getState().backupDraft).toBeUndefined()
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
expect(deleteAllInspectVars).toHaveBeenCalledTimes(1)
expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith({

View File

@ -91,7 +91,7 @@ describe('useWorkflowFinished', () => {
expect(state.resultText).toBe('hello')
})
it('should not activate result tab for multi-key outputs', () => {
it('should activate result tab for multi-key outputs', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
@ -100,7 +100,7 @@ describe('useWorkflowFinished', () => {
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
} as unknown as WorkflowFinishedResponse)
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
expect(store.getState().workflowRunningData!.resultTabActive).toBe(true)
})
})

View File

@ -41,7 +41,7 @@ const renderFormInputItem = (props: Partial<ComponentProps<typeof FormInputItem>
renderWorkflowFlowComponent(
<FormInputItem
readOnly={false}
nodeId="node-1"
nodeId=""
schema={createSchema()}
value={{
field: {

View File

@ -1,7 +1,8 @@
import type { InputVar, Node } from '../../../types'
import type { IterationNodeType } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { act } from '@testing-library/react'
import { renderWorkflowHook } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, ErrorHandleMode, InputVarType, VarType } from '@/app/components/workflow/types'
import useSingleRunFormParams from '../use-single-run-form-params'
@ -94,7 +95,7 @@ describe('iteration/use-single-run-form-params', () => {
it('should build single-run forms from external vars and keep iterator state in a dedicated form', () => {
const toVarInputs = vi.fn(() => [createInputVar('#start-node.answer#')])
const { result } = renderHook(() => useSingleRunFormParams({
const { result } = renderWorkflowHook(() => useSingleRunFormParams({
id: 'iteration-node',
payload: createPayload(),
runInputData: {
@ -134,7 +135,7 @@ describe('iteration/use-single-run-form-params', () => {
it('should forward form updates and expose iterator dependencies', () => {
const setRunInputData = vi.fn()
const { result } = renderHook(() => useSingleRunFormParams({
const { result } = renderWorkflowHook(() => useSingleRunFormParams({
id: 'iteration-node',
payload: createPayload({
iterator_selector: ['source-node', 'records'],

View File

@ -82,6 +82,8 @@ vi.mock('@/app/components/workflow/block-selector', () => ({
}))
vi.mock('../../loop-start', () => ({
__esModule: true,
default: () => <div>loop-start-node</div>,
LoopStartNodeDumb: () => <div>loop-start-node</div>,
}))

View File

@ -6,6 +6,16 @@ import Node from '../node'
const mockUseNodePluginInstallation = vi.hoisted(() => vi.fn())
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUseNodesMetaData = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useNodes: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => mockUseNodesMetaData(),
}))
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
useNodePluginInstallation: mockUseNodePluginInstallation,
@ -20,6 +30,14 @@ vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button'
InstallPluginButton: () => <button type="button">Install Plugin</button>,
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('@/service/use-strategy', () => ({
useStrategyProviders: () => ({ data: undefined }),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
@ -37,6 +55,8 @@ const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType =>
describe('ToolNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNodes.mockReturnValue([])
mockUseNodesMetaData.mockReturnValue({ nodesMap: {} })
mockUseNodePluginInstallation.mockReturnValue({
isChecking: false,
isMissing: false,

View File

@ -1,4 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { FeaturesProvider } from '@/app/components/base/features/context'
import { VarType } from '../../../types'
import { useGetAvailableVars, useVariableAssigner } from '../hooks'
@ -229,7 +231,9 @@ describe('variable-assigner/hooks', () => {
getNodeAvailableVars,
})
const { result } = renderHook(() => useGetAvailableVars())
const { result } = renderHook(() => useGetAvailableVars(), {
wrapper: ({ children }) => React.createElement(FeaturesProvider, null, children),
})
expect(result.current('current-node', 'target', () => true, true)).toEqual([{
isStartNode: true,

View File

@ -142,7 +142,7 @@ describe('WorkflowPreview', () => {
it('should keep the input tab active, switch to result after running, and close the preview panel', async () => {
const user = userEvent.setup()
const { container } = renderWorkflowComponent(
const { container, store } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
@ -156,7 +156,20 @@ describe('WorkflowPreview', () => {
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'run-inputs' }))
expect(screen.getByTestId('result-text')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
store.setState({
workflowRunningData: createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Running,
files: [],
}),
}) as NonNullable<Shape['workflowRunningData']>,
})
await waitFor(() => {
expect(screen.getByTestId('result-text')).toBeInTheDocument()
})
await user.click(container.querySelector('.flex.items-center.justify-between .cursor-pointer.p-1') as HTMLElement)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
@ -281,6 +294,7 @@ describe('WorkflowPreview', () => {
},
)
fireEvent.click(screen.getByText('runLog.tracing'))
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('0')
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})

View File

@ -1,10 +1,12 @@
import type { ConversationVariable, Node } from '@/app/components/workflow/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { updateConversationVariables } from '@/service/workflow'
import ChatVariablePanel from '../index'
import { ChatVarType } from '../type'
type MockWorkflowStoreState = {
appId: string
setShowChatVariablePanel: (value: boolean) => void
conversationVariables: ConversationVariable[]
setConversationVariables: (value: ConversationVariable[]) => void
@ -17,9 +19,6 @@ type MockFlowStore = {
const mockSetShowChatVariablePanel = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn((_sync: boolean, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
const mockInvalidateConversationVarValues = vi.fn()
const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>()
const mockUpdateNodeVars = vi.fn<(node: Node, current: string[], next: string[]) => Node>()
@ -61,16 +60,15 @@ vi.mock('reactflow', () => ({
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => selector({
appId: 'app-1',
setShowChatVariablePanel: mockSetShowChatVariablePanel,
conversationVariables: mockConversationVariables,
setConversationVariables: mockSetConversationVariables,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
vi.mock('@/service/workflow', () => ({
updateConversationVariables: vi.fn(),
}))
vi.mock('../../../hooks/use-inspect-vars-crud', () => ({
@ -171,12 +169,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-conf
}))
describe('ChatVariablePanel', () => {
const mockUpdateConversationVariables = vi.mocked(updateConversationVariables)
beforeEach(() => {
vi.clearAllMocks()
mockConversationVariables = [createConversationVariable()]
mockFlowNodes = [createNode('node-1'), createNode('node-2')]
mockFindUsedVarNodes.mockReturnValue([])
mockUpdateNodeVars.mockImplementation((node: Node) => node)
mockUpdateConversationVariables.mockResolvedValue(undefined as never)
})
it('should toggle the tips area and close the panel', async () => {
@ -208,7 +209,13 @@ describe('ChatVariablePanel', () => {
createConversationVariable(),
])
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockUpdateConversationVariables).toHaveBeenCalledWith({
appId: 'app-1',
conversationVariables: [
expect.objectContaining({ id: 'var-added', name: 'fresh_var' }),
createConversationVariable(),
],
})
expect(mockInvalidateConversationVarValues).toHaveBeenCalledTimes(1)
})

View File

@ -77,7 +77,7 @@ describe('variable-modal helpers', () => {
objectValue: [],
type: ChatVarType.Boolean,
value: undefined,
})).toBe(true)
})).toBe(false)
expect(formatChatVariableValue({
editInJSON: false,

View File

@ -109,7 +109,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
},
}))
vi.mock('../hooks', () => ({
vi.mock('../hooks/use-chat', () => ({
useChat: (...args: unknown[]) => mockUseChat(...args),
}))

View File

@ -220,6 +220,7 @@ describe('useFileOperations', () => {
nodeId: 'node-from-node',
node,
treeRef,
nodeType: 'file',
appId: 'app-1',
storeApi: mocks.workflowStore,
treeData: mocks.treeData,

View File

@ -29,6 +29,7 @@ import {
importDSLConfirm,
} from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { collaborationManager } from './collaboration/core/collaboration-manager'
import { WORKFLOW_DATA_UPDATE } from './constants'
import {
getImportNotificationPayload,
@ -36,7 +37,6 @@ import {
normalizeWorkflowFeatures,
validateDSLContent,
} from './update-dsl-modal.helpers'
import { collaborationManager } from './collaboration/core/collaboration-manager'
import {
initialEdges,
initialNodes,

View File

@ -150,7 +150,7 @@ describe('VariableInspect Panel', () => {
showVariableInspectPanel: true,
})
fireEvent.click(screen.getAllByRole('button')[0])
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.getByText('workflow.debug.variableInspect.emptyTip')).toBeInTheDocument()
expect(store.getState().showVariableInspectPanel).toBe(false)

View File

@ -1,5 +1,6 @@
import type { VarInInspect } from '@/types/workflow'
import { VarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { VarInInspectType } from '@/types/workflow'
import {
formatInspectFileValue,
@ -9,6 +10,19 @@ import {
} from '../value-content.helpers'
describe('value-content helpers', () => {
const createFileValue = (id: string) => ({
related_id: id,
extension: '.txt',
filename: `${id}.txt`,
size: 1,
mime_type: 'text/plain',
transfer_method: TransferMethod.local_file,
type: 'document',
url: `https://example.com/${id}.txt`,
upload_file_id: `${id}-upload`,
remote_url: '',
})
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-1',
name: 'query',
@ -56,7 +70,7 @@ describe('value-content helpers', () => {
expect(formatInspectFileValue(createVar({
name: 'file',
value_type: VarType.file,
value: { id: 'file-1' },
value: createFileValue('file-1'),
}))).toHaveLength(1)
expect(isFileValueUploaded([{ upload_file_id: 'file-1' }])).toBe(true)
@ -65,7 +79,7 @@ describe('value-content helpers', () => {
type: VarInInspectType.system,
name: 'files',
value_type: VarType.arrayFile,
value: [{ id: 'file-2' }],
value: [createFileValue('file-2')],
}))).toHaveLength(1)
})

View File

@ -6339,9 +6339,6 @@
"no-restricted-imports": {
"count": 1
},
"perfectionist/sort-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}

View File

@ -56,7 +56,10 @@ export default defineConfig(({ mode }) => {
],
resolve: {
alias: {
'loro-crdt': path.resolve(projectRoot, 'node_modules/loro-crdt/web/index.js'),
'loro-crdt': path.resolve(
projectRoot,
isTest ? 'node_modules/loro-crdt/nodejs/index.js' : 'node_modules/loro-crdt/web/index.js',
),
},
tsconfigPaths: true,
},