From 3398962bfa9b20bc2e5e7a095c9326bc65ad0f4a Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 4 Mar 2026 10:59:31 +0800 Subject: [PATCH] test(workflow): add unit tests for workflow store slices (#32932) Co-authored-by: CodingOnStar --- .../__tests__/chat-variable-slice.spec.ts | 67 +++ .../__tests__/datasets-detail-store.spec.ts | 62 +++ .../__tests__/env-variable-slice.spec.ts | 67 +++ .../__tests__/inspect-vars-slice.spec.ts | 240 +++++++++ .../__tests__/plugin-dependency-store.spec.ts | 43 ++ .../store/__tests__/version-slice.spec.ts | 61 +++ .../__tests__/workflow-draft-slice.spec.ts | 105 ++++ .../store/__tests__/workflow-store.spec.ts | 486 ++++++++++++++++++ 8 files changed, 1131 insertions(+) create mode 100644 web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/datasets-detail-store.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/version-slice.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts create mode 100644 web/app/components/workflow/store/__tests__/workflow-store.spec.ts diff --git a/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts new file mode 100644 index 0000000000..512eb5b404 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts @@ -0,0 +1,67 @@ +import type { ConversationVariable } from '@/app/components/workflow/types' +import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' +import { createWorkflowStore } from '../workflow' + +function createStore() { + return createWorkflowStore({}) +} + +describe('Chat Variable Slice', () => { + describe('setShowChatVariablePanel', () => { + it('should hide other panels when opening', () => { + const store = createStore() + store.getState().setShowDebugAndPreviewPanel(true) + store.getState().setShowEnvPanel(true) + + store.getState().setShowChatVariablePanel(true) + + const state = store.getState() + expect(state.showChatVariablePanel).toBe(true) + expect(state.showDebugAndPreviewPanel).toBe(false) + expect(state.showEnvPanel).toBe(false) + expect(state.showGlobalVariablePanel).toBe(false) + }) + + it('should only close itself when setting false', () => { + const store = createStore() + store.getState().setShowChatVariablePanel(true) + + store.getState().setShowChatVariablePanel(false) + + expect(store.getState().showChatVariablePanel).toBe(false) + }) + }) + + describe('setShowGlobalVariablePanel', () => { + it('should hide other panels when opening', () => { + const store = createStore() + store.getState().setShowDebugAndPreviewPanel(true) + store.getState().setShowChatVariablePanel(true) + + store.getState().setShowGlobalVariablePanel(true) + + const state = store.getState() + expect(state.showGlobalVariablePanel).toBe(true) + expect(state.showDebugAndPreviewPanel).toBe(false) + expect(state.showChatVariablePanel).toBe(false) + expect(state.showEnvPanel).toBe(false) + }) + + it('should only close itself when setting false', () => { + const store = createStore() + store.getState().setShowGlobalVariablePanel(true) + store.getState().setShowGlobalVariablePanel(false) + + expect(store.getState().showGlobalVariablePanel).toBe(false) + }) + }) + + describe('setConversationVariables', () => { + it('should update conversationVariables', () => { + const store = createStore() + const vars: ConversationVariable[] = [{ id: 'cv1', name: 'history', value: [], value_type: ChatVarType.String, description: '' }] + store.getState().setConversationVariables(vars) + expect(store.getState().conversationVariables).toEqual(vars) + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/datasets-detail-store.spec.ts b/web/app/components/workflow/store/__tests__/datasets-detail-store.spec.ts new file mode 100644 index 0000000000..3728aeda8e --- /dev/null +++ b/web/app/components/workflow/store/__tests__/datasets-detail-store.spec.ts @@ -0,0 +1,62 @@ +import type { DataSet } from '@/models/datasets' +import { createDatasetsDetailStore } from '../../datasets-detail-store/store' + +function makeDataset(id: string, name: string): DataSet { + return { id, name } as DataSet +} + +describe('DatasetsDetailStore', () => { + describe('Initial State', () => { + it('should start with empty datasetsDetail', () => { + const store = createDatasetsDetailStore() + expect(store.getState().datasetsDetail).toEqual({}) + }) + }) + + describe('updateDatasetsDetail', () => { + it('should add datasets by id', () => { + const store = createDatasetsDetailStore() + const ds1 = makeDataset('ds-1', 'Dataset 1') + const ds2 = makeDataset('ds-2', 'Dataset 2') + + store.getState().updateDatasetsDetail([ds1, ds2]) + + expect(store.getState().datasetsDetail['ds-1']).toEqual(ds1) + expect(store.getState().datasetsDetail['ds-2']).toEqual(ds2) + }) + + it('should merge new datasets into existing ones', () => { + const store = createDatasetsDetailStore() + const ds1 = makeDataset('ds-1', 'First') + const ds2 = makeDataset('ds-2', 'Second') + const ds3 = makeDataset('ds-3', 'Third') + + store.getState().updateDatasetsDetail([ds1, ds2]) + store.getState().updateDatasetsDetail([ds3]) + + const detail = store.getState().datasetsDetail + expect(detail['ds-1']).toEqual(ds1) + expect(detail['ds-2']).toEqual(ds2) + expect(detail['ds-3']).toEqual(ds3) + }) + + it('should overwrite existing datasets with same id', () => { + const store = createDatasetsDetailStore() + const ds1v1 = makeDataset('ds-1', 'Version 1') + const ds1v2 = makeDataset('ds-1', 'Version 2') + + store.getState().updateDatasetsDetail([ds1v1]) + store.getState().updateDatasetsDetail([ds1v2]) + + expect(store.getState().datasetsDetail['ds-1'].name).toBe('Version 2') + }) + + it('should handle empty array without errors', () => { + const store = createDatasetsDetailStore() + store.getState().updateDatasetsDetail([makeDataset('ds-1', 'Test')]) + store.getState().updateDatasetsDetail([]) + + expect(store.getState().datasetsDetail['ds-1'].name).toBe('Test') + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts new file mode 100644 index 0000000000..95ed7d3955 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts @@ -0,0 +1,67 @@ +import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { createWorkflowStore } from '../workflow' + +function createStore() { + return createWorkflowStore({}) +} + +describe('Env Variable Slice', () => { + describe('setShowEnvPanel', () => { + it('should hide other panels when opening', () => { + const store = createStore() + store.getState().setShowDebugAndPreviewPanel(true) + store.getState().setShowChatVariablePanel(true) + + store.getState().setShowEnvPanel(true) + + const state = store.getState() + expect(state.showEnvPanel).toBe(true) + expect(state.showDebugAndPreviewPanel).toBe(false) + expect(state.showChatVariablePanel).toBe(false) + expect(state.showGlobalVariablePanel).toBe(false) + }) + + it('should only close itself when setting false', () => { + const store = createStore() + store.getState().setShowEnvPanel(true) + + store.getState().setShowEnvPanel(false) + + expect(store.getState().showEnvPanel).toBe(false) + }) + }) + + describe('setEnvironmentVariables', () => { + it('should update environmentVariables', () => { + const store = createStore() + const vars: EnvironmentVariable[] = [{ id: 'v1', name: 'API_KEY', value: 'secret', value_type: 'string', description: '' }] + store.getState().setEnvironmentVariables(vars) + expect(store.getState().environmentVariables).toEqual(vars) + }) + }) + + describe('setEnvSecrets', () => { + it('should update envSecrets', () => { + const store = createStore() + store.getState().setEnvSecrets({ API_KEY: '***' }) + expect(store.getState().envSecrets).toEqual({ API_KEY: '***' }) + }) + }) + + describe('Sequential Panel Switching', () => { + it('should correctly switch between exclusive panels', () => { + const store = createStore() + + store.getState().setShowChatVariablePanel(true) + expect(store.getState().showChatVariablePanel).toBe(true) + + store.getState().setShowEnvPanel(true) + expect(store.getState().showEnvPanel).toBe(true) + expect(store.getState().showChatVariablePanel).toBe(false) + + store.getState().setShowGlobalVariablePanel(true) + expect(store.getState().showGlobalVariablePanel).toBe(true) + expect(store.getState().showEnvPanel).toBe(false) + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts new file mode 100644 index 0000000000..225cb6a6c8 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts @@ -0,0 +1,240 @@ +import type { NodeWithVar, VarInInspect } from '@/types/workflow' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { VarInInspectType } from '@/types/workflow' +import { createWorkflowStore } from '../workflow' + +function createStore() { + return createWorkflowStore({}) +} + +function makeVar(overrides: Partial = {}): VarInInspect { + return { + id: 'var-1', + name: 'output', + type: VarInInspectType.node, + description: '', + selector: ['node-1', 'output'], + value_type: VarType.string, + value: 'hello', + edited: false, + visible: true, + is_truncated: false, + full_content: { size_bytes: 0, download_url: '' }, + ...overrides, + } +} + +function makeNodeWithVar(nodeId: string, vars: VarInInspect[]): NodeWithVar { + return { + nodeId, + nodePayload: { title: `Node ${nodeId}`, desc: '', type: BlockEnum.Code } as NodeWithVar['nodePayload'], + nodeType: BlockEnum.Code, + title: `Node ${nodeId}`, + vars, + isValueFetched: false, + } +} + +describe('Inspect Vars Slice', () => { + describe('setNodesWithInspectVars', () => { + it('should replace the entire list', () => { + const store = createStore() + const nodes = [makeNodeWithVar('n1', [makeVar()])] + store.getState().setNodesWithInspectVars(nodes) + expect(store.getState().nodesWithInspectVars).toEqual(nodes) + }) + }) + + describe('deleteAllInspectVars', () => { + it('should clear all nodes', () => { + const store = createStore() + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])]) + store.getState().deleteAllInspectVars() + expect(store.getState().nodesWithInspectVars).toEqual([]) + }) + }) + + describe('setNodeInspectVars', () => { + it('should update vars for a specific node and mark as fetched', () => { + const store = createStore() + const v1 = makeVar({ id: 'v1', name: 'a' }) + const v2 = makeVar({ id: 'v2', name: 'b' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v1])]) + + store.getState().setNodeInspectVars('n1', [v2]) + + const node = store.getState().nodesWithInspectVars[0] + expect(node.vars).toEqual([v2]) + expect(node.isValueFetched).toBe(true) + }) + + it('should not modify state when node is not found', () => { + const store = createStore() + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])]) + + store.getState().setNodeInspectVars('non-existent', []) + + expect(store.getState().nodesWithInspectVars[0].vars).toHaveLength(1) + }) + }) + + describe('deleteNodeInspectVars', () => { + it('should remove the matching node', () => { + const store = createStore() + store.getState().setNodesWithInspectVars([ + makeNodeWithVar('n1', [makeVar()]), + makeNodeWithVar('n2', [makeVar()]), + ]) + + store.getState().deleteNodeInspectVars('n1') + + expect(store.getState().nodesWithInspectVars).toHaveLength(1) + expect(store.getState().nodesWithInspectVars[0].nodeId).toBe('n2') + }) + }) + + describe('setInspectVarValue', () => { + it('should update the value and set edited=true', () => { + const store = createStore() + const v = makeVar({ id: 'v1', value: 'old', edited: false }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().setInspectVarValue('n1', 'v1', 'new') + + const updated = store.getState().nodesWithInspectVars[0].vars[0] + expect(updated.value).toBe('new') + expect(updated.edited).toBe(true) + }) + + it('should not change state when var is not found', () => { + const store = createStore() + const v = makeVar({ id: 'v1', value: 'old' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().setInspectVarValue('n1', 'wrong-id', 'new') + + expect(store.getState().nodesWithInspectVars[0].vars[0].value).toBe('old') + }) + + it('should not change state when node is not found', () => { + const store = createStore() + const v = makeVar({ id: 'v1', value: 'old' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().setInspectVarValue('wrong-node', 'v1', 'new') + + expect(store.getState().nodesWithInspectVars[0].vars[0].value).toBe('old') + }) + }) + + describe('resetToLastRunVar', () => { + it('should restore value and set edited=false', () => { + const store = createStore() + const v = makeVar({ id: 'v1', value: 'modified', edited: true }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().resetToLastRunVar('n1', 'v1', 'original') + + const updated = store.getState().nodesWithInspectVars[0].vars[0] + expect(updated.value).toBe('original') + expect(updated.edited).toBe(false) + }) + + it('should not change state when node is not found', () => { + const store = createStore() + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])]) + + store.getState().resetToLastRunVar('wrong-node', 'v1', 'val') + + expect(store.getState().nodesWithInspectVars[0].vars[0].edited).toBe(false) + }) + + it('should not change state when var is not found', () => { + const store = createStore() + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar({ id: 'v1', edited: true })])]) + + store.getState().resetToLastRunVar('n1', 'wrong-var', 'val') + + expect(store.getState().nodesWithInspectVars[0].vars[0].edited).toBe(true) + }) + }) + + describe('renameInspectVarName', () => { + it('should update name and selector', () => { + const store = createStore() + const v = makeVar({ id: 'v1', name: 'old_name', selector: ['n1', 'old_name'] }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().renameInspectVarName('n1', 'v1', ['n1', 'new_name']) + + const updated = store.getState().nodesWithInspectVars[0].vars[0] + expect(updated.name).toBe('new_name') + expect(updated.selector).toEqual(['n1', 'new_name']) + }) + + it('should not change state when node is not found', () => { + const store = createStore() + const v = makeVar({ id: 'v1', name: 'old' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().renameInspectVarName('wrong-node', 'v1', ['x', 'y']) + + expect(store.getState().nodesWithInspectVars[0].vars[0].name).toBe('old') + }) + + it('should not change state when var is not found', () => { + const store = createStore() + const v = makeVar({ id: 'v1', name: 'old' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().renameInspectVarName('n1', 'wrong-var', ['x', 'y']) + + expect(store.getState().nodesWithInspectVars[0].vars[0].name).toBe('old') + }) + }) + + describe('deleteInspectVar', () => { + it('should remove the matching var from the node', () => { + const store = createStore() + const v1 = makeVar({ id: 'v1' }) + const v2 = makeVar({ id: 'v2' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v1, v2])]) + + store.getState().deleteInspectVar('n1', 'v1') + + const vars = store.getState().nodesWithInspectVars[0].vars + expect(vars).toHaveLength(1) + expect(vars[0].id).toBe('v2') + }) + + it('should not change state when var is not found', () => { + const store = createStore() + const v = makeVar({ id: 'v1' }) + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [v])]) + + store.getState().deleteInspectVar('n1', 'wrong-id') + + expect(store.getState().nodesWithInspectVars[0].vars).toHaveLength(1) + }) + + it('should not change state when node is not found', () => { + const store = createStore() + store.getState().setNodesWithInspectVars([makeNodeWithVar('n1', [makeVar()])]) + + store.getState().deleteInspectVar('wrong-node', 'v1') + + expect(store.getState().nodesWithInspectVars[0].vars).toHaveLength(1) + }) + }) + + describe('currentFocusNodeId', () => { + it('should update and clear focus node', () => { + const store = createStore() + store.getState().setCurrentFocusNodeId('n1') + expect(store.getState().currentFocusNodeId).toBe('n1') + + store.getState().setCurrentFocusNodeId(null) + expect(store.getState().currentFocusNodeId).toBeNull() + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts b/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts new file mode 100644 index 0000000000..8c0cdd8337 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/plugin-dependency-store.spec.ts @@ -0,0 +1,43 @@ +import type { Dependency } from '@/app/components/plugins/types' +import { useStore } from '../../plugin-dependency/store' + +describe('Plugin Dependency Store', () => { + beforeEach(() => { + useStore.setState({ dependencies: [] }) + }) + + describe('Initial State', () => { + it('should start with empty dependencies', () => { + expect(useStore.getState().dependencies).toEqual([]) + }) + }) + + describe('setDependencies', () => { + it('should update dependencies list', () => { + const deps: Dependency[] = [ + { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } }, + { type: 'marketplace', value: { plugin_unique_identifier: 'p2' } }, + ] as Dependency[] + + useStore.getState().setDependencies(deps) + expect(useStore.getState().dependencies).toEqual(deps) + }) + + it('should replace existing dependencies', () => { + const dep1: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } } as Dependency + const dep2: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p2' } } as Dependency + useStore.getState().setDependencies([dep1]) + useStore.getState().setDependencies([dep2]) + + expect(useStore.getState().dependencies).toHaveLength(1) + }) + + it('should handle empty array', () => { + const dep: Dependency = { type: 'marketplace', value: { plugin_unique_identifier: 'p1' } } as Dependency + useStore.getState().setDependencies([dep]) + useStore.getState().setDependencies([]) + + expect(useStore.getState().dependencies).toEqual([]) + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/version-slice.spec.ts b/web/app/components/workflow/store/__tests__/version-slice.spec.ts new file mode 100644 index 0000000000..8d76a62256 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/version-slice.spec.ts @@ -0,0 +1,61 @@ +import type { VersionHistory } from '@/types/workflow' +import { createWorkflowStore } from '../workflow' + +function createStore() { + return createWorkflowStore({}) +} + +describe('Version Slice', () => { + describe('setDraftUpdatedAt', () => { + it('should multiply timestamp by 1000 (seconds to milliseconds)', () => { + const store = createStore() + store.getState().setDraftUpdatedAt(1704067200) + expect(store.getState().draftUpdatedAt).toBe(1704067200000) + }) + + it('should set 0 when given 0', () => { + const store = createStore() + store.getState().setDraftUpdatedAt(0) + expect(store.getState().draftUpdatedAt).toBe(0) + }) + }) + + describe('setPublishedAt', () => { + it('should multiply timestamp by 1000', () => { + const store = createStore() + store.getState().setPublishedAt(1704067200) + expect(store.getState().publishedAt).toBe(1704067200000) + }) + + it('should set 0 when given 0', () => { + const store = createStore() + store.getState().setPublishedAt(0) + expect(store.getState().publishedAt).toBe(0) + }) + }) + + describe('currentVersion', () => { + it('should default to null', () => { + const store = createStore() + expect(store.getState().currentVersion).toBeNull() + }) + + it('should update current version', () => { + const store = createStore() + const version = { hash: 'abc', updated_at: 1000, version: '1.0' } as VersionHistory + store.getState().setCurrentVersion(version) + expect(store.getState().currentVersion).toEqual(version) + }) + }) + + describe('isRestoring', () => { + it('should toggle restoring state', () => { + const store = createStore() + store.getState().setIsRestoring(true) + expect(store.getState().isRestoring).toBe(true) + + store.getState().setIsRestoring(false) + expect(store.getState().isRestoring).toBe(false) + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts b/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts new file mode 100644 index 0000000000..dfbc58e050 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts @@ -0,0 +1,105 @@ +import type { Node } from '@/app/components/workflow/types' +import { createWorkflowStore } from '../workflow' + +function createStore() { + return createWorkflowStore({}) +} + +describe('Workflow Draft Slice', () => { + describe('Initial State', () => { + it('should have empty default values', () => { + const store = createStore() + const state = store.getState() + expect(state.backupDraft).toBeUndefined() + expect(state.syncWorkflowDraftHash).toBe('') + expect(state.isSyncingWorkflowDraft).toBe(false) + expect(state.isWorkflowDataLoaded).toBe(false) + expect(state.nodes).toEqual([]) + }) + }) + + describe('setBackupDraft', () => { + it('should set and clear backup draft', () => { + const store = createStore() + const draft = { + nodes: [] as Node[], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + environmentVariables: [], + } + store.getState().setBackupDraft(draft) + expect(store.getState().backupDraft).toEqual(draft) + + store.getState().setBackupDraft(undefined) + expect(store.getState().backupDraft).toBeUndefined() + }) + }) + + describe('setSyncWorkflowDraftHash', () => { + it('should update the hash', () => { + const store = createStore() + store.getState().setSyncWorkflowDraftHash('abc123') + expect(store.getState().syncWorkflowDraftHash).toBe('abc123') + }) + }) + + describe('setIsSyncingWorkflowDraft', () => { + it('should toggle syncing state', () => { + const store = createStore() + store.getState().setIsSyncingWorkflowDraft(true) + expect(store.getState().isSyncingWorkflowDraft).toBe(true) + }) + }) + + describe('setIsWorkflowDataLoaded', () => { + it('should toggle loaded state', () => { + const store = createStore() + store.getState().setIsWorkflowDataLoaded(true) + expect(store.getState().isWorkflowDataLoaded).toBe(true) + }) + }) + + describe('setNodes', () => { + it('should update nodes array', () => { + const store = createStore() + const nodes: Node[] = [] + store.getState().setNodes(nodes) + expect(store.getState().nodes).toEqual(nodes) + }) + }) + + describe('debouncedSyncWorkflowDraft', () => { + it('should be a callable function', () => { + const store = createStore() + expect(typeof store.getState().debouncedSyncWorkflowDraft).toBe('function') + }) + + it('should debounce the sync call', () => { + vi.useFakeTimers() + const store = createStore() + const syncFn = vi.fn() + + store.getState().debouncedSyncWorkflowDraft(syncFn) + expect(syncFn).not.toHaveBeenCalled() + + vi.advanceTimersByTime(5000) + expect(syncFn).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) + + it('should flush pending sync via flushPendingSync', () => { + vi.useFakeTimers() + const store = createStore() + const syncFn = vi.fn() + + store.getState().debouncedSyncWorkflowDraft(syncFn) + expect(syncFn).not.toHaveBeenCalled() + + store.getState().flushPendingSync() + expect(syncFn).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) + }) +}) diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts new file mode 100644 index 0000000000..df94be90b8 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -0,0 +1,486 @@ +import type { Shape, SliceFromInjection } from '../workflow' +import type { HelpLineHorizontalPosition, HelpLineVerticalPosition } from '@/app/components/workflow/help-line/types' +import type { WorkflowRunningData } from '@/app/components/workflow/types' +import type { FileUploadConfigResponse } from '@/models/common' +import type { VersionHistory } from '@/types/workflow' +import { renderHook } from '@testing-library/react' +import * as React from 'react' +import { BlockEnum } from '@/app/components/workflow/types' +import { WorkflowContext } from '../../context' +import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow' + +function createStore() { + return createWorkflowStore({}) +} + +describe('createWorkflowStore', () => { + describe('Initial State', () => { + it('should create a store with all slices merged', () => { + const store = createStore() + const state = store.getState() + + expect(state.showSingleRunPanel).toBe(false) + expect(state.controlMode).toBeDefined() + expect(state.nodes).toEqual([]) + expect(state.environmentVariables).toEqual([]) + expect(state.conversationVariables).toEqual([]) + expect(state.nodesWithInspectVars).toEqual([]) + expect(state.workflowCanvasWidth).toBeUndefined() + expect(state.draftUpdatedAt).toBe(0) + expect(state.versionHistory).toEqual([]) + }) + }) + + describe('Workflow Slice Setters', () => { + it('should update workflowRunningData', () => { + const store = createStore() + const data: Partial = { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } } + store.getState().setWorkflowRunningData(data as Parameters[0]) + expect(store.getState().workflowRunningData).toEqual(data) + }) + + it('should update isListening', () => { + const store = createStore() + store.getState().setIsListening(true) + expect(store.getState().isListening).toBe(true) + }) + + it('should update listeningTriggerType', () => { + const store = createStore() + store.getState().setListeningTriggerType(BlockEnum.TriggerWebhook) + expect(store.getState().listeningTriggerType).toBe(BlockEnum.TriggerWebhook) + }) + + it('should update listeningTriggerNodeId', () => { + const store = createStore() + store.getState().setListeningTriggerNodeId('node-abc') + expect(store.getState().listeningTriggerNodeId).toBe('node-abc') + }) + + it('should update listeningTriggerNodeIds', () => { + const store = createStore() + store.getState().setListeningTriggerNodeIds(['n1', 'n2']) + expect(store.getState().listeningTriggerNodeIds).toEqual(['n1', 'n2']) + }) + + it('should update listeningTriggerIsAll', () => { + const store = createStore() + store.getState().setListeningTriggerIsAll(true) + expect(store.getState().listeningTriggerIsAll).toBe(true) + }) + + it('should update clipboardElements', () => { + const store = createStore() + store.getState().setClipboardElements([]) + expect(store.getState().clipboardElements).toEqual([]) + }) + + it('should update selection', () => { + const store = createStore() + const sel = { x1: 0, y1: 0, x2: 100, y2: 100 } + store.getState().setSelection(sel) + expect(store.getState().selection).toEqual(sel) + }) + + it('should update bundleNodeSize', () => { + const store = createStore() + store.getState().setBundleNodeSize({ width: 200, height: 100 }) + expect(store.getState().bundleNodeSize).toEqual({ width: 200, height: 100 }) + }) + + it('should persist controlMode to localStorage', () => { + const store = createStore() + store.getState().setControlMode('pointer') + expect(store.getState().controlMode).toBe('pointer') + expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', 'pointer') + }) + + it('should update mousePosition', () => { + const store = createStore() + const pos = { pageX: 10, pageY: 20, elementX: 5, elementY: 15 } + store.getState().setMousePosition(pos) + expect(store.getState().mousePosition).toEqual(pos) + }) + + it('should update showConfirm', () => { + const store = createStore() + const confirm = { title: 'Delete?', onConfirm: vi.fn() } + store.getState().setShowConfirm(confirm) + expect(store.getState().showConfirm).toEqual(confirm) + }) + + it('should update controlPromptEditorRerenderKey', () => { + const store = createStore() + store.getState().setControlPromptEditorRerenderKey(42) + expect(store.getState().controlPromptEditorRerenderKey).toBe(42) + }) + + it('should update showImportDSLModal', () => { + const store = createStore() + store.getState().setShowImportDSLModal(true) + expect(store.getState().showImportDSLModal).toBe(true) + }) + + it('should update fileUploadConfig', () => { + const store = createStore() + const config: FileUploadConfigResponse = { + batch_count_limit: 5, + image_file_batch_limit: 10, + single_chunk_attachment_limit: 10, + attachment_image_file_size_limit: 2, + file_size_limit: 15, + file_upload_limit: 5, + } + store.getState().setFileUploadConfig(config) + expect(store.getState().fileUploadConfig).toEqual(config) + }) + }) + + describe('Node Slice Setters', () => { + it('should update showSingleRunPanel', () => { + const store = createStore() + store.getState().setShowSingleRunPanel(true) + expect(store.getState().showSingleRunPanel).toBe(true) + }) + + it('should update nodeAnimation', () => { + const store = createStore() + store.getState().setNodeAnimation(true) + expect(store.getState().nodeAnimation).toBe(true) + }) + + it('should update candidateNode', () => { + const store = createStore() + store.getState().setCandidateNode(undefined) + expect(store.getState().candidateNode).toBeUndefined() + }) + + it('should update nodeMenu', () => { + const store = createStore() + store.getState().setNodeMenu({ top: 100, left: 200, nodeId: 'n1' }) + expect(store.getState().nodeMenu).toEqual({ top: 100, left: 200, nodeId: 'n1' }) + }) + + it('should update showAssignVariablePopup', () => { + const store = createStore() + store.getState().setShowAssignVariablePopup(undefined) + expect(store.getState().showAssignVariablePopup).toBeUndefined() + }) + + it('should update hoveringAssignVariableGroupId', () => { + const store = createStore() + store.getState().setHoveringAssignVariableGroupId('group-1') + expect(store.getState().hoveringAssignVariableGroupId).toBe('group-1') + }) + + it('should update connectingNodePayload', () => { + const store = createStore() + const payload = { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' } + store.getState().setConnectingNodePayload(payload) + expect(store.getState().connectingNodePayload).toEqual(payload) + }) + + it('should update enteringNodePayload', () => { + const store = createStore() + store.getState().setEnteringNodePayload(undefined) + expect(store.getState().enteringNodePayload).toBeUndefined() + }) + + it('should update iterTimes', () => { + const store = createStore() + store.getState().setIterTimes(5) + expect(store.getState().iterTimes).toBe(5) + }) + + it('should update loopTimes', () => { + const store = createStore() + store.getState().setLoopTimes(10) + expect(store.getState().loopTimes).toBe(10) + }) + + it('should update iterParallelLogMap', () => { + const store = createStore() + const map = new Map>() + store.getState().setIterParallelLogMap(map) + expect(store.getState().iterParallelLogMap).toBe(map) + }) + + it('should update pendingSingleRun', () => { + const store = createStore() + store.getState().setPendingSingleRun({ nodeId: 'n1', action: 'run' }) + expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'n1', action: 'run' }) + }) + }) + + describe('Panel Slice Setters', () => { + it('should update showFeaturesPanel', () => { + const store = createStore() + store.getState().setShowFeaturesPanel(true) + expect(store.getState().showFeaturesPanel).toBe(true) + }) + + it('should update showWorkflowVersionHistoryPanel', () => { + const store = createStore() + store.getState().setShowWorkflowVersionHistoryPanel(true) + expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true) + }) + + it('should update showInputsPanel', () => { + const store = createStore() + store.getState().setShowInputsPanel(true) + expect(store.getState().showInputsPanel).toBe(true) + }) + + it('should update showDebugAndPreviewPanel', () => { + const store = createStore() + store.getState().setShowDebugAndPreviewPanel(true) + expect(store.getState().showDebugAndPreviewPanel).toBe(true) + }) + + it('should update panelMenu', () => { + const store = createStore() + store.getState().setPanelMenu({ top: 10, left: 20 }) + expect(store.getState().panelMenu).toEqual({ top: 10, left: 20 }) + }) + + it('should update selectionMenu', () => { + const store = createStore() + store.getState().setSelectionMenu({ top: 50, left: 60 }) + expect(store.getState().selectionMenu).toEqual({ top: 50, left: 60 }) + }) + + it('should update showVariableInspectPanel', () => { + const store = createStore() + store.getState().setShowVariableInspectPanel(true) + expect(store.getState().showVariableInspectPanel).toBe(true) + }) + + it('should update initShowLastRunTab', () => { + const store = createStore() + store.getState().setInitShowLastRunTab(true) + expect(store.getState().initShowLastRunTab).toBe(true) + }) + }) + + describe('Help Line Slice Setters', () => { + it('should update helpLineHorizontal', () => { + const store = createStore() + const pos: HelpLineHorizontalPosition = { top: 100, left: 0, width: 500 } + store.getState().setHelpLineHorizontal(pos) + expect(store.getState().helpLineHorizontal).toEqual(pos) + }) + + it('should clear helpLineHorizontal', () => { + const store = createStore() + store.getState().setHelpLineHorizontal({ top: 100, left: 0, width: 500 }) + store.getState().setHelpLineHorizontal(undefined) + expect(store.getState().helpLineHorizontal).toBeUndefined() + }) + + it('should update helpLineVertical', () => { + const store = createStore() + const pos: HelpLineVerticalPosition = { top: 0, left: 200, height: 300 } + store.getState().setHelpLineVertical(pos) + expect(store.getState().helpLineVertical).toEqual(pos) + }) + }) + + describe('History Slice Setters', () => { + it('should update historyWorkflowData', () => { + const store = createStore() + store.getState().setHistoryWorkflowData({ id: 'run-1', status: 'succeeded' }) + expect(store.getState().historyWorkflowData).toEqual({ id: 'run-1', status: 'succeeded' }) + }) + + it('should update showRunHistory', () => { + const store = createStore() + store.getState().setShowRunHistory(true) + expect(store.getState().showRunHistory).toBe(true) + }) + + it('should update versionHistory', () => { + const store = createStore() + const history: VersionHistory[] = [] + store.getState().setVersionHistory(history) + expect(store.getState().versionHistory).toEqual(history) + }) + }) + + describe('Form Slice Setters', () => { + it('should update inputs', () => { + const store = createStore() + store.getState().setInputs({ name: 'test', count: 42 }) + expect(store.getState().inputs).toEqual({ name: 'test', count: 42 }) + }) + + it('should update files', () => { + const store = createStore() + store.getState().setFiles([]) + expect(store.getState().files).toEqual([]) + }) + }) + + describe('Tool Slice Setters', () => { + it('should update toolPublished', () => { + const store = createStore() + store.getState().setToolPublished(true) + expect(store.getState().toolPublished).toBe(true) + }) + + it('should update lastPublishedHasUserInput', () => { + const store = createStore() + store.getState().setLastPublishedHasUserInput(true) + expect(store.getState().lastPublishedHasUserInput).toBe(true) + }) + }) + + describe('Layout Slice Setters', () => { + it('should update workflowCanvasWidth', () => { + const store = createStore() + store.getState().setWorkflowCanvasWidth(1200) + expect(store.getState().workflowCanvasWidth).toBe(1200) + }) + + it('should update workflowCanvasHeight', () => { + const store = createStore() + store.getState().setWorkflowCanvasHeight(800) + expect(store.getState().workflowCanvasHeight).toBe(800) + }) + + it('should update rightPanelWidth', () => { + const store = createStore() + store.getState().setRightPanelWidth(500) + expect(store.getState().rightPanelWidth).toBe(500) + }) + + it('should update nodePanelWidth', () => { + const store = createStore() + store.getState().setNodePanelWidth(350) + expect(store.getState().nodePanelWidth).toBe(350) + }) + + it('should update previewPanelWidth', () => { + const store = createStore() + store.getState().setPreviewPanelWidth(450) + expect(store.getState().previewPanelWidth).toBe(450) + }) + + it('should update otherPanelWidth', () => { + const store = createStore() + store.getState().setOtherPanelWidth(380) + expect(store.getState().otherPanelWidth).toBe(380) + }) + + it('should update bottomPanelWidth', () => { + const store = createStore() + store.getState().setBottomPanelWidth(600) + expect(store.getState().bottomPanelWidth).toBe(600) + }) + + it('should update bottomPanelHeight', () => { + const store = createStore() + store.getState().setBottomPanelHeight(500) + expect(store.getState().bottomPanelHeight).toBe(500) + }) + + it('should update variableInspectPanelHeight', () => { + const store = createStore() + store.getState().setVariableInspectPanelHeight(250) + expect(store.getState().variableInspectPanelHeight).toBe(250) + }) + + it('should update maximizeCanvas', () => { + const store = createStore() + store.getState().setMaximizeCanvas(true) + expect(store.getState().maximizeCanvas).toBe(true) + }) + }) + + describe('localStorage Initialization', () => { + it('should read controlMode from localStorage', () => { + localStorage.setItem('workflow-operation-mode', 'pointer') + const store = createStore() + expect(store.getState().controlMode).toBe('pointer') + }) + + it('should default controlMode to hand when localStorage has no value', () => { + const store = createStore() + expect(store.getState().controlMode).toBe('hand') + }) + + it('should read panelWidth from localStorage', () => { + localStorage.setItem('workflow-node-panel-width', '500') + const store = createStore() + expect(store.getState().panelWidth).toBe(500) + }) + + it('should default panelWidth to 420 when localStorage is empty', () => { + const store = createStore() + expect(store.getState().panelWidth).toBe(420) + }) + + it('should read nodePanelWidth from localStorage', () => { + localStorage.setItem('workflow-node-panel-width', '350') + const store = createStore() + expect(store.getState().nodePanelWidth).toBe(350) + }) + + it('should read previewPanelWidth from localStorage', () => { + localStorage.setItem('debug-and-preview-panel-width', '450') + const store = createStore() + expect(store.getState().previewPanelWidth).toBe(450) + }) + + it('should read variableInspectPanelHeight from localStorage', () => { + localStorage.setItem('workflow-variable-inpsect-panel-height', '200') + const store = createStore() + expect(store.getState().variableInspectPanelHeight).toBe(200) + }) + + it('should read maximizeCanvas from localStorage', () => { + localStorage.setItem('workflow-canvas-maximize', 'true') + const store = createStore() + expect(store.getState().maximizeCanvas).toBe(true) + }) + }) + + describe('useStore hook', () => { + it('should read state via selector when wrapped in WorkflowContext', () => { + const store = createStore() + store.getState().setShowSingleRunPanel(true) + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(WorkflowContext.Provider, { value: store }, children) + + const { result } = renderHook(() => useStore(s => s.showSingleRunPanel), { wrapper }) + expect(result.current).toBe(true) + }) + + it('should throw when used without WorkflowContext.Provider', () => { + expect(() => { + renderHook(() => useStore(s => s.showSingleRunPanel)) + }).toThrow('Missing WorkflowContext.Provider in the tree') + }) + }) + + describe('useWorkflowStore hook', () => { + it('should return the store instance when wrapped in WorkflowContext', () => { + const store = createStore() + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(WorkflowContext.Provider, { value: store }, children) + + const { result } = renderHook(() => useWorkflowStore(), { wrapper }) + expect(result.current).toBe(store) + }) + }) + + describe('Injection', () => { + it('should support injecting additional slice', () => { + const injected: SliceFromInjection = {} + const store = createWorkflowStore({ + injectWorkflowStoreSliceFn: () => injected, + }) + expect(store.getState()).toBeDefined() + }) + }) +})