From 5385ec3023c46001ef1462a29ab3937d342256f2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 4 Mar 2026 17:24:50 +0800 Subject: [PATCH 1/3] test(workflow): add comprehensive hooks unit tests and refactor test infrastructure (Part 3) (#32958) Co-authored-by: CodingOnStar --- .../components/workflow/__tests__/fixtures.ts | 73 ++- .../workflow/__tests__/mock-hooks-store.ts | 59 --- .../workflow/__tests__/mock-reactflow.ts | 110 ----- .../workflow/__tests__/mock-workflow-store.ts | 199 -------- .../__tests__/reactflow-mock-state.ts | 143 ++++++ .../__tests__/service-mock-factory.ts | 75 +++ ....test.tsx => trigger-status-sync.spec.tsx} | 6 +- .../workflow/__tests__/workflow-test-env.tsx | 195 ++++++++ .../use-auto-generate-webhook-url.spec.ts | 83 ++++ .../__tests__/use-available-blocks.spec.ts | 162 +++++++ .../hooks/__tests__/use-checklist.spec.ts | 312 +++++++++++++ .../__tests__/use-edges-interactions.spec.ts | 151 ++++++ .../hooks/__tests__/use-helpline.spec.ts | 194 ++++++++ .../__tests__/use-hooksstore-wrappers.spec.ts | 79 ++++ .../__tests__/use-node-data-update.spec.ts | 99 ++++ .../__tests__/use-nodes-sync-draft.spec.ts | 79 ++++ .../__tests__/use-panel-interactions.spec.ts | 78 ++++ .../use-selection-interactions.spec.ts | 190 ++++++++ .../use-serial-async-callback.spec.ts | 94 ++++ .../hooks/__tests__/use-tool-icon.spec.ts | 171 +++++++ .../__tests__/use-without-sync-hooks.spec.ts | 130 ++++++ .../hooks/__tests__/use-workflow-mode.spec.ts | 47 ++ .../use-workflow-run-event-store-only.spec.ts | 242 ++++++++++ .../use-workflow-run-event-with-store.spec.ts | 269 +++++++++++ ...e-workflow-run-event-with-viewport.spec.ts | 244 ++++++++++ .../__tests__/use-workflow-variables.spec.ts | 148 ++++++ .../hooks/__tests__/use-workflow.spec.ts | 234 ++++++++++ ...an-input.test.tsx => human-input.spec.tsx} | 0 ...ls.test.ts => output-schema-utils.spec.ts} | 2 +- ...m-helpers.test.ts => form-helpers.spec.ts} | 4 +- .../__tests__/chat-variable-slice.spec.ts | 4 +- .../__tests__/env-variable-slice.spec.ts | 4 +- .../__tests__/inspect-vars-slice.spec.ts | 4 +- ...-status.test.ts => trigger-status.spec.ts} | 0 .../store/__tests__/version-slice.spec.ts | 4 +- .../__tests__/workflow-draft-slice.spec.ts | 18 +- .../store/__tests__/workflow-store.spec.ts | 438 ++++-------------- 37 files changed, 3615 insertions(+), 729 deletions(-) delete mode 100644 web/app/components/workflow/__tests__/mock-hooks-store.ts delete mode 100644 web/app/components/workflow/__tests__/mock-reactflow.ts delete mode 100644 web/app/components/workflow/__tests__/mock-workflow-store.ts create mode 100644 web/app/components/workflow/__tests__/reactflow-mock-state.ts create mode 100644 web/app/components/workflow/__tests__/service-mock-factory.ts rename web/app/components/workflow/__tests__/{trigger-status-sync.test.tsx => trigger-status-sync.spec.tsx} (98%) create mode 100644 web/app/components/workflow/__tests__/workflow-test-env.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts rename web/app/components/workflow/nodes/human-input/__tests__/{human-input.test.tsx => human-input.spec.tsx} (100%) rename web/app/components/workflow/nodes/tool/__tests__/{output-schema-utils.test.ts => output-schema-utils.spec.ts} (99%) rename web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/{form-helpers.test.ts => form-helpers.spec.ts} (98%) rename web/app/components/workflow/store/__tests__/{trigger-status.test.ts => trigger-status.spec.ts} (100%) diff --git a/web/app/components/workflow/__tests__/fixtures.ts b/web/app/components/workflow/__tests__/fixtures.ts index 50a42ebe3d..ebc1d0d300 100644 --- a/web/app/components/workflow/__tests__/fixtures.ts +++ b/web/app/components/workflow/__tests__/fixtures.ts @@ -1,4 +1,5 @@ -import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../types' +import type { CommonEdgeType, CommonNodeType, Edge, Node, ToolWithProvider, WorkflowRunningData } from '../types' +import type { NodeTracing } from '@/types/workflow' import { Position } from 'reactflow' import { CUSTOM_NODE } from '../constants' import { BlockEnum, NodeRunningStatus } from '../types' @@ -108,4 +109,74 @@ export function createLinearGraph(nodeCount: number): { nodes: Node[], edges: Ed return { nodes, edges } } +// --------------------------------------------------------------------------- +// Workflow-level factories +// --------------------------------------------------------------------------- + +export function createWorkflowRunningData( + overrides?: Partial, +): WorkflowRunningData { + return { + task_id: 'task-test', + result: { + status: 'running', + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + ...overrides?.result, + }, + tracing: overrides?.tracing ?? [], + ...overrides, + } +} + +export function createNodeTracing( + overrides?: Partial, +): NodeTracing { + const nodeId = overrides?.node_id ?? 'node-1' + return { + id: `trace-${nodeId}`, + index: 0, + predecessor_node_id: '', + node_id: nodeId, + node_type: BlockEnum.Code, + title: 'Node', + inputs: null, + inputs_truncated: false, + process_data: null, + process_data_truncated: false, + outputs_truncated: false, + status: NodeRunningStatus.Running, + elapsed_time: 0, + metadata: { iterator_length: 0, iterator_index: 0, loop_length: 0, loop_index: 0 }, + created_at: 0, + created_by: { id: 'user-1', name: 'Test', email: 'test@test.com' }, + finished_at: 0, + ...overrides, + } +} + +export function createToolWithProvider( + overrides?: Partial, +): ToolWithProvider { + return { + id: 'tool-provider-1', + name: 'test-tool', + author: 'test', + description: { en_US: 'Test tool', zh_Hans: '测试工具' }, + icon: '/icon.svg', + icon_dark: '/icon-dark.svg', + label: { en_US: 'Test Tool', zh_Hans: '测试工具' }, + type: 'builtin', + team_credentials: {}, + is_team_authorization: false, + allow_delete: true, + labels: [], + tools: [], + meta: { version: '0.0.1' }, + plugin_id: 'plugin-1', + ...overrides, + } +} + export { BlockEnum, NodeRunningStatus } diff --git a/web/app/components/workflow/__tests__/mock-hooks-store.ts b/web/app/components/workflow/__tests__/mock-hooks-store.ts deleted file mode 100644 index 9363b31c35..0000000000 --- a/web/app/components/workflow/__tests__/mock-hooks-store.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { noop } from 'es-toolkit' - -/** - * Default hooks store state. - * All function fields default to noop / vi.fn() stubs. - * Use `createHooksStoreState(overrides)` to get a customised state object. - */ -export function createHooksStoreState(overrides: Record = {}) { - return { - refreshAll: noop, - - // draft sync - doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), - syncWorkflowDraftWhenPageClose: noop, - handleRefreshWorkflowDraft: noop, - handleBackupDraft: noop, - handleLoadBackupDraft: noop, - handleRestoreFromPublishedWorkflow: noop, - - // run - handleRun: noop, - handleStopRun: noop, - handleStartWorkflowRun: noop, - handleWorkflowStartRunInWorkflow: noop, - handleWorkflowStartRunInChatflow: noop, - handleWorkflowTriggerScheduleRunInWorkflow: noop, - handleWorkflowTriggerWebhookRunInWorkflow: noop, - handleWorkflowTriggerPluginRunInWorkflow: noop, - handleWorkflowRunAllTriggersInWorkflow: noop, - - // meta - availableNodesMetaData: undefined, - configsMap: undefined, - - // export / DSL - exportCheck: vi.fn().mockResolvedValue(undefined), - handleExportDSL: vi.fn().mockResolvedValue(undefined), - getWorkflowRunAndTraceUrl: vi.fn().mockReturnValue({ runUrl: '', traceUrl: '' }), - - // inspect vars - fetchInspectVars: vi.fn().mockResolvedValue(undefined), - hasNodeInspectVars: vi.fn().mockReturnValue(false), - hasSetInspectVar: vi.fn().mockReturnValue(false), - fetchInspectVarValue: vi.fn().mockResolvedValue(undefined), - editInspectVarValue: vi.fn().mockResolvedValue(undefined), - renameInspectVarName: vi.fn().mockResolvedValue(undefined), - appendNodeInspectVars: noop, - deleteInspectVar: vi.fn().mockResolvedValue(undefined), - deleteNodeInspectorVars: vi.fn().mockResolvedValue(undefined), - deleteAllInspectorVars: vi.fn().mockResolvedValue(undefined), - isInspectVarEdited: vi.fn().mockReturnValue(false), - resetToLastRunVar: vi.fn().mockResolvedValue(undefined), - invalidateSysVarValues: noop, - resetConversationVar: vi.fn().mockResolvedValue(undefined), - invalidateConversationVarValues: noop, - - ...overrides, - } -} diff --git a/web/app/components/workflow/__tests__/mock-reactflow.ts b/web/app/components/workflow/__tests__/mock-reactflow.ts deleted file mode 100644 index 168713de4c..0000000000 --- a/web/app/components/workflow/__tests__/mock-reactflow.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * ReactFlow mock factory for workflow tests. - * - * Usage — add this to the top of any test file that imports reactflow: - * - * vi.mock('reactflow', async () => (await import('../__tests__/mock-reactflow')).createReactFlowMock()) - * - * Or for more control: - * - * vi.mock('reactflow', async () => { - * const base = (await import('../__tests__/mock-reactflow')).createReactFlowMock() - * return { ...base, useReactFlow: () => ({ ...base.useReactFlow(), fitView: vi.fn() }) } - * }) - */ -import * as React from 'react' - -export function createReactFlowMock(overrides: Record = {}) { - const noopComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) => - React.createElement('div', { 'data-testid': 'reactflow-mock' }, children) - noopComponent.displayName = 'ReactFlowMock' - - const backgroundComponent: React.FC = () => null - backgroundComponent.displayName = 'BackgroundMock' - - return { - // re-export the real Position enum - Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }, - MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' }, - ConnectionMode: { Strict: 'strict', Loose: 'loose' }, - ConnectionLineType: { Bezier: 'default', Straight: 'straight', Step: 'step', SmoothStep: 'smoothstep' }, - - // components - default: noopComponent, - ReactFlow: noopComponent, - ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => - React.createElement(React.Fragment, null, children), - Background: backgroundComponent, - MiniMap: backgroundComponent, - Controls: backgroundComponent, - Handle: (props: Record) => React.createElement('div', { 'data-testid': 'handle', ...props }), - BaseEdge: (props: Record) => React.createElement('path', props), - EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) => - React.createElement('div', null, children), - - // hooks - useReactFlow: () => ({ - setCenter: vi.fn(), - fitView: vi.fn(), - zoomIn: vi.fn(), - zoomOut: vi.fn(), - zoomTo: vi.fn(), - getNodes: vi.fn().mockReturnValue([]), - getEdges: vi.fn().mockReturnValue([]), - getNode: vi.fn(), - setNodes: vi.fn(), - setEdges: vi.fn(), - addNodes: vi.fn(), - addEdges: vi.fn(), - deleteElements: vi.fn(), - getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }), - setViewport: vi.fn(), - screenToFlowPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos), - flowToScreenPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos), - toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }), - viewportInitialized: true, - }), - - useStoreApi: () => ({ - getState: vi.fn().mockReturnValue({ - nodeInternals: new Map(), - edges: [], - transform: [0, 0, 1], - d3Selection: null, - d3Zoom: null, - }), - setState: vi.fn(), - subscribe: vi.fn().mockReturnValue(vi.fn()), - }), - - useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), - - useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), - - useStore: vi.fn().mockReturnValue(null), - useNodes: vi.fn().mockReturnValue([]), - useEdges: vi.fn().mockReturnValue([]), - useViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }), - useOnSelectionChange: vi.fn(), - useKeyPress: vi.fn().mockReturnValue(false), - useUpdateNodeInternals: vi.fn().mockReturnValue(vi.fn()), - useOnViewportChange: vi.fn(), - useNodeId: vi.fn().mockReturnValue(null), - - // utils - getOutgoers: vi.fn().mockReturnValue([]), - getIncomers: vi.fn().mockReturnValue([]), - getConnectedEdges: vi.fn().mockReturnValue([]), - isNode: vi.fn().mockReturnValue(true), - isEdge: vi.fn().mockReturnValue(false), - addEdge: vi.fn().mockImplementation((_edge: unknown, edges: unknown[]) => edges), - applyNodeChanges: vi.fn().mockImplementation((_changes: unknown[], nodes: unknown[]) => nodes), - applyEdgeChanges: vi.fn().mockImplementation((_changes: unknown[], edges: unknown[]) => edges), - getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), - getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), - getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), - internalsSymbol: Symbol('internals'), - - ...overrides, - } -} diff --git a/web/app/components/workflow/__tests__/mock-workflow-store.ts b/web/app/components/workflow/__tests__/mock-workflow-store.ts deleted file mode 100644 index 112384c4f6..0000000000 --- a/web/app/components/workflow/__tests__/mock-workflow-store.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { ControlMode, Node } from '../types' -import { noop } from 'es-toolkit' -import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../constants' - -/** - * Default workflow store state covering all slices. - * Use `createWorkflowStoreState(overrides)` to get a state object - * that can be injected via `useWorkflowStore.setState(...)` or - * used as the return value of a mocked `useStore` selector. - */ -export function createWorkflowStoreState(overrides: Record = {}) { - return { - // --- workflow-slice --- - workflowRunningData: undefined, - isListening: false, - listeningTriggerType: null, - listeningTriggerNodeId: null, - listeningTriggerNodeIds: [], - listeningTriggerIsAll: false, - clipboardElements: [] as Node[], - selection: null, - bundleNodeSize: null, - controlMode: 'pointer' as ControlMode, - mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 }, - showConfirm: undefined, - controlPromptEditorRerenderKey: 0, - showImportDSLModal: false, - fileUploadConfig: undefined, - - // --- node-slice --- - showSingleRunPanel: false, - nodeAnimation: false, - candidateNode: undefined, - nodeMenu: undefined, - showAssignVariablePopup: undefined, - hoveringAssignVariableGroupId: undefined, - connectingNodePayload: undefined, - enteringNodePayload: undefined, - iterTimes: DEFAULT_ITER_TIMES, - loopTimes: DEFAULT_LOOP_TIMES, - iterParallelLogMap: new Map(), - pendingSingleRun: undefined, - - // --- panel-slice --- - panelWidth: 420, - showFeaturesPanel: false, - showWorkflowVersionHistoryPanel: false, - showInputsPanel: false, - showDebugAndPreviewPanel: false, - panelMenu: undefined, - selectionMenu: undefined, - showVariableInspectPanel: false, - initShowLastRunTab: false, - - // --- help-line-slice --- - helpLineHorizontal: undefined, - helpLineVertical: undefined, - - // --- history-slice --- - historyWorkflowData: undefined, - showRunHistory: false, - versionHistory: [], - - // --- chat-variable-slice --- - showChatVariablePanel: false, - showGlobalVariablePanel: false, - conversationVariables: [], - - // --- env-variable-slice --- - showEnvPanel: false, - environmentVariables: [], - envSecrets: {}, - - // --- form-slice --- - inputs: {}, - files: [], - - // --- tool-slice --- - toolPublished: false, - lastPublishedHasUserInput: false, - buildInTools: undefined, - customTools: undefined, - workflowTools: undefined, - mcpTools: undefined, - - // --- version-slice --- - draftUpdatedAt: 0, - publishedAt: 0, - currentVersion: null, - isRestoring: false, - - // --- workflow-draft-slice --- - backupDraft: undefined, - syncWorkflowDraftHash: '', - isSyncingWorkflowDraft: false, - isWorkflowDataLoaded: false, - nodes: [] as Node[], - - // --- inspect-vars-slice --- - currentFocusNodeId: null, - nodesWithInspectVars: [], - conversationVars: [], - - // --- layout-slice --- - workflowCanvasWidth: undefined, - workflowCanvasHeight: undefined, - rightPanelWidth: undefined, - nodePanelWidth: 420, - previewPanelWidth: 420, - otherPanelWidth: 420, - bottomPanelWidth: 0, - bottomPanelHeight: 0, - variableInspectPanelHeight: 300, - maximizeCanvas: false, - - // --- setters (all default to noop, override as needed) --- - setWorkflowRunningData: noop, - setIsListening: noop, - setListeningTriggerType: noop, - setListeningTriggerNodeId: noop, - setListeningTriggerNodeIds: noop, - setListeningTriggerIsAll: noop, - setClipboardElements: noop, - setSelection: noop, - setBundleNodeSize: noop, - setControlMode: noop, - setMousePosition: noop, - setShowConfirm: noop, - setControlPromptEditorRerenderKey: noop, - setShowImportDSLModal: noop, - setFileUploadConfig: noop, - setShowSingleRunPanel: noop, - setNodeAnimation: noop, - setCandidateNode: noop, - setNodeMenu: noop, - setShowAssignVariablePopup: noop, - setHoveringAssignVariableGroupId: noop, - setConnectingNodePayload: noop, - setEnteringNodePayload: noop, - setIterTimes: noop, - setLoopTimes: noop, - setIterParallelLogMap: noop, - setPendingSingleRun: noop, - setShowFeaturesPanel: noop, - setShowWorkflowVersionHistoryPanel: noop, - setShowInputsPanel: noop, - setShowDebugAndPreviewPanel: noop, - setPanelMenu: noop, - setSelectionMenu: noop, - setShowVariableInspectPanel: noop, - setInitShowLastRunTab: noop, - setHelpLineHorizontal: noop, - setHelpLineVertical: noop, - setHistoryWorkflowData: noop, - setShowRunHistory: noop, - setVersionHistory: noop, - setShowChatVariablePanel: noop, - setShowGlobalVariablePanel: noop, - setConversationVariables: noop, - setShowEnvPanel: noop, - setEnvironmentVariables: noop, - setEnvSecrets: noop, - setInputs: noop, - setFiles: noop, - setToolPublished: noop, - setLastPublishedHasUserInput: noop, - setDraftUpdatedAt: noop, - setPublishedAt: noop, - setCurrentVersion: noop, - setIsRestoring: noop, - setBackupDraft: noop, - setSyncWorkflowDraftHash: noop, - setIsSyncingWorkflowDraft: noop, - setIsWorkflowDataLoaded: noop, - setNodes: noop, - flushPendingSync: noop, - setCurrentFocusNodeId: noop, - setNodesWithInspectVars: noop, - setNodeInspectVars: noop, - deleteAllInspectVars: noop, - deleteNodeInspectVars: noop, - setInspectVarValue: noop, - resetToLastRunVar: noop, - renameInspectVarName: noop, - deleteInspectVar: noop, - setWorkflowCanvasWidth: noop, - setWorkflowCanvasHeight: noop, - setRightPanelWidth: noop, - setNodePanelWidth: noop, - setPreviewPanelWidth: noop, - setOtherPanelWidth: noop, - setBottomPanelWidth: noop, - setBottomPanelHeight: noop, - setVariableInspectPanelHeight: noop, - setMaximizeCanvas: noop, - - ...overrides, - } -} diff --git a/web/app/components/workflow/__tests__/reactflow-mock-state.ts b/web/app/components/workflow/__tests__/reactflow-mock-state.ts new file mode 100644 index 0000000000..dd7a73d2a9 --- /dev/null +++ b/web/app/components/workflow/__tests__/reactflow-mock-state.ts @@ -0,0 +1,143 @@ +/** + * Shared mutable ReactFlow mock state for hook/component tests. + * + * Mutate `rfState` in `beforeEach` to configure nodes/edges, + * then assert on `rfState.setNodes`, `rfState.setEdges`, etc. + * + * Usage (one line at top of test file): + * ```ts + * vi.mock('reactflow', async () => + * (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), + * ) + * ``` + */ +import * as React from 'react' + +type MockNode = { + id: string + position: { x: number, y: number } + width?: number + height?: number + parentId?: string + data: Record +} + +type MockEdge = { + id: string + source: string + target: string + sourceHandle?: string + data: Record +} + +type ReactFlowMockState = { + nodes: MockNode[] + edges: MockEdge[] + transform: [number, number, number] + setViewport: ReturnType + setNodes: ReturnType + setEdges: ReturnType +} + +export const rfState: ReactFlowMockState = { + nodes: [], + edges: [], + transform: [0, 0, 1], + setViewport: vi.fn(), + setNodes: vi.fn(), + setEdges: vi.fn(), +} + +export function resetReactFlowMockState() { + rfState.nodes = [] + rfState.edges = [] + rfState.transform = [0, 0, 1] + rfState.setViewport.mockReset() + rfState.setNodes.mockReset() + rfState.setEdges.mockReset() +} + +export function createReactFlowModuleMock() { + return { + Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }, + MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' }, + ConnectionMode: { Strict: 'strict', Loose: 'loose' }, + + useStoreApi: vi.fn(() => ({ + getState: () => ({ + getNodes: () => rfState.nodes, + setNodes: rfState.setNodes, + edges: rfState.edges, + setEdges: rfState.setEdges, + transform: rfState.transform, + nodeInternals: new Map(), + d3Selection: null, + d3Zoom: null, + }), + setState: vi.fn(), + subscribe: vi.fn().mockReturnValue(vi.fn()), + })), + + useReactFlow: vi.fn(() => ({ + setViewport: rfState.setViewport, + setCenter: vi.fn(), + fitView: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + zoomTo: vi.fn(), + getNodes: () => rfState.nodes, + getEdges: () => rfState.edges, + setNodes: rfState.setNodes, + setEdges: rfState.setEdges, + getViewport: () => ({ x: 0, y: 0, zoom: 1 }), + screenToFlowPosition: (pos: { x: number, y: number }) => pos, + flowToScreenPosition: (pos: { x: number, y: number }) => pos, + deleteElements: vi.fn(), + addNodes: vi.fn(), + addEdges: vi.fn(), + getNode: vi.fn(), + toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }), + viewportInitialized: true, + })), + + useStore: vi.fn().mockReturnValue(null), + useNodes: vi.fn(() => rfState.nodes), + useEdges: vi.fn(() => rfState.edges), + useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + useKeyPress: vi.fn(() => false), + useOnSelectionChange: vi.fn(), + useOnViewportChange: vi.fn(), + useUpdateNodeInternals: vi.fn(() => vi.fn()), + useNodeId: vi.fn(() => null), + + useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), + useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]), + + ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + ReactFlow: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'reactflow-mock' }, children), + Background: () => null, + MiniMap: () => null, + Controls: () => null, + Handle: (props: Record) => React.createElement('div', props), + BaseEdge: (props: Record) => React.createElement('path', props), + EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) => + React.createElement('div', null, children), + + getOutgoers: vi.fn().mockReturnValue([]), + getIncomers: vi.fn().mockReturnValue([]), + getConnectedEdges: vi.fn().mockReturnValue([]), + isNode: vi.fn().mockReturnValue(true), + isEdge: vi.fn().mockReturnValue(false), + addEdge: vi.fn().mockImplementation((_e: unknown, edges: unknown[]) => edges), + applyNodeChanges: vi.fn().mockImplementation((_c: unknown[], nodes: unknown[]) => nodes), + applyEdgeChanges: vi.fn().mockImplementation((_c: unknown[], edges: unknown[]) => edges), + getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), + getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), + getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]), + internalsSymbol: Symbol('internals'), + } +} + +export type { MockEdge, MockNode, ReactFlowMockState } diff --git a/web/app/components/workflow/__tests__/service-mock-factory.ts b/web/app/components/workflow/__tests__/service-mock-factory.ts new file mode 100644 index 0000000000..7998c15481 --- /dev/null +++ b/web/app/components/workflow/__tests__/service-mock-factory.ts @@ -0,0 +1,75 @@ +/** + * Centralized mock factories for external services used by workflow. + * + * Usage: + * ```ts + * vi.mock('@/service/use-tools', async () => + * (await import('../../__tests__/service-mock-factory')).createToolServiceMock(), + * ) + * vi.mock('@/app/components/app/store', async () => + * (await import('../../__tests__/service-mock-factory')).createAppStoreMock(), + * ) + * ``` + */ + +// --------------------------------------------------------------------------- +// App store +// --------------------------------------------------------------------------- + +type AppStoreMockData = { + appId?: string + appMode?: string +} + +export function createAppStoreMock(data?: AppStoreMockData) { + return { + useStore: { + getState: () => ({ + appDetail: { + id: data?.appId ?? 'app-test-id', + mode: data?.appMode ?? 'workflow', + }, + }), + }, + } +} + +// --------------------------------------------------------------------------- +// SWR service hooks +// --------------------------------------------------------------------------- + +type ToolMockData = { + buildInTools?: unknown[] + customTools?: unknown[] + workflowTools?: unknown[] + mcpTools?: unknown[] +} + +type TriggerMockData = { + triggerPlugins?: unknown[] +} + +type StrategyMockData = { + strategyProviders?: unknown[] +} + +export function createToolServiceMock(data?: ToolMockData) { + return { + useAllBuiltInTools: vi.fn(() => ({ data: data?.buildInTools ?? [] })), + useAllCustomTools: vi.fn(() => ({ data: data?.customTools ?? [] })), + useAllWorkflowTools: vi.fn(() => ({ data: data?.workflowTools ?? [] })), + useAllMCPTools: vi.fn(() => ({ data: data?.mcpTools ?? [] })), + } +} + +export function createTriggerServiceMock(data?: TriggerMockData) { + return { + useAllTriggerPlugins: vi.fn(() => ({ data: data?.triggerPlugins ?? [] })), + } +} + +export function createStrategyServiceMock(data?: StrategyMockData) { + return { + useStrategyProviders: vi.fn(() => ({ data: data?.strategyProviders ?? [] })), + } +} diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx similarity index 98% rename from web/app/components/workflow/__tests__/trigger-status-sync.test.tsx rename to web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx index d3c3d235fe..76be431aa7 100644 --- a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx +++ b/web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx @@ -276,7 +276,7 @@ describe('Trigger Status Synchronization Integration', () => { nodeId: string nodeType: string }> = ({ nodeId, nodeType }) => { - const triggerStatusSelector = useCallback((state: any) => + const triggerStatusSelector = useCallback((state: { triggerStatuses: Record }) => mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', [nodeId, nodeType]) const triggerStatus = useTriggerStatusStore(triggerStatusSelector) @@ -319,9 +319,9 @@ describe('Trigger Status Synchronization Integration', () => { const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => { const triggerStatusSelector = useCallback( - (state: any) => + (state: { triggerStatuses: Record }) => mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', - ['test-node', nodeType], // Dependencies should match implementation + [nodeType], ) const status = useTriggerStatusStore(triggerStatusSelector) return
diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx new file mode 100644 index 0000000000..6109d8a7f4 --- /dev/null +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -0,0 +1,195 @@ +/** + * Workflow test environment — composable providers + render helpers. + * + * ## Quick start + * + * ```ts + * import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' + * import { renderWorkflowHook } from '../../__tests__/workflow-test-env' + * + * // Mock ReactFlow (one line, only needed when the hook imports reactflow) + * vi.mock('reactflow', async () => + * (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), + * ) + * + * it('example', () => { + * resetReactFlowMockState() + * rfState.nodes = [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }] + * + * const { result, store } = renderWorkflowHook( + * () => useMyHook(), + * { + * initialStoreState: { workflowRunningData: {...} }, + * hooksStoreProps: { doSyncWorkflowDraft: vi.fn() }, + * }, + * ) + * + * result.current.doSomething() + * expect(store.getState().someValue).toBe(expected) + * expect(rfState.setNodes).toHaveBeenCalled() + * }) + * ``` + */ +import type { RenderHookOptions, RenderHookResult } from '@testing-library/react' +import type { Shape as HooksStoreShape } from '../hooks-store/store' +import type { Shape } from '../store/workflow' +import type { Edge, Node, WorkflowRunningData } from '../types' +import type { WorkflowHistoryStoreApi } from '../workflow-history-store' +import { renderHook } from '@testing-library/react' +import isDeepEqual from 'fast-deep-equal' +import * as React from 'react' +import { temporal } from 'zundo' +import { create } from 'zustand' +import { WorkflowContext } from '../context' +import { HooksStoreContext } from '../hooks-store/provider' +import { createHooksStore } from '../hooks-store/store' +import { createWorkflowStore } from '../store/workflow' +import { WorkflowRunningStatus } from '../types' +import { WorkflowHistoryStoreContext } from '../workflow-history-store' + +// Re-exports are in a separate non-JSX file to avoid react-refresh warnings. +// Import directly from the individual modules: +// reactflow-mock-state.ts → rfState, resetReactFlowMockState, createReactFlowModuleMock +// service-mock-factory.ts → createToolServiceMock, createTriggerServiceMock, ... +// fixtures.ts → createNode, createEdge, createLinearGraph, ... + +// --------------------------------------------------------------------------- +// Test data factories +// --------------------------------------------------------------------------- + +export function baseRunningData(overrides: Record = {}) { + return { + task_id: 'task-1', + result: { status: WorkflowRunningStatus.Running } as WorkflowRunningData['result'], + tracing: [], + resultText: '', + resultTabActive: false, + ...overrides, + } as WorkflowRunningData +} + +// --------------------------------------------------------------------------- +// Store creation helpers +// --------------------------------------------------------------------------- + +type WorkflowStore = ReturnType +type HooksStore = ReturnType + +export function createTestWorkflowStore(initialState?: Partial): WorkflowStore { + const store = createWorkflowStore({}) + if (initialState) + store.setState(initialState) + return store +} + +export function createTestHooksStore(props?: Partial): HooksStore { + return createHooksStore(props ?? {}) +} + +// --------------------------------------------------------------------------- +// renderWorkflowHook — composable hook renderer +// --------------------------------------------------------------------------- + +type HistoryStoreConfig = { + nodes?: Node[] + edges?: Edge[] +} + +type WorkflowTestOptions

= Omit, 'wrapper'> & { + initialStoreState?: Partial + hooksStoreProps?: Partial + historyStore?: HistoryStoreConfig +} + +type WorkflowTestResult = RenderHookResult & { + store: WorkflowStore + hooksStore?: HooksStore +} + +/** + * Renders a hook inside composable workflow providers. + * + * Contexts provided based on options: + * - **Always**: `WorkflowContext` (real zustand store) + * - **hooksStoreProps**: `HooksStoreContext` (real zustand store) + * - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store) + */ +export function renderWorkflowHook( + hook: (props: P) => R, + options?: WorkflowTestOptions

, +): WorkflowTestResult { + const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...rest } = options ?? {} + + const store = createTestWorkflowStore(initialStoreState) + const hooksStore = hooksStoreProps !== undefined + ? createTestHooksStore(hooksStoreProps) + : undefined + + const wrapper = ({ children }: { children: React.ReactNode }) => { + let inner: React.ReactNode = children + + if (historyConfig) { + const historyCtxValue = createTestHistoryStoreContext(historyConfig) + inner = React.createElement( + WorkflowHistoryStoreContext.Provider, + { value: historyCtxValue }, + inner, + ) + } + + if (hooksStore) { + inner = React.createElement( + HooksStoreContext.Provider, + { value: hooksStore }, + inner, + ) + } + + return React.createElement( + WorkflowContext.Provider, + { value: store }, + inner, + ) + } + + const renderResult = renderHook(hook, { wrapper, ...rest }) + return { ...renderResult, store, hooksStore } +} + +// --------------------------------------------------------------------------- +// WorkflowHistoryStore test helper +// --------------------------------------------------------------------------- + +function createTestHistoryStoreContext(config: HistoryStoreConfig) { + const nodes = config.nodes ?? [] + const edges = config.edges ?? [] + + type HistState = { + workflowHistoryEvent: string | undefined + workflowHistoryEventMeta: unknown + nodes: Node[] + edges: Edge[] + getNodes: () => Node[] + setNodes: (n: Node[]) => void + setEdges: (e: Edge[]) => void + } + + const store = create(temporal( + (set, get) => ({ + workflowHistoryEvent: undefined, + workflowHistoryEventMeta: undefined, + nodes, + edges, + getNodes: () => get().nodes, + setNodes: (n: Node[]) => set({ nodes: n }), + setEdges: (e: Edge[]) => set({ edges: e }), + }), + { equality: (a, b) => isDeepEqual(a, b) }, + )) as unknown as WorkflowHistoryStoreApi + + return { + store, + shortcutsEnabled: true, + setShortcutsEnabled: () => {}, + } +} diff --git a/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts new file mode 100644 index 0000000000..cad77c3af8 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-auto-generate-webhook-url.spec.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { BlockEnum } from '../../types' +import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('@/app/components/app/store', async () => + (await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' })) + +const mockFetchWebhookUrl = vi.fn() +vi.mock('@/service/apps', () => ({ + fetchWebhookUrl: (...args: unknown[]) => mockFetchWebhookUrl(...args), +})) + +describe('useAutoGenerateWebhookUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + rfState.nodes = [ + { id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } }, + { id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } }, + ] + }) + + it('should fetch and set webhook URL for a webhook trigger node', async () => { + mockFetchWebhookUrl.mockResolvedValue({ + webhook_url: 'https://example.com/webhook', + webhook_debug_url: 'https://example.com/webhook-debug', + }) + + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('webhook-1') + + expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' }) + expect(rfState.setNodes).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1') + expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook') + expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug') + }) + + it('should not fetch when node is not a webhook trigger', async () => { + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('code-1') + + expect(mockFetchWebhookUrl).not.toHaveBeenCalled() + expect(rfState.setNodes).not.toHaveBeenCalled() + }) + + it('should not fetch when node does not exist', async () => { + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('nonexistent') + + expect(mockFetchWebhookUrl).not.toHaveBeenCalled() + }) + + it('should not fetch when webhook_url already exists', async () => { + rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook' + + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('webhook-1') + + expect(mockFetchWebhookUrl).not.toHaveBeenCalled() + }) + + it('should handle API errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockFetchWebhookUrl.mockRejectedValue(new Error('network error')) + + const { result } = renderHook(() => useAutoGenerateWebhookUrl()) + await result.current('webhook-1') + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to auto-generate webhook URL:', + expect.any(Error), + ) + expect(rfState.setNodes).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts new file mode 100644 index 0000000000..c89ba9ce96 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts @@ -0,0 +1,162 @@ +import type { NodeDefault } from '../../types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockClassificationEnum } from '../../block-selector/types' +import { BlockEnum } from '../../types' +import { useAvailableBlocks } from '../use-available-blocks' + +// Transitive imports of use-nodes-meta-data.ts — only useNodeMetaData uses these +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) +vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en' })) + +const mockNodeTypes = [ + BlockEnum.Start, + BlockEnum.End, + BlockEnum.LLM, + BlockEnum.Code, + BlockEnum.IfElse, + BlockEnum.Iteration, + BlockEnum.Loop, + BlockEnum.Tool, + BlockEnum.DataSource, + BlockEnum.KnowledgeBase, + BlockEnum.HumanInput, + BlockEnum.LoopEnd, +] + +function createNodeDefault(type: BlockEnum): NodeDefault { + return { + metaData: { + classification: BlockClassificationEnum.Default, + sort: 0, + type, + title: type, + author: 'test', + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), + } +} + +const hooksStoreProps = { + availableNodesMetaData: { + nodes: mockNodeTypes.map(createNodeDefault), + }, +} + +describe('useAvailableBlocks', () => { + describe('availablePrevBlocks', () => { + it('should return empty array when nodeType is undefined', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + }) + + it('should return empty array for Start node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.Start), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + }) + + it('should return empty array for trigger nodes', () => { + for (const trigger of [BlockEnum.TriggerPlugin, BlockEnum.TriggerWebhook, BlockEnum.TriggerSchedule]) { + const { result } = renderWorkflowHook(() => useAvailableBlocks(trigger), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + } + }) + + it('should return empty array for DataSource node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.DataSource), { hooksStoreProps }) + expect(result.current.availablePrevBlocks).toEqual([]) + }) + + it('should return all available nodes for regular block types', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + expect(result.current.availablePrevBlocks.length).toBeGreaterThan(0) + expect(result.current.availablePrevBlocks).toContain(BlockEnum.Code) + }) + }) + + describe('availableNextBlocks', () => { + it('should return empty array when nodeType is undefined', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(undefined), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return empty array for End node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.End), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return empty array for LoopEnd node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LoopEnd), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return empty array for KnowledgeBase node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.KnowledgeBase), { hooksStoreProps }) + expect(result.current.availableNextBlocks).toEqual([]) + }) + + it('should return all available nodes for regular block types', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + expect(result.current.availableNextBlocks.length).toBeGreaterThan(0) + }) + }) + + describe('inContainer filtering', () => { + it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { hooksStoreProps }) + + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Iteration) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.Loop) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput) + }) + + it('should exclude LoopEnd when not in container', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, false), { hooksStoreProps }) + expect(result.current.availableNextBlocks).not.toContain(BlockEnum.LoopEnd) + }) + }) + + describe('getAvailableBlocks callback', () => { + it('should return prev and next blocks for a given node type', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.Code) + + expect(blocks.availablePrevBlocks.length).toBeGreaterThan(0) + expect(blocks.availableNextBlocks.length).toBeGreaterThan(0) + }) + + it('should return empty prevBlocks for Start node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.Start) + + expect(blocks.availablePrevBlocks).toEqual([]) + }) + + it('should return empty prevBlocks for DataSource node', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.DataSource) + + expect(blocks.availablePrevBlocks).toEqual([]) + }) + + it('should return empty nextBlocks for End/LoopEnd/KnowledgeBase', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + + expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toEqual([]) + expect(result.current.getAvailableBlocks(BlockEnum.LoopEnd).availableNextBlocks).toEqual([]) + expect(result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks).toEqual([]) + }) + + it('should filter by inContainer when provided', () => { + const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps }) + const blocks = result.current.getAvailableBlocks(BlockEnum.Code, true) + + expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Iteration) + expect(blocks.availableNextBlocks).not.toContain(BlockEnum.Loop) + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts new file mode 100644 index 0000000000..d72d001e0b --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -0,0 +1,312 @@ +import type { CommonNodeType, Node } from '../../types' +import type { ChecklistItem } from '../use-checklist' +import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useChecklist, useWorkflowRunValidation } from '../use-checklist' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('reactflow', async () => { + const base = (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock() + return { + ...base, + getOutgoers: vi.fn((node: Node, nodes: Node[], edges: { source: string, target: string }[]) => { + return edges + .filter(e => e.source === node.id) + .map(e => nodes.find(n => n.id === e.target)) + .filter(Boolean) + }), + } +}) + +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + +vi.mock('@/service/use-triggers', async () => + (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock()) + +vi.mock('@/service/use-strategy', () => ({ + useStrategyProviders: () => ({ data: [] }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string } +const mockNodesMap: Record = {} + +vi.mock('../use-nodes-meta-data', () => ({ + useNodesMetaData: () => ({ + nodes: [], + nodesMap: mockNodesMap, + }), +})) + +vi.mock('../use-nodes-available-var-list', () => ({ + default: (nodes: Node[]) => { + const map: Record = {} + if (nodes) { + for (const n of nodes) + map[n.id] = { availableVars: [] } + } + return map + }, + useGetNodesAvailableVarList: () => ({ getNodesAvailableVarList: vi.fn(() => ({})) }), +})) + +vi.mock('../../nodes/_base/components/variable/utils', () => ({ + getNodeUsedVars: () => [], + isSpecialVar: () => false, +})) + +vi.mock('@/app/components/app/store', () => { + const state = { appDetail: { mode: 'workflow' } } + return { + useStore: { + getState: () => state, + }, + } +}) + +vi.mock('../../datasets-detail-store/store', () => ({ + useDatasetsDetailStore: () => ({}), +})) + +vi.mock('../index', () => ({ + useGetToolIcon: () => () => undefined, + useNodesMetaData: () => ({ nodes: [], nodesMap: mockNodesMap }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: vi.fn() }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en', +})) + +// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook) + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +function setupNodesMap() { + mockNodesMap[BlockEnum.Start] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: true, isRequired: false }, + } + mockNodesMap[BlockEnum.Code] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } + mockNodesMap[BlockEnum.LLM] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } + mockNodesMap[BlockEnum.End] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } + mockNodesMap[BlockEnum.Tool] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + resetFixtureCounters() + Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k]) + setupNodesMap() +}) + +// --------------------------------------------------------------------------- +// Helper: build a simple connected graph +// --------------------------------------------------------------------------- + +function buildConnectedGraph() { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + const endNode = createNode({ id: 'end', data: { type: BlockEnum.End, title: 'End' } }) + const nodes = [startNode, codeNode, endNode] + const edges = [ + createEdge({ source: 'start', target: 'code' }), + createEdge({ source: 'code', target: 'end' }), + ] + return { nodes, edges } +} + +// --------------------------------------------------------------------------- +// useChecklist +// --------------------------------------------------------------------------- + +describe('useChecklist', () => { + it('should return empty list when all nodes are valid and connected', () => { + const { nodes, edges } = buildConnectedGraph() + + const { result } = renderWorkflowHook( + () => useChecklist(nodes, edges), + ) + + expect(result.current).toEqual([]) + }) + + it('should detect disconnected nodes', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + const isolatedLlm = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) + + const edges = [ + createEdge({ source: 'start', target: 'code' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, codeNode, isolatedLlm], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') + expect(warning).toBeDefined() + expect(warning!.unConnected).toBe(true) + }) + + it('should detect validation errors from checkValid', () => { + mockNodesMap[BlockEnum.LLM] = { + checkValid: () => ({ errorMessage: 'Model not configured' }), + metaData: { isStart: false, isRequired: false }, + } + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const llmNode = createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: 'LLM' } }) + + const edges = [ + createEdge({ source: 'start', target: 'llm' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, llmNode], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'llm') + expect(warning).toBeDefined() + expect(warning!.errorMessage).toBe('Model not configured') + }) + + it('should report missing start node in workflow mode', () => { + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([codeNode], []), + ) + + const startRequired = result.current.find((item: ChecklistItem) => item.id === 'start-node-required') + expect(startRequired).toBeDefined() + expect(startRequired!.canNavigate).toBe(false) + }) + + it('should detect plugin not installed', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const toolNode = createNode({ + id: 'tool', + data: { + type: BlockEnum.Tool, + title: 'My Tool', + _pluginInstallLocked: true, + }, + }) + + const edges = [ + createEdge({ source: 'start', target: 'tool' }), + ] + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, toolNode], edges), + ) + + const warning = result.current.find((item: ChecklistItem) => item.id === 'tool') + expect(warning).toBeDefined() + expect(warning!.canNavigate).toBe(false) + expect(warning!.disableGoTo).toBe(true) + }) + + it('should report required node types that are missing', () => { + mockNodesMap[BlockEnum.End] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: true }, + } + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([startNode], []), + ) + + const requiredItem = result.current.find((item: ChecklistItem) => item.id === `${BlockEnum.End}-need-added`) + expect(requiredItem).toBeDefined() + expect(requiredItem!.canNavigate).toBe(false) + }) + + it('should not flag start nodes as unconnected', () => { + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, codeNode], []), + ) + + const startWarning = result.current.find((item: ChecklistItem) => item.id === 'start') + expect(startWarning).toBeUndefined() + }) + + it('should skip nodes without CUSTOM_NODE type', () => { + const nonCustomNode = createNode({ + id: 'alien', + type: 'not-custom', + data: { type: BlockEnum.Code, title: 'Non-Custom' }, + }) + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + + const { result } = renderWorkflowHook( + () => useChecklist([startNode, nonCustomNode], []), + ) + + const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien') + expect(alienWarning).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// useWorkflowRunValidation +// --------------------------------------------------------------------------- + +describe('useWorkflowRunValidation', () => { + it('should return hasValidationErrors false when there are no warnings', () => { + const { nodes, edges } = buildConnectedGraph() + rfState.edges = edges as unknown as typeof rfState.edges + + const { result } = renderWorkflowHook(() => useWorkflowRunValidation(), { + initialStoreState: { nodes: nodes as Node[] }, + }) + + expect(result.current.hasValidationErrors).toBe(false) + expect(result.current.warningNodes).toEqual([]) + }) + + it('should return validateBeforeRun as a function that returns true when valid', () => { + const { nodes, edges } = buildConnectedGraph() + rfState.edges = edges as unknown as typeof rfState.edges + + const { result } = renderWorkflowHook(() => useWorkflowRunValidation(), { + initialStoreState: { nodes: nodes as Node[] }, + }) + + expect(typeof result.current.validateBeforeRun).toBe('function') + expect(result.current.validateBeforeRun()).toBe(true) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts new file mode 100644 index 0000000000..6d19862efd --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts @@ -0,0 +1,151 @@ +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useEdgesInteractions } from '../use-edges-interactions' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +// useWorkflowHistory uses a debounced save — mock for synchronous assertions +const mockSaveStateToHistory = vi.fn() +vi.mock('../use-workflow-history', () => ({ + useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }), + WorkflowHistoryEvent: { + EdgeDelete: 'EdgeDelete', + EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch', + EdgeSourceHandleChange: 'EdgeSourceHandleChange', + }, +})) + +// use-workflow.ts has heavy transitive imports — mock only useNodesReadOnly +let mockReadOnly = false +vi.mock('../use-workflow', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: () => mockReadOnly, + }), +})) + +vi.mock('../../utils', () => ({ + getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})), +})) + +// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps +function renderEdgesInteractions() { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + return { + ...renderWorkflowHook(() => useEdgesInteractions(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }), + mockDoSync, + } +} + +describe('useEdgesInteractions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + mockReadOnly = false + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + { id: 'n2', position: { x: 100, y: 0 }, data: {} }, + ] + rfState.edges = [ + { id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } }, + { id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } }, + ] + }) + + it('handleEdgeEnter should set _hovering to true', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeEnter({} as never, rfState.edges[0] as never) + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true) + expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false) + }) + + it('handleEdgeLeave should set _hovering to false', () => { + rfState.edges[0].data._hovering = true + const { result } = renderEdgesInteractions() + result.current.handleEdgeLeave({} as never, rfState.edges[0] as never) + + expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false) + }) + + it('handleEdgesChange should update edge.selected for select changes', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgesChange([ + { type: 'select', id: 'e1', selected: true }, + { type: 'select', id: 'e2', selected: false }, + ]) + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true) + expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false) + }) + + it('handleEdgeDelete should remove selected edge and trigger sync + history', () => { + ;(rfState.edges[0] as Record).selected = true + const { result } = renderEdgesInteractions() + + result.current.handleEdgeDelete() + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated).toHaveLength(1) + expect(updated[0].id).toBe('e2') + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + }) + + it('handleEdgeDelete should do nothing when no edge is selected', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDelete() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a') + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated).toHaveLength(1) + expect(updated[0].id).toBe('e2') + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch') + }) + + it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => { + rfState.edges = [ + { id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0], + ] + + const { result } = renderEdgesInteractions() + result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle') + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated[0].sourceHandle).toBe('new-handle') + expect(updated[0].id).toBe('n1-new-handle-n2-target') + }) + + describe('read-only mode', () => { + beforeEach(() => { + mockReadOnly = true + }) + + it('handleEdgeEnter should do nothing', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeEnter({} as never, rfState.edges[0] as never) + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeDelete should do nothing', () => { + ;(rfState.edges[0] as Record).selected = true + const { result } = renderEdgesInteractions() + result.current.handleEdgeDelete() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeDeleteByDeleteBranch should do nothing', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a') + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts new file mode 100644 index 0000000000..d75e39a733 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts @@ -0,0 +1,194 @@ +import type { Node } from '../../types' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useHelpline } from '../use-helpline' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +function makeNode(overrides: Record & { id: string }): Node { + return { + position: { x: 0, y: 0 }, + width: 240, + height: 100, + data: { type: BlockEnum.LLM, title: '', desc: '' }, + ...overrides, + } as unknown as Node +} + +describe('useHelpline', () => { + beforeEach(() => { + resetReactFlowMockState() + }) + + it('should return empty arrays for nodes in iteration', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', data: { type: BlockEnum.LLM, isInIteration: true } }) + const output = result.current.handleSetHelpline(draggingNode) + + expect(output.showHorizontalHelpLineNodes).toEqual([]) + expect(output.showVerticalHelpLineNodes).toEqual([]) + }) + + it('should return empty arrays for nodes in loop', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', data: { type: BlockEnum.LLM, isInLoop: true } }) + const output = result.current.handleSetHelpline(draggingNode) + + expect(output.showHorizontalHelpLineNodes).toEqual([]) + expect(output.showVerticalHelpLineNodes).toEqual([]) + }) + + it('should detect horizontally aligned nodes (same y ±5px)', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 103 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n3', position: { x: 600, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).toContain('n2') + expect(horizontalIds).not.toContain('n3') + }) + + it('should detect vertically aligned nodes (same x ±5px)', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 100, y: 0 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 102, y: 200 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n3', position: { x: 500, y: 400 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 0 } }) + const output = result.current.handleSetHelpline(draggingNode) + + const verticalIds = output.showVerticalHelpLineNodes.map((n: { id: string }) => n.id) + expect(verticalIds).toContain('n2') + expect(verticalIds).not.toContain('n3') + }) + + it('should apply entry node offset for Start nodes', () => { + const ENTRY_OFFSET_Y = 21 + + rfState.nodes = [ + { id: 'start', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.Start } }, + { id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'far', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ + id: 'start', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.Start }, + }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).toContain('n2') + expect(horizontalIds).not.toContain('far') + }) + + it('should apply entry node offset for Trigger nodes', () => { + const ENTRY_OFFSET_Y = 21 + + rfState.nodes = [ + { id: 'trigger', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.TriggerWebhook } }, + { id: 'n2', position: { x: 300, y: 100 + ENTRY_OFFSET_Y }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ + id: 'trigger', + position: { x: 100, y: 100 }, + width: 240, + height: 100, + data: { type: BlockEnum.TriggerWebhook }, + }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).toContain('n2') + }) + + it('should not detect alignment when positions differ by more than 5px', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 106 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n3', position: { x: 106, y: 300 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 100 } }) + const output = result.current.handleSetHelpline(draggingNode) + + expect(output.showHorizontalHelpLineNodes).toHaveLength(0) + expect(output.showVerticalHelpLineNodes).toHaveLength(0) + }) + + it('should exclude child nodes in iteration', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'child', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM, isInIteration: true } }, + ] + + const { result } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 100, y: 100 } }) + const output = result.current.handleSetHelpline(draggingNode) + + const horizontalIds = output.showHorizontalHelpLineNodes.map((n: { id: string }) => n.id) + expect(horizontalIds).not.toContain('child') + }) + + it('should set helpLineHorizontal in store when aligned nodes found', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } }) + result.current.handleSetHelpline(draggingNode) + + expect(store.getState().helpLineHorizontal).toBeDefined() + }) + + it('should clear helpLineHorizontal when no aligned nodes', () => { + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'n2', position: { x: 300, y: 500 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + const draggingNode = makeNode({ id: 'n1', position: { x: 0, y: 100 } }) + result.current.handleSetHelpline(draggingNode) + + expect(store.getState().helpLineHorizontal).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts new file mode 100644 index 0000000000..38bfa4839e --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-hooksstore-wrappers.spec.ts @@ -0,0 +1,79 @@ +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useDSL } from '../use-DSL' +import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft' +import { useWorkflowRun } from '../use-workflow-run' +import { useWorkflowStartRun } from '../use-workflow-start-run' + +describe('useDSL', () => { + it('should return exportCheck and handleExportDSL from hooksStore', () => { + const mockExportCheck = vi.fn() + const mockHandleExportDSL = vi.fn() + + const { result } = renderWorkflowHook(() => useDSL(), { + hooksStoreProps: { exportCheck: mockExportCheck, handleExportDSL: mockHandleExportDSL }, + }) + + expect(result.current.exportCheck).toBe(mockExportCheck) + expect(result.current.handleExportDSL).toBe(mockHandleExportDSL) + }) +}) + +describe('useWorkflowRun', () => { + it('should return all run-related handlers from hooksStore', () => { + const mocks = { + handleBackupDraft: vi.fn(), + handleLoadBackupDraft: vi.fn(), + handleRestoreFromPublishedWorkflow: vi.fn(), + handleRun: vi.fn(), + handleStopRun: vi.fn(), + } + + const { result } = renderWorkflowHook(() => useWorkflowRun(), { + hooksStoreProps: mocks, + }) + + expect(result.current.handleBackupDraft).toBe(mocks.handleBackupDraft) + expect(result.current.handleLoadBackupDraft).toBe(mocks.handleLoadBackupDraft) + expect(result.current.handleRestoreFromPublishedWorkflow).toBe(mocks.handleRestoreFromPublishedWorkflow) + expect(result.current.handleRun).toBe(mocks.handleRun) + expect(result.current.handleStopRun).toBe(mocks.handleStopRun) + }) +}) + +describe('useWorkflowStartRun', () => { + it('should return all start-run handlers from hooksStore', () => { + const mocks = { + handleStartWorkflowRun: vi.fn(), + handleWorkflowStartRunInWorkflow: vi.fn(), + handleWorkflowStartRunInChatflow: vi.fn(), + handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(), + handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(), + handleWorkflowTriggerPluginRunInWorkflow: vi.fn(), + handleWorkflowRunAllTriggersInWorkflow: vi.fn(), + } + + const { result } = renderWorkflowHook(() => useWorkflowStartRun(), { + hooksStoreProps: mocks, + }) + + expect(result.current.handleStartWorkflowRun).toBe(mocks.handleStartWorkflowRun) + expect(result.current.handleWorkflowStartRunInWorkflow).toBe(mocks.handleWorkflowStartRunInWorkflow) + expect(result.current.handleWorkflowStartRunInChatflow).toBe(mocks.handleWorkflowStartRunInChatflow) + expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe(mocks.handleWorkflowTriggerScheduleRunInWorkflow) + expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe(mocks.handleWorkflowTriggerWebhookRunInWorkflow) + expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe(mocks.handleWorkflowTriggerPluginRunInWorkflow) + expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe(mocks.handleWorkflowRunAllTriggersInWorkflow) + }) +}) + +describe('useWorkflowRefreshDraft', () => { + it('should return handleRefreshWorkflowDraft from hooksStore', () => { + const mockRefresh = vi.fn() + + const { result } = renderWorkflowHook(() => useWorkflowRefreshDraft(), { + hooksStoreProps: { handleRefreshWorkflowDraft: mockRefresh }, + }) + + expect(result.current.handleRefreshWorkflowDraft).toBe(mockRefresh) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts b/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts new file mode 100644 index 0000000000..7fcb10ff0e --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-node-data-update.spec.ts @@ -0,0 +1,99 @@ +import type { WorkflowRunningData } from '../../types' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { useNodeDataUpdate } from '../use-node-data-update' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +describe('useNodeDataUpdate', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Node 1', value: 'original' } }, + { id: 'node-2', position: { x: 300, y: 0 }, data: { title: 'Node 2' } }, + ] + }) + + describe('handleNodeDataUpdate', () => { + it('should merge data into the target node and call setNodes', () => { + const { result } = renderWorkflowHook(() => useNodeDataUpdate(), { + hooksStoreProps: {}, + }) + + result.current.handleNodeDataUpdate({ + id: 'node-1', + data: { value: 'updated', extra: true }, + }) + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes.find((n: { id: string }) => n.id === 'node-1').data).toEqual({ + title: 'Node 1', + value: 'updated', + extra: true, + }) + expect(updatedNodes.find((n: { id: string }) => n.id === 'node-2').data).toEqual({ + title: 'Node 2', + }) + }) + }) + + describe('handleNodeDataUpdateWithSyncDraft', () => { + it('should update node data and trigger debounced sync draft', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result, store } = renderWorkflowHook(() => useNodeDataUpdate(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleNodeDataUpdateWithSyncDraft({ + id: 'node-1', + data: { value: 'synced' }, + }) + + expect(rfState.setNodes).toHaveBeenCalledOnce() + + store.getState().flushPendingSync() + expect(mockDoSync).toHaveBeenCalledOnce() + }) + + it('should call doSyncWorkflowDraft directly when sync=true', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + const callback = { onSuccess: vi.fn() } + + const { result } = renderWorkflowHook(() => useNodeDataUpdate(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleNodeDataUpdateWithSyncDraft( + { id: 'node-1', data: { value: 'synced' } }, + { sync: true, notRefreshWhenSyncError: true, callback }, + ) + + expect(mockDoSync).toHaveBeenCalledWith(true, callback) + }) + + it('should do nothing when nodes are read-only', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodeDataUpdate(), { + initialStoreState: { + workflowRunningData: { + result: { status: WorkflowRunningStatus.Running }, + } as WorkflowRunningData, + }, + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleNodeDataUpdateWithSyncDraft({ + id: 'node-1', + data: { value: 'should-not-update' }, + }) + + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(mockDoSync).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts new file mode 100644 index 0000000000..100692b22a --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -0,0 +1,79 @@ +import type { WorkflowRunningData } from '../../types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { useNodesSyncDraft } from '../use-nodes-sync-draft' + +describe('useNodesSyncDraft', () => { + it('should return doSyncWorkflowDraft, handleSyncWorkflowDraft, and syncWorkflowDraftWhenPageClose', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + const mockSyncClose = vi.fn() + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { + doSyncWorkflowDraft: mockDoSync, + syncWorkflowDraftWhenPageClose: mockSyncClose, + }, + }) + + expect(result.current.doSyncWorkflowDraft).toBe(mockDoSync) + expect(result.current.syncWorkflowDraftWhenPageClose).toBe(mockSyncClose) + expect(typeof result.current.handleSyncWorkflowDraft).toBe('function') + }) + + it('should call doSyncWorkflowDraft synchronously when sync=true', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + const callback = { onSuccess: vi.fn() } + result.current.handleSyncWorkflowDraft(true, false, callback) + + expect(mockDoSync).toHaveBeenCalledWith(false, callback) + }) + + it('should use debounced path when sync is falsy, then flush triggers doSync', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result, store } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleSyncWorkflowDraft() + + expect(mockDoSync).not.toHaveBeenCalled() + + store.getState().flushPendingSync() + expect(mockDoSync).toHaveBeenCalledOnce() + }) + + it('should do nothing when nodes are read-only (workflow running)', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + initialStoreState: { + workflowRunningData: { + result: { status: WorkflowRunningStatus.Running }, + } as WorkflowRunningData, + }, + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleSyncWorkflowDraft(true) + + expect(mockDoSync).not.toHaveBeenCalled() + }) + + it('should pass notRefreshWhenSyncError to doSyncWorkflowDraft', () => { + const mockDoSync = vi.fn().mockResolvedValue(undefined) + + const { result } = renderWorkflowHook(() => useNodesSyncDraft(), { + hooksStoreProps: { doSyncWorkflowDraft: mockDoSync }, + }) + + result.current.handleSyncWorkflowDraft(true, true) + + expect(mockDoSync).toHaveBeenCalledWith(true, undefined) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts new file mode 100644 index 0000000000..ec689f23f9 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -0,0 +1,78 @@ +import type * as React from 'react' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { usePanelInteractions } from '../use-panel-interactions' + +describe('usePanelInteractions', () => { + let container: HTMLDivElement + + beforeEach(() => { + container = document.createElement('div') + container.id = 'workflow-container' + container.getBoundingClientRect = vi.fn().mockReturnValue({ + x: 100, + y: 50, + width: 800, + height: 600, + top: 50, + right: 900, + bottom: 650, + left: 100, + }) + document.body.appendChild(container) + }) + + afterEach(() => { + container.remove() + }) + + it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions()) + const preventDefault = vi.fn() + + result.current.handlePaneContextMenu({ + preventDefault, + clientX: 350, + clientY: 250, + } as unknown as React.MouseEvent) + + expect(preventDefault).toHaveBeenCalled() + expect(store.getState().panelMenu).toEqual({ + top: 200, + left: 250, + }) + }) + + it('handlePaneContextMenu should throw when container does not exist', () => { + container.remove() + + const { result } = renderWorkflowHook(() => usePanelInteractions()) + + expect(() => { + result.current.handlePaneContextMenu({ + preventDefault: vi.fn(), + clientX: 350, + clientY: 250, + } as unknown as React.MouseEvent) + }).toThrow() + }) + + it('handlePaneContextmenuCancel should clear panelMenu', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { + initialStoreState: { panelMenu: { top: 10, left: 20 } }, + }) + + result.current.handlePaneContextmenuCancel() + + expect(store.getState().panelMenu).toBeUndefined() + }) + + it('handleNodeContextmenuCancel should clear nodeMenu', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { + initialStoreState: { nodeMenu: { top: 10, left: 20, nodeId: 'n1' } }, + }) + + result.current.handleNodeContextmenuCancel() + + expect(store.getState().nodeMenu).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts new file mode 100644 index 0000000000..7e65176e6f --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -0,0 +1,190 @@ +import type * as React from 'react' +import type { Node, OnSelectionChangeParams } from 'reactflow' +import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useSelectionInteractions } from '../use-selection-interactions' + +const rfStoreExtra = vi.hoisted(() => ({ + userSelectionRect: null as { x: number, y: number, width: number, height: number } | null, + userSelectionActive: false, + resetSelectedElements: vi.fn(), + setState: vi.fn(), +})) + +vi.mock('reactflow', async () => { + const mod = await import('../../__tests__/reactflow-mock-state') + const base = mod.createReactFlowModuleMock() + return { + ...base, + useStoreApi: vi.fn(() => ({ + getState: () => ({ + getNodes: () => mod.rfState.nodes, + setNodes: mod.rfState.setNodes, + edges: mod.rfState.edges, + setEdges: mod.rfState.setEdges, + transform: mod.rfState.transform, + userSelectionRect: rfStoreExtra.userSelectionRect, + userSelectionActive: rfStoreExtra.userSelectionActive, + resetSelectedElements: rfStoreExtra.resetSelectedElements, + }), + setState: rfStoreExtra.setState, + subscribe: vi.fn().mockReturnValue(vi.fn()), + })), + } +}) + +describe('useSelectionInteractions', () => { + let container: HTMLDivElement + + beforeEach(() => { + resetReactFlowMockState() + rfStoreExtra.userSelectionRect = null + rfStoreExtra.userSelectionActive = false + rfStoreExtra.resetSelectedElements = vi.fn() + rfStoreExtra.setState.mockReset() + + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } }, + { id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } }, + { id: 'n3', position: { x: 200, y: 200 }, data: {} }, + ] + rfState.edges = [ + { id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } }, + { id: 'e2', source: 'n2', target: 'n3', data: {} }, + ] + + container = document.createElement('div') + container.id = 'workflow-container' + container.getBoundingClientRect = vi.fn().mockReturnValue({ + x: 100, + y: 50, + width: 800, + height: 600, + top: 50, + right: 900, + bottom: 650, + left: 100, + }) + document.body.appendChild(container) + }) + + afterEach(() => { + container.remove() + }) + + it('handleSelectionStart should clear _isBundled from all nodes and edges', () => { + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionStart() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true) + + const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[] + expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true) + }) + + it('handleSelectionChange should mark selected nodes as bundled', () => { + rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 } + + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionChange({ + nodes: [{ id: 'n1' }, { id: 'n3' }], + edges: [], + } as unknown as OnSelectionChangeParams) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true) + expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false) + expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true) + }) + + it('handleSelectionChange should mark selected edges', () => { + rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 } + + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionChange({ + nodes: [], + edges: [{ id: 'e1' }], + } as unknown as OnSelectionChangeParams) + + const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[] + expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true) + expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false) + }) + + it('handleSelectionDrag should sync node positions', () => { + const { result, store } = renderWorkflowHook(() => useSelectionInteractions()) + + const draggedNodes = [ + { id: 'n1', position: { x: 50, y: 60 }, data: {} }, + ] as unknown as Node[] + + result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes) + + expect(store.getState().nodeAnimation).toBe(false) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 }) + expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 }) + }) + + it('handleSelectionCancel should clear all selection state', () => { + const { result } = renderWorkflowHook(() => useSelectionInteractions()) + + result.current.handleSelectionCancel() + + expect(rfStoreExtra.setState).toHaveBeenCalledWith({ + userSelectionRect: null, + userSelectionActive: true, + }) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[] + expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true) + + const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[] + expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true) + }) + + it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => { + const { result, store } = renderWorkflowHook(() => useSelectionInteractions()) + + const wrongTarget = document.createElement('div') + wrongTarget.classList.add('some-other-class') + result.current.handleSelectionContextMenu({ + target: wrongTarget, + preventDefault: vi.fn(), + clientX: 300, + clientY: 200, + } as unknown as React.MouseEvent) + + expect(store.getState().selectionMenu).toBeUndefined() + + const correctTarget = document.createElement('div') + correctTarget.classList.add('react-flow__nodesselection-rect') + result.current.handleSelectionContextMenu({ + target: correctTarget, + preventDefault: vi.fn(), + clientX: 300, + clientY: 200, + } as unknown as React.MouseEvent) + + expect(store.getState().selectionMenu).toEqual({ + top: 150, + left: 200, + }) + }) + + it('handleSelectionContextmenuCancel should clear selectionMenu', () => { + const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), { + initialStoreState: { selectionMenu: { top: 50, left: 60 } }, + }) + + result.current.handleSelectionContextmenuCancel() + + expect(store.getState().selectionMenu).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts b/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts new file mode 100644 index 0000000000..bdb2554cd8 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-serial-async-callback.spec.ts @@ -0,0 +1,94 @@ +import { act, renderHook } from '@testing-library/react' +import { useSerialAsyncCallback } from '../use-serial-async-callback' + +describe('useSerialAsyncCallback', () => { + it('should execute a synchronous function and return its result', async () => { + const fn = vi.fn((..._args: number[]) => 42) + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + const value = await act(() => result.current(1, 2)) + + expect(value).toBe(42) + expect(fn).toHaveBeenCalledWith(1, 2) + }) + + it('should execute an async function and return its result', async () => { + const fn = vi.fn(async (x: number) => x * 2) + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + const value = await act(() => result.current(5)) + + expect(value).toBe(10) + }) + + it('should serialize concurrent calls sequentially', async () => { + const order: number[] = [] + const fn = vi.fn(async (id: number, delay: number) => { + await new Promise(resolve => setTimeout(resolve, delay)) + order.push(id) + return id + }) + + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + let r1: number | undefined + let r2: number | undefined + let r3: number | undefined + + await act(async () => { + const p1 = result.current(1, 30) + const p2 = result.current(2, 10) + const p3 = result.current(3, 5) + r1 = await p1 + r2 = await p2 + r3 = await p3 + }) + + expect(order).toEqual([1, 2, 3]) + expect(r1).toBe(1) + expect(r2).toBe(2) + expect(r3).toBe(3) + }) + + it('should skip execution when shouldSkip returns true', async () => { + const fn = vi.fn(async () => 'executed') + const shouldSkip = vi.fn(() => true) + const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip)) + + const value = await act(() => result.current()) + + expect(value).toBeUndefined() + expect(fn).not.toHaveBeenCalled() + }) + + it('should execute when shouldSkip returns false', async () => { + const fn = vi.fn(async () => 'executed') + const shouldSkip = vi.fn(() => false) + const { result } = renderHook(() => useSerialAsyncCallback(fn, shouldSkip)) + + const value = await act(() => result.current()) + + expect(value).toBe('executed') + expect(fn).toHaveBeenCalledOnce() + }) + + it('should continue queuing after a previous call rejects', async () => { + let callCount = 0 + const fn = vi.fn(async () => { + callCount++ + if (callCount === 1) + throw new Error('fail') + return 'ok' + }) + + const { result } = renderHook(() => useSerialAsyncCallback(fn)) + + await act(async () => { + await result.current().catch(() => {}) + const value = await result.current() + expect(value).toBe('ok') + }) + + expect(fn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts new file mode 100644 index 0000000000..4ce79d5bf2 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts @@ -0,0 +1,171 @@ +import { CollectionType } from '@/app/components/tools/types' +import { resetReactFlowMockState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useGetToolIcon, useToolIcon } from '../use-tool-icon' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock({ + buildInTools: [{ id: 'builtin-1', name: 'builtin', icon: '/builtin.svg', icon_dark: '/builtin-dark.svg', plugin_id: 'p1' }], + customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }], + })) + +vi.mock('@/service/use-triggers', async () => + (await import('../../__tests__/service-mock-factory')).createTriggerServiceMock({ + triggerPlugins: [{ id: 'trigger-1', icon: '/trigger.svg', icon_dark: '/trigger-dark.svg' }], + })) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/utils', () => ({ + canFindTool: (id: string, target: string) => id === target, +})) + +const baseNodeData = { title: '', desc: '' } + +describe('useToolIcon', () => { + beforeEach(() => { + resetReactFlowMockState() + }) + + it('should return empty string when no data', () => { + const { result } = renderWorkflowHook(() => useToolIcon(undefined)) + expect(result.current).toBe('') + }) + + it('should find icon for TriggerPlugin node', () => { + const data = { + ...baseNodeData, + type: BlockEnum.TriggerPlugin, + plugin_id: 'trigger-1', + provider_id: 'trigger-1', + provider_name: 'trigger-1', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/trigger.svg') + }) + + it('should find icon for Tool node (builtIn)', () => { + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'builtin-1', + plugin_id: 'p1', + provider_name: 'builtin', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/builtin.svg') + }) + + it('should find icon for Tool node (custom)', () => { + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.custom, + provider_id: 'custom-1', + plugin_id: 'p2', + provider_name: 'custom', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/custom.svg') + }) + + it('should fallback to provider_icon when no collection match', () => { + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'unknown-provider', + plugin_id: 'unknown-plugin', + provider_name: 'unknown', + provider_icon: '/fallback.svg', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('/fallback.svg') + }) + + it('should return empty string for unmatched DataSource node', () => { + const data = { + ...baseNodeData, + type: BlockEnum.DataSource, + plugin_id: 'unknown-ds', + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('') + }) + + it('should return empty string for unrecognized node type', () => { + const data = { + ...baseNodeData, + type: BlockEnum.LLM, + } + + const { result } = renderWorkflowHook(() => useToolIcon(data)) + expect(result.current).toBe('') + }) +}) + +describe('useGetToolIcon', () => { + beforeEach(() => { + resetReactFlowMockState() + }) + + it('should return a function', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + expect(typeof result.current).toBe('function') + }) + + it('should find icon for TriggerPlugin node via returned function', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + + const data = { + ...baseNodeData, + type: BlockEnum.TriggerPlugin, + plugin_id: 'trigger-1', + provider_id: 'trigger-1', + provider_name: 'trigger-1', + } + + const icon = result.current(data) + expect(icon).toBe('/trigger.svg') + }) + + it('should find icon for Tool node via returned function', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + + const data = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'builtin-1', + plugin_id: 'p1', + provider_name: 'builtin', + } + + const icon = result.current(data) + expect(icon).toBe('/builtin.svg') + }) + + it('should return undefined for unmatched node type', () => { + const { result } = renderWorkflowHook(() => useGetToolIcon()) + + const data = { + ...baseNodeData, + type: BlockEnum.LLM, + } + + const icon = result.current(data) + expect(icon).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts new file mode 100644 index 0000000000..9544c401cf --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts @@ -0,0 +1,130 @@ +import { renderHook } from '@testing-library/react' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { NodeRunningStatus } from '../../types' +import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync' +import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +describe('useEdgesInteractionsWithoutSync', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.edges = [ + { id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } }, + { id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } }, + ] + }) + + it('should clear running status and waitingRun on all edges', () => { + const { result } = renderHook(() => useEdgesInteractionsWithoutSync()) + + result.current.handleEdgeCancelRunningStatus() + + expect(rfState.setEdges).toHaveBeenCalledOnce() + const updated = rfState.setEdges.mock.calls[0][0] + for (const edge of updated) { + expect(edge.data._sourceRunningStatus).toBeUndefined() + expect(edge.data._targetRunningStatus).toBeUndefined() + expect(edge.data._waitingRun).toBe(false) + } + }) + + it('should not mutate original edges', () => { + const originalData = { ...rfState.edges[0].data } + const { result } = renderHook(() => useEdgesInteractionsWithoutSync()) + + result.current.handleEdgeCancelRunningStatus() + + expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus) + }) +}) + +describe('useNodesInteractionsWithoutSync', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }, + { id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }, + { id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }, + ] + }) + + describe('handleNodeCancelRunningStatus', () => { + it('should clear _runningStatus and _waitingRun on all nodes', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleNodeCancelRunningStatus() + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updated = rfState.setNodes.mock.calls[0][0] + for (const node of updated) { + expect(node.data._runningStatus).toBeUndefined() + expect(node.data._waitingRun).toBe(false) + } + }) + }) + + describe('handleCancelAllNodeSuccessStatus', () => { + it('should clear _runningStatus only for Succeeded nodes', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelAllNodeSuccessStatus() + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updated = rfState.setNodes.mock.calls[0][0] + const n1 = updated.find((n: { id: string }) => n.id === 'n1') + const n2 = updated.find((n: { id: string }) => n.id === 'n2') + const n3 = updated.find((n: { id: string }) => n.id === 'n3') + + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n2.data._runningStatus).toBeUndefined() + expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed) + }) + + it('should not modify _waitingRun', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelAllNodeSuccessStatus() + + const updated = rfState.setNodes.mock.calls[0][0] + expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true) + expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true) + }) + }) + + describe('handleCancelNodeSuccessStatus', () => { + it('should clear _runningStatus and _waitingRun for the specified Succeeded node', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelNodeSuccessStatus('n2') + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updated = rfState.setNodes.mock.calls[0][0] + const n2 = updated.find((n: { id: string }) => n.id === 'n2') + expect(n2.data._runningStatus).toBeUndefined() + expect(n2.data._waitingRun).toBe(false) + }) + + it('should not modify nodes that are not Succeeded', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelNodeSuccessStatus('n1') + + const updated = rfState.setNodes.mock.calls[0][0] + const n1 = updated.find((n: { id: string }) => n.id === 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._waitingRun).toBe(true) + }) + + it('should not modify other nodes', () => { + const { result } = renderHook(() => useNodesInteractionsWithoutSync()) + + result.current.handleCancelNodeSuccessStatus('n2') + + const updated = rfState.setNodes.mock.calls[0][0] + const n1 = updated.find((n: { id: string }) => n.id === 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts new file mode 100644 index 0000000000..856ada37ed --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-mode.spec.ts @@ -0,0 +1,47 @@ +import type { HistoryWorkflowData } from '../../types' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useWorkflowMode } from '../use-workflow-mode' + +describe('useWorkflowMode', () => { + it('should return normal mode when no history data and not restoring', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode()) + + expect(result.current.normal).toBe(true) + expect(result.current.restoring).toBe(false) + expect(result.current.viewHistory).toBe(false) + }) + + it('should return restoring mode when isRestoring is true', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode(), { + initialStoreState: { isRestoring: true }, + }) + + expect(result.current.normal).toBe(false) + expect(result.current.restoring).toBe(true) + expect(result.current.viewHistory).toBe(false) + }) + + it('should return viewHistory mode when historyWorkflowData exists', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode(), { + initialStoreState: { + historyWorkflowData: { id: 'v1', status: 'succeeded' } as HistoryWorkflowData, + }, + }) + + expect(result.current.normal).toBe(false) + expect(result.current.restoring).toBe(false) + expect(result.current.viewHistory).toBe(true) + }) + + it('should prioritize restoring over viewHistory when both are set', () => { + const { result } = renderWorkflowHook(() => useWorkflowMode(), { + initialStoreState: { + isRestoring: true, + historyWorkflowData: { id: 'v1', status: 'succeeded' } as HistoryWorkflowData, + }, + }) + + expect(result.current.restoring).toBe(true) + expect(result.current.normal).toBe(false) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts new file mode 100644 index 0000000000..2085e5ab47 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts @@ -0,0 +1,242 @@ +import type { + AgentLogResponse, + HumanInputFormFilledResponse, + HumanInputFormTimeoutResponse, + TextChunkResponse, + TextReplaceResponse, + WorkflowFinishedResponse, +} from '@/types/workflow' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { useWorkflowAgentLog } from '../use-workflow-run-event/use-workflow-agent-log' +import { useWorkflowFailed } from '../use-workflow-run-event/use-workflow-failed' +import { useWorkflowFinished } from '../use-workflow-run-event/use-workflow-finished' +import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-run-event/use-workflow-node-human-input-form-filled' +import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-run-event/use-workflow-node-human-input-form-timeout' +import { useWorkflowPaused } from '../use-workflow-run-event/use-workflow-paused' +import { useWorkflowTextChunk } from '../use-workflow-run-event/use-workflow-text-chunk' +import { useWorkflowTextReplace } from '../use-workflow-run-event/use-workflow-text-replace' + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getFilesInLogs: vi.fn(() => []), +})) + +describe('useWorkflowFailed', () => { + it('should set status to Failed', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowFailed() + + expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed) + }) +}) + +describe('useWorkflowPaused', () => { + it('should set status to Paused', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowPaused() + + expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused) + }) +}) + +describe('useWorkflowTextChunk', () => { + it('should append text and activate result tab', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), { + initialStoreState: { + workflowRunningData: baseRunningData({ resultText: 'Hello' }), + }, + }) + + result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse) + + const state = store.getState().workflowRunningData! + expect(state.resultText).toBe('Hello World') + expect(state.resultTabActive).toBe(true) + }) +}) + +describe('useWorkflowTextReplace', () => { + it('should replace resultText', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), { + initialStoreState: { + workflowRunningData: baseRunningData({ resultText: 'old text' }), + }, + }) + + result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse) + + expect(store.getState().workflowRunningData!.resultText).toBe('new text') + }) +}) + +describe('useWorkflowFinished', () => { + it('should merge data into result and activate result tab for single string output', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowFinished({ + data: { status: 'succeeded', outputs: { answer: 'hello' } }, + } as WorkflowFinishedResponse) + + const state = store.getState().workflowRunningData! + expect(state.result.status).toBe('succeeded') + expect(state.resultTabActive).toBe(true) + expect(state.resultText).toBe('hello') + }) + + it('should not activate result tab for multi-key outputs', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowFinished({ + data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } }, + } as WorkflowFinishedResponse) + + expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy() + }) +}) + +describe('useWorkflowAgentLog', () => { + it('should create agent_log array when execution_metadata has no agent_log', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1', execution_metadata: {} }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm1' }, + } as AgentLogResponse) + + const trace = store.getState().workflowRunningData!.tracing![0] + expect(trace.execution_metadata!.agent_log).toHaveLength(1) + expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1') + }) + + it('should append to existing agent_log', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ + node_id: 'n1', + execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] }, + }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm2' }, + } as AgentLogResponse) + + expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2) + }) + + it('should update existing log entry by message_id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ + node_id: 'n1', + execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] }, + }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm1', text: 'new' }, + } as unknown as AgentLogResponse) + + const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log! + expect(log).toHaveLength(1) + expect((log[0] as unknown as { text: string }).text).toBe('new') + }) + + it('should create execution_metadata when it does not exist', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1' }], + }), + }, + }) + + result.current.handleWorkflowAgentLog({ + data: { node_id: 'n1', message_id: 'm1' }, + } as AgentLogResponse) + + expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1) + }) +}) + +describe('useWorkflowNodeHumanInputFormFilled', () => { + it('should remove form from humanInputFormDataList and add to humanInputFilledFormDataList', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputFormFilled({ + data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' }, + } as HumanInputFormFilledResponse) + + const state = store.getState().workflowRunningData! + expect(state.humanInputFormDataList).toHaveLength(0) + expect(state.humanInputFilledFormDataList).toHaveLength(1) + expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1') + }) + + it('should create humanInputFilledFormDataList when it does not exist', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputFormFilled({ + data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' }, + } as HumanInputFormFilledResponse) + + expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined() + }) +}) + +describe('useWorkflowNodeHumanInputFormTimeout', () => { + it('should set expiration_time on the matching form', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputFormTimeout({ + data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 }, + } as HumanInputFormTimeoutResponse) + + expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts new file mode 100644 index 0000000000..e40efd3819 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts @@ -0,0 +1,269 @@ +import type { WorkflowRunningData } from '../../types' +import type { + IterationFinishedResponse, + IterationNextResponse, + LoopFinishedResponse, + LoopNextResponse, + NodeFinishedResponse, + WorkflowStartedResponse, +} from '@/types/workflow' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { DEFAULT_ITER_TIMES } from '../../constants' +import { NodeRunningStatus, WorkflowRunningStatus } from '../../types' +import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished' +import { useWorkflowNodeIterationFinished } from '../use-workflow-run-event/use-workflow-node-iteration-finished' +import { useWorkflowNodeIterationNext } from '../use-workflow-run-event/use-workflow-node-iteration-next' +import { useWorkflowNodeLoopFinished } from '../use-workflow-run-event/use-workflow-node-loop-finished' +import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-node-loop-next' +import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry' +import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +describe('useWorkflowStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should initialize workflow running data and reset nodes/edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowStarted({ + task_id: 'task-2', + data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 }, + } as WorkflowStartedResponse) + + const state = store.getState().workflowRunningData! + expect(state.task_id).toBe('task-2') + expect(state.result.status).toBe(WorkflowRunningStatus.Running) + expect(state.resultText).toBe('') + + expect(rfState.setNodes).toHaveBeenCalledOnce() + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._waitingRun).toBe(true) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) + + it('should resume from Paused without resetting nodes/edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'], + }), + }, + }) + + result.current.handleWorkflowStarted({ + task_id: 'task-2', + data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 }, + } as WorkflowStartedResponse) + + expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running) + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(rfState.setEdges).not.toHaveBeenCalled() + }) +}) + +describe('useWorkflowNodeFinished', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should update tracing and node running status', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeFinished({ + data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + } as NodeFinishedResponse) + + const trace = store.getState().workflowRunningData!.tracing![0] + expect(trace.status).toBe(NodeRunningStatus.Succeeded) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded) + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) + + it('should set _runningBranchId for IfElse node', () => { + const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeFinished({ + data: { + id: 'trace-1', + node_id: 'n1', + node_type: 'if-else', + status: NodeRunningStatus.Succeeded, + outputs: { selected_case_id: 'branch-a' }, + }, + } as unknown as NodeFinishedResponse) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._runningBranchId).toBe('branch-a') + }) +}) + +describe('useWorkflowNodeRetry', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + ] + }) + + it('should push retry data to tracing and update _retryIndex', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeRetry({ + data: { node_id: 'n1', retry_index: 2 }, + } as NodeFinishedResponse) + + expect(store.getState().workflowRunningData!.tracing).toHaveLength(1) + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._retryIndex).toBe(2) + }) +}) + +describe('useWorkflowNodeIterationNext', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + ] + }) + + it('should set _iterationIndex and increment iterTimes', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + iterTimes: 3, + }, + }) + + result.current.handleWorkflowNodeIterationNext({ + data: { node_id: 'n1' }, + } as IterationNextResponse) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._iterationIndex).toBe(3) + expect(store.getState().iterTimes).toBe(4) + }) +}) + +describe('useWorkflowNodeIterationFinished', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should update tracing, reset iterTimes, update node status and edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + iterTimes: 10, + }, + }) + + result.current.handleWorkflowNodeIterationFinished({ + data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + } as IterationFinishedResponse) + + expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded) + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) + +describe('useWorkflowNodeLoopNext', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: {} }, + { id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } }, + ] + }) + + it('should set _loopIndex and reset child nodes to waiting', () => { + const { result } = renderWorkflowHook(() => useWorkflowNodeLoopNext(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeLoopNext({ + data: { node_id: 'n1', index: 5 }, + } as LoopNextResponse) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes[0].data._loopIndex).toBe(5) + expect(updatedNodes[1].data._waitingRun).toBe(true) + expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting) + }) +}) + +describe('useWorkflowNodeLoopFinished', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should update tracing, node status and edges', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopFinished(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeLoopFinished({ + data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded }, + } as LoopFinishedResponse) + + const trace = store.getState().workflowRunningData!.tracing![0] + expect(trace.status).toBe(NodeRunningStatus.Succeeded) + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts new file mode 100644 index 0000000000..51d1ba5b74 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts @@ -0,0 +1,244 @@ +import type { + HumanInputRequiredResponse, + IterationStartedResponse, + LoopStartedResponse, + NodeStartedResponse, +} from '@/types/workflow' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { DEFAULT_ITER_TIMES } from '../../constants' +import { NodeRunningStatus } from '../../types' +import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required' +import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-workflow-node-iteration-started' +import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started' +import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +function findNodeById(nodes: Array<{ id: string, data: Record }>, id: string) { + return nodes.find(n => n.id === id)! +} + +const containerParams = { clientWidth: 1200, clientHeight: 800 } + +describe('useWorkflowNodeStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } }, + { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } }, + { id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should push to tracing, set node running, and adjust viewport for root node', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeStarted( + { data: { node_id: 'n1' } } as NodeStartedResponse, + containerParams, + ) + + const tracing = store.getState().workflowRunningData!.tracing! + expect(tracing).toHaveLength(1) + expect(tracing[0].status).toBe(NodeRunningStatus.Running) + + expect(rfState.setViewport).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const n1 = findNodeById(updatedNodes, 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._waitingRun).toBe(false) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) + + it('should not adjust viewport for child node (has parentId)', () => { + const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeStarted( + { data: { node_id: 'n2' } } as NodeStartedResponse, + containerParams, + ) + + expect(rfState.setViewport).not.toHaveBeenCalled() + }) + + it('should update existing tracing entry if node_id exists at non-zero index', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [ + { node_id: 'n0', status: NodeRunningStatus.Succeeded }, + { node_id: 'n1', status: NodeRunningStatus.Succeeded }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeStarted( + { data: { node_id: 'n1' } } as NodeStartedResponse, + containerParams, + ) + + const tracing = store.getState().workflowRunningData!.tracing! + expect(tracing).toHaveLength(2) + expect(tracing[1].status).toBe(NodeRunningStatus.Running) + }) +}) + +describe('useWorkflowNodeIterationStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } }, + { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationStarted(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + iterTimes: 99, + }, + }) + + result.current.handleWorkflowNodeIterationStarted( + { data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse, + containerParams, + ) + + const tracing = store.getState().workflowRunningData!.tracing! + expect(tracing[0].status).toBe(NodeRunningStatus.Running) + + expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES) + expect(rfState.setViewport).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const n1 = findNodeById(updatedNodes, 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._iterationLength).toBe(10) + expect(n1.data._waitingRun).toBe(false) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) + +describe('useWorkflowNodeLoopStarted', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } }, + { id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n0', target: 'n1', data: {} }, + ] + }) + + it('should push to tracing, set viewport, and update node with _loopLength', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeLoopStarted(), { + initialStoreState: { workflowRunningData: baseRunningData() }, + }) + + result.current.handleWorkflowNodeLoopStarted( + { data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse, + containerParams, + ) + + expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running) + expect(rfState.setViewport).toHaveBeenCalledOnce() + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + const n1 = findNodeById(updatedNodes, 'n1') + expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running) + expect(n1.data._loopLength).toBe(5) + expect(n1.data._waitingRun).toBe(false) + + expect(rfState.setEdges).toHaveBeenCalledOnce() + }) +}) + +describe('useWorkflowNodeHumanInputRequired', () => { + beforeEach(() => { + resetReactFlowMockState() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + { id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }, + ] + }) + + it('should create humanInputFormDataList and set tracing/node to Paused', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputRequired({ + data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' }, + } as HumanInputRequiredResponse) + + const state = store.getState().workflowRunningData! + expect(state.humanInputFormDataList).toHaveLength(1) + expect(state.humanInputFormDataList![0].form_id).toBe('f1') + expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused) + + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(findNodeById(updatedNodes, 'n1').data._runningStatus).toBe(NodeRunningStatus.Paused) + }) + + it('should update existing form entry for same node_id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }], + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputRequired({ + data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' }, + } as HumanInputRequiredResponse) + + const formList = store.getState().workflowRunningData!.humanInputFormDataList! + expect(formList).toHaveLength(1) + expect(formList[0].form_id).toBe('new') + }) + + it('should append new form entry for different node_id', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), { + initialStoreState: { + workflowRunningData: baseRunningData({ + tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }], + humanInputFormDataList: [ + { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' }, + ], + }), + }, + }) + + result.current.handleWorkflowNodeHumanInputRequired({ + data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' }, + } as HumanInputRequiredResponse) + + expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts new file mode 100644 index 0000000000..685df81864 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-variables.spec.ts @@ -0,0 +1,148 @@ +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { useWorkflowVariables, useWorkflowVariableType } from '../use-workflow-variables' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('@/service/use-tools', async () => + (await import('../../__tests__/service-mock-factory')).createToolServiceMock()) + +const { mockToNodeAvailableVars, mockGetVarType } = vi.hoisted(() => ({ + mockToNodeAvailableVars: vi.fn((_args: Record) => [] as unknown[]), + mockGetVarType: vi.fn((_args: Record) => 'string' as string), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ + toNodeAvailableVars: mockToNodeAvailableVars, + getVarType: mockGetVarType, +})) + +vi.mock('../../nodes/_base/components/variable/use-match-schema-type', () => ({ + default: () => ({ schemaTypeDefinitions: [] }), +})) + +let mockIsChatMode = false +vi.mock('../use-workflow', () => ({ + useIsChatMode: () => mockIsChatMode, +})) + +describe('useWorkflowVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getNodeAvailableVars', () => { + it('should call toNodeAvailableVars with store data', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariables(), { + initialStoreState: { + conversationVariables: [{ id: 'cv1' }] as never[], + environmentVariables: [{ id: 'ev1' }] as never[], + }, + }) + + result.current.getNodeAvailableVars({ + beforeNodes: [], + isChatMode: true, + filterVar: () => true, + }) + + expect(mockToNodeAvailableVars).toHaveBeenCalledOnce() + const args = mockToNodeAvailableVars.mock.calls[0][0] + expect(args.isChatMode).toBe(true) + expect(args.conversationVariables).toHaveLength(1) + expect(args.environmentVariables).toHaveLength(1) + }) + + it('should hide env variables when hideEnv is true', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariables(), { + initialStoreState: { + environmentVariables: [{ id: 'ev1' }] as never[], + }, + }) + + result.current.getNodeAvailableVars({ + beforeNodes: [], + isChatMode: false, + filterVar: () => true, + hideEnv: true, + }) + + const args = mockToNodeAvailableVars.mock.calls[0][0] + expect(args.environmentVariables).toEqual([]) + }) + + it('should hide chat variables when not in chat mode', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariables(), { + initialStoreState: { + conversationVariables: [{ id: 'cv1' }] as never[], + }, + }) + + result.current.getNodeAvailableVars({ + beforeNodes: [], + isChatMode: false, + filterVar: () => true, + }) + + const args = mockToNodeAvailableVars.mock.calls[0][0] + expect(args.conversationVariables).toEqual([]) + }) + }) + + describe('getCurrentVariableType', () => { + it('should call getVarType with store data and return the result', () => { + mockGetVarType.mockReturnValue('number') + + const { result } = renderWorkflowHook(() => useWorkflowVariables()) + + const type = result.current.getCurrentVariableType({ + valueSelector: ['node-1', 'output'], + availableNodes: [], + isChatMode: false, + }) + + expect(mockGetVarType).toHaveBeenCalledOnce() + expect(type).toBe('number') + }) + }) +}) + +describe('useWorkflowVariableType', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + mockIsChatMode = false + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { isInIteration: false } }, + { id: 'n2', position: { x: 300, y: 0 }, data: { isInIteration: true }, parentId: 'iter-1' }, + { id: 'iter-1', position: { x: 0, y: 200 }, data: {} }, + ] + }) + + it('should return a function', () => { + const { result } = renderWorkflowHook(() => useWorkflowVariableType()) + expect(typeof result.current).toBe('function') + }) + + it('should call getCurrentVariableType with the correct node', () => { + mockGetVarType.mockReturnValue('string') + + const { result } = renderWorkflowHook(() => useWorkflowVariableType()) + const type = result.current({ nodeId: 'n1', valueSelector: ['n1', 'output'] }) + + expect(mockGetVarType).toHaveBeenCalledOnce() + expect(type).toBe('string') + }) + + it('should pass iterationNode as parentNode when node is in iteration', () => { + mockGetVarType.mockReturnValue('array') + + const { result } = renderWorkflowHook(() => useWorkflowVariableType()) + result.current({ nodeId: 'n2', valueSelector: ['n2', 'item'] }) + + const args = mockGetVarType.mock.calls[0][0] + expect(args.parentNode).toBeDefined() + expect((args.parentNode as { id: string }).id).toBe('iter-1') + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts new file mode 100644 index 0000000000..24cc9455cb --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow.spec.ts @@ -0,0 +1,234 @@ +import { act, renderHook } from '@testing-library/react' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { WorkflowRunningStatus } from '../../types' +import { + useIsChatMode, + useIsNodeInIteration, + useIsNodeInLoop, + useNodesReadOnly, + useWorkflowReadOnly, +} from '../use-workflow' + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +let mockAppMode = 'workflow' +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }), +})) + +beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + mockAppMode = 'workflow' +}) + +// --------------------------------------------------------------------------- +// useIsChatMode +// --------------------------------------------------------------------------- + +describe('useIsChatMode', () => { + it('should return true when app mode is advanced-chat', () => { + mockAppMode = 'advanced-chat' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(true) + }) + + it('should return false when app mode is workflow', () => { + mockAppMode = 'workflow' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(false) + }) + + it('should return false when app mode is chat', () => { + mockAppMode = 'chat' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(false) + }) + + it('should return false when app mode is completion', () => { + mockAppMode = 'completion' + const { result } = renderHook(() => useIsChatMode()) + expect(result.current).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// useWorkflowReadOnly +// --------------------------------------------------------------------------- + +describe('useWorkflowReadOnly', () => { + it('should return workflowReadOnly true when status is Running', () => { + const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + }, + }) + expect(result.current.workflowReadOnly).toBe(true) + }) + + it('should return workflowReadOnly false when status is Succeeded', () => { + const { result } = renderWorkflowHook(() => useWorkflowReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Succeeded } }), + }, + }) + expect(result.current.workflowReadOnly).toBe(false) + }) + + it('should return workflowReadOnly false when no running data', () => { + const { result } = renderWorkflowHook(() => useWorkflowReadOnly()) + expect(result.current.workflowReadOnly).toBe(false) + }) + + it('should expose getWorkflowReadOnly that reads from store state', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowReadOnly()) + + expect(result.current.getWorkflowReadOnly()).toBe(false) + + act(() => { + store.setState({ + workflowRunningData: baseRunningData({ task_id: 'task-2' }), + }) + }) + + expect(result.current.getWorkflowReadOnly()).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// useNodesReadOnly +// --------------------------------------------------------------------------- + +describe('useNodesReadOnly', () => { + it('should return true when status is Running', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData(), + }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return true when status is Paused', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { + workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Paused } }), + }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return true when historyWorkflowData is present', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { + historyWorkflowData: { id: 'run-1', status: 'succeeded' }, + }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return true when isRestoring is true', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly(), { + initialStoreState: { isRestoring: true }, + }) + expect(result.current.nodesReadOnly).toBe(true) + }) + + it('should return false when none of the conditions are met', () => { + const { result } = renderWorkflowHook(() => useNodesReadOnly()) + expect(result.current.nodesReadOnly).toBe(false) + }) + + it('should expose getNodesReadOnly that reads from store state', () => { + const { result, store } = renderWorkflowHook(() => useNodesReadOnly()) + + expect(result.current.getNodesReadOnly()).toBe(false) + + act(() => { + store.setState({ isRestoring: true }) + }) + expect(result.current.getNodesReadOnly()).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// useIsNodeInIteration +// --------------------------------------------------------------------------- + +describe('useIsNodeInIteration', () => { + beforeEach(() => { + rfState.nodes = [ + { id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } }, + { id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} }, + { id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} }, + { id: 'outside-1', position: { x: 100, y: 0 }, data: {} }, + ] + }) + + it('should return true when node is a direct child of the iteration', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('child-1')).toBe(true) + }) + + it('should return false for a grandchild (only checks direct parentId)', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('grandchild-1')).toBe(false) + }) + + it('should return false when node is outside the iteration', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('outside-1')).toBe(false) + }) + + it('should return false when node does not exist', () => { + const { result } = renderHook(() => useIsNodeInIteration('iter-1')) + expect(result.current.isNodeInIteration('nonexistent')).toBe(false) + }) + + it('should return false when iteration id has no children', () => { + const { result } = renderHook(() => useIsNodeInIteration('no-such-iter')) + expect(result.current.isNodeInIteration('child-1')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// useIsNodeInLoop +// --------------------------------------------------------------------------- + +describe('useIsNodeInLoop', () => { + beforeEach(() => { + rfState.nodes = [ + { id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } }, + { id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} }, + { id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} }, + { id: 'outside-1', position: { x: 100, y: 0 }, data: {} }, + ] + }) + + it('should return true when node is a direct child of the loop', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('child-1')).toBe(true) + }) + + it('should return false for a grandchild (only checks direct parentId)', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('grandchild-1')).toBe(false) + }) + + it('should return false when node is outside the loop', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('outside-1')).toBe(false) + }) + + it('should return false when node does not exist', () => { + const { result } = renderHook(() => useIsNodeInLoop('loop-1')) + expect(result.current.isNodeInLoop('nonexistent')).toBe(false) + }) + + it('should return false when loop id has no children', () => { + const { result } = renderHook(() => useIsNodeInLoop('no-such-loop')) + expect(result.current.isNodeInLoop('child-1')).toBe(false) + }) +}) diff --git a/web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx similarity index 100% rename from web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx rename to web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx diff --git a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts similarity index 99% rename from web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts rename to web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts index 4ccd8248b1..4d095ab189 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts +++ b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.spec.ts @@ -7,7 +7,7 @@ import { // Mock the getMatchedSchemaType dependency vi.mock('../../_base/components/variable/use-match-schema-type', () => ({ - getMatchedSchemaType: (schema: any) => { + getMatchedSchemaType: (schema: Record | null | undefined) => { // Return schema_type or schemaType if present return schema?.schema_type || schema?.schemaType || undefined }, diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.spec.ts similarity index 98% rename from web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts rename to web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.spec.ts index c75ffc0a59..17c6767f3e 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.spec.ts @@ -281,7 +281,7 @@ describe('Form Helpers', () => { describe('Edge cases', () => { it('should handle objects with non-string keys', () => { - const input = { [Symbol('test')]: 'value', regular: 'field' } as any + const input = { [Symbol('test')]: 'value', regular: 'field' } as Record const result = sanitizeFormValues(input) expect(result.regular).toBe('field') @@ -299,7 +299,7 @@ describe('Form Helpers', () => { }) it('should handle circular references in deepSanitizeFormValues gracefully', () => { - const obj: any = { field: 'value' } + const obj: Record = { field: 'value' } obj.circular = obj expect(() => deepSanitizeFormValues(obj)).not.toThrow() 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 index 512eb5b404..145b5d72fe 100644 --- a/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/chat-variable-slice.spec.ts @@ -1,9 +1,9 @@ import type { ConversationVariable } from '@/app/components/workflow/types' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Chat Variable Slice', () => { 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 index 95ed7d3955..a8e53e0b8b 100644 --- a/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/env-variable-slice.spec.ts @@ -1,8 +1,8 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Env Variable Slice', () => { 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 index 225cb6a6c8..4ecbbda092 100644 --- a/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/inspect-vars-slice.spec.ts @@ -1,10 +1,10 @@ import type { NodeWithVar, VarInInspect } from '@/types/workflow' import { BlockEnum, VarType } from '@/app/components/workflow/types' import { VarInInspectType } from '@/types/workflow' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } function makeVar(overrides: Partial = {}): VarInInspect { diff --git a/web/app/components/workflow/store/__tests__/trigger-status.test.ts b/web/app/components/workflow/store/__tests__/trigger-status.spec.ts similarity index 100% rename from web/app/components/workflow/store/__tests__/trigger-status.test.ts rename to web/app/components/workflow/store/__tests__/trigger-status.spec.ts diff --git a/web/app/components/workflow/store/__tests__/version-slice.spec.ts b/web/app/components/workflow/store/__tests__/version-slice.spec.ts index 8d76a62256..d85946354d 100644 --- a/web/app/components/workflow/store/__tests__/version-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/version-slice.spec.ts @@ -1,8 +1,8 @@ import type { VersionHistory } from '@/types/workflow' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Version Slice', () => { 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 index dfbc58e050..b09f8511f2 100644 --- a/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-draft-slice.spec.ts @@ -1,8 +1,8 @@ import type { Node } from '@/app/components/workflow/types' -import { createWorkflowStore } from '../workflow' +import { createTestWorkflowStore } from '../../__tests__/workflow-test-env' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } describe('Workflow Draft Slice', () => { @@ -69,13 +69,20 @@ describe('Workflow Draft Slice', () => { }) describe('debouncedSyncWorkflowDraft', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + 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() @@ -84,12 +91,9 @@ describe('Workflow Draft Slice', () => { 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() @@ -98,8 +102,6 @@ describe('Workflow Draft Slice', () => { 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 index df94be90b8..c917986953 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -1,18 +1,29 @@ 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 { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env' import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow' function createStore() { - return createWorkflowStore({}) + return createTestWorkflowStore() } +type SetterKey = keyof Shape & `set${string}` +type StateKey = Exclude + +/** + * Verifies a simple setter → state round-trip: + * calling state[setter](value) should update state[stateKey] to equal value. + */ +function testSetter(setter: SetterKey, stateKey: StateKey, value: Shape[StateKey]) { + const store = createStore() + const setFn = store.getState()[setter] as (v: Shape[StateKey]) => void + setFn(value) + expect(store.getState()[stateKey]).toEqual(value) +} + +const emptyIterParallelLogMap = new Map>() + describe('createWorkflowStore', () => { describe('Initial State', () => { it('should create a store with all slices merged', () => { @@ -32,60 +43,23 @@ describe('createWorkflowStore', () => { }) 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.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['workflowRunningData', 'setWorkflowRunningData', { result: { status: 'running', inputs_truncated: false, process_data_truncated: false, outputs_truncated: false } }], + ['isListening', 'setIsListening', true], + ['listeningTriggerType', 'setListeningTriggerType', BlockEnum.TriggerWebhook], + ['listeningTriggerNodeId', 'setListeningTriggerNodeId', 'node-abc'], + ['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']], + ['listeningTriggerIsAll', 'setListeningTriggerIsAll', true], + ['clipboardElements', 'setClipboardElements', []], + ['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }], + ['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }], + ['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }], + ['showConfirm', 'setShowConfirm', { title: 'Delete?', onConfirm: vi.fn() }], + ['controlPromptEditorRerenderKey', 'setControlPromptEditorRerenderKey', 42], + ['showImportDSLModal', 'setShowImportDSLModal', true], + ['fileUploadConfig', 'setFileUploadConfig', { 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 }], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) it('should persist controlMode to localStorage', () => { @@ -94,180 +68,48 @@ describe('createWorkflowStore', () => { 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' }) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['showSingleRunPanel', 'setShowSingleRunPanel', true], + ['nodeAnimation', 'setNodeAnimation', true], + ['candidateNode', 'setCandidateNode', undefined], + ['nodeMenu', 'setNodeMenu', { top: 100, left: 200, nodeId: 'n1' }], + ['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined], + ['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'], + ['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }], + ['enteringNodePayload', 'setEnteringNodePayload', undefined], + ['iterTimes', 'setIterTimes', 5], + ['loopTimes', 'setLoopTimes', 10], + ['iterParallelLogMap', 'setIterParallelLogMap', emptyIterParallelLogMap], + ['pendingSingleRun', 'setPendingSingleRun', { nodeId: 'n1', action: 'run' }], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) 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) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['showFeaturesPanel', 'setShowFeaturesPanel', true], + ['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true], + ['showInputsPanel', 'setShowInputsPanel', true], + ['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true], + ['panelMenu', 'setPanelMenu', { top: 10, left: 20 }], + ['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }], + ['showVariableInspectPanel', 'setShowVariableInspectPanel', true], + ['initShowLastRunTab', 'setInitShowLastRunTab', true], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) 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.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['helpLineHorizontal', 'setHelpLineHorizontal', { top: 100, left: 0, width: 500 }], + ['helpLineVertical', 'setHelpLineVertical', { top: 0, left: 200, height: 300 }], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) it('should clear helpLineHorizontal', () => { @@ -276,123 +118,50 @@ describe('createWorkflowStore', () => { 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) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['historyWorkflowData', 'setHistoryWorkflowData', { id: 'run-1', status: 'succeeded' }], + ['showRunHistory', 'setShowRunHistory', true], + ['versionHistory', 'setVersionHistory', []], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) 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([]) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['inputs', 'setInputs', { name: 'test', count: 42 }], + ['files', 'setFiles', []], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) 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) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['toolPublished', 'setToolPublished', true], + ['lastPublishedHasUserInput', 'setLastPublishedHasUserInput', true], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) 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) + it.each<[StateKey, SetterKey, Shape[StateKey]]>([ + ['workflowCanvasWidth', 'setWorkflowCanvasWidth', 1200], + ['workflowCanvasHeight', 'setWorkflowCanvasHeight', 800], + ['rightPanelWidth', 'setRightPanelWidth', 500], + ['nodePanelWidth', 'setNodePanelWidth', 350], + ['previewPanelWidth', 'setPreviewPanelWidth', 450], + ['otherPanelWidth', 'setOtherPanelWidth', 380], + ['bottomPanelWidth', 'setBottomPanelWidth', 600], + ['bottomPanelHeight', 'setBottomPanelHeight', 500], + ['variableInspectPanelHeight', 'setVariableInspectPanelHeight', 250], + ['maximizeCanvas', 'setMaximizeCanvas', true], + ])('should update %s', (stateKey, setter, value) => { + testSetter(setter, stateKey, value) }) }) @@ -446,13 +215,10 @@ describe('createWorkflowStore', () => { 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 }) + const { result } = renderWorkflowHook( + () => useStore(s => s.showSingleRunPanel), + { initialStoreState: { showSingleRunPanel: true } }, + ) expect(result.current).toBe(true) }) @@ -465,11 +231,7 @@ describe('createWorkflowStore', () => { 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 }) + const { result, store } = renderWorkflowHook(() => useWorkflowStore()) expect(result.current).toBe(store) }) }) From 336957b4beeadc9d4e8f607142faee069b81e90e Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 4 Mar 2026 17:35:35 +0800 Subject: [PATCH 2/3] chore: drop pwa with serwist (#32838) --- web/app/components/provider/serwist.tsx | 43 --- web/app/layout.tsx | 59 ++-- web/app/serwist/[path]/route.ts | 12 - web/next.config.ts | 1 - web/package.json | 4 - web/pnpm-lock.yaml | 412 +----------------------- 6 files changed, 44 insertions(+), 487 deletions(-) delete mode 100644 web/app/components/provider/serwist.tsx delete mode 100644 web/app/serwist/[path]/route.ts diff --git a/web/app/components/provider/serwist.tsx b/web/app/components/provider/serwist.tsx deleted file mode 100644 index 0cab1bce5d..0000000000 --- a/web/app/components/provider/serwist.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client' - -import { SerwistProvider } from '@serwist/turbopack/react' -import { useEffect } from 'react' -import { IS_DEV } from '@/config' -import { env } from '@/env' -import { isClient } from '@/utils/client' - -export function PWAProvider({ children }: { children: React.ReactNode }) { - if (IS_DEV) { - return {children} - } - - const basePath = env.NEXT_PUBLIC_BASE_PATH - const swUrl = `${basePath}/serwist/sw.js` - - return ( - - {children} - - ) -} - -function DisabledPWAProvider({ children }: { children: React.ReactNode }) { - useEffect(() => { - if (isClient && 'serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations() - .then((registrations) => { - registrations.forEach((registration) => { - registration.unregister() - .catch((error) => { - console.error('Error unregistering service worker:', error) - }) - }) - }) - .catch((error) => { - console.error('Error unregistering service workers:', error) - }) - } - }, []) - - return <>{children} -} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 318cad3a6c..2aaa4dc5ce 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -13,7 +13,6 @@ import { TooltipProvider } from './components/base/ui/tooltip' import BrowserInitializer from './components/browser-initializer' import { ReactScanLoader } from './components/devtools/react-scan/loader' import { I18nServerProvider } from './components/provider/i18n-server' -import { PWAProvider } from './components/provider/serwist' import SentryInitializer from './components/sentry-initializer' import RoutePrefixHandle from './routePrefixHandle' import './styles/globals.css' @@ -64,36 +63,34 @@ const LocaleLayout = async ({ {...datasetMap} >

- - - - - - - - - - - - {children} - - - - - - - - - - - - + + + + + + + + + + + {children} + + + + + + + + + + +
diff --git a/web/app/serwist/[path]/route.ts b/web/app/serwist/[path]/route.ts deleted file mode 100644 index aac0aad17d..0000000000 --- a/web/app/serwist/[path]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createSerwistRoute } from '@serwist/turbopack' -import { env } from '@/env' - -const basePath = env.NEXT_PUBLIC_BASE_PATH - -export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({ - swSrc: 'app/sw.ts', - nextConfig: { - basePath, - }, - useNativeEsbuild: true, -}) diff --git a/web/next.config.ts b/web/next.config.ts index 591c210fe9..414e45318f 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -25,7 +25,6 @@ const remoteImageURLs = ([hasSetWebPrefix ? new URL(`${env.NEXT_PUBLIC_WEB_PREFI const nextConfig: NextConfig = { basePath: env.NEXT_PUBLIC_BASE_PATH, - serverExternalPackages: ['esbuild'], transpilePackages: ['@t3-oss/env-core', '@t3-oss/env-nextjs', 'echarts', 'zrender'], turbopack: { rules: codeInspectorPlugin({ diff --git a/web/package.json b/web/package.json index d352943328..5527dcaa37 100644 --- a/web/package.json +++ b/web/package.json @@ -161,7 +161,6 @@ "string-ts": "2.3.1", "tailwind-merge": "2.6.1", "tldts": "7.0.17", - "ufo": "1.6.3", "use-context-selector": "2.0.0", "uuid": "10.0.0", "zod": "4.3.6", @@ -181,7 +180,6 @@ "@next/eslint-plugin-next": "16.1.6", "@next/mdx": "16.1.5", "@rgrove/parse-xml": "4.2.0", - "@serwist/turbopack": "9.5.4", "@storybook/addon-docs": "10.2.13", "@storybook/addon-links": "10.2.13", "@storybook/addon-onboarding": "10.2.13", @@ -221,7 +219,6 @@ "autoprefixer": "10.4.21", "code-inspector-plugin": "1.3.6", "cross-env": "10.1.0", - "esbuild": "0.27.2", "eslint": "10.0.2", "eslint-plugin-better-tailwindcss": "https://pkg.pr.new/hyoban/eslint-plugin-better-tailwindcss@a520d15", "eslint-plugin-hyoban": "0.11.2", @@ -241,7 +238,6 @@ "react-scan": "0.5.3", "react-server-dom-webpack": "19.2.4", "sass": "1.93.2", - "serwist": "9.5.4", "storybook": "10.2.13", "tailwindcss": "3.4.19", "tsx": "4.21.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3251493a13..4a87d145b1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -5,11 +5,6 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion: ~2.0 - canvas: ^3.2.0 - pbkdf2: ~3.1.3 - prismjs: ~1.30 - string-width: ~4.2.3 '@monaco-editor/loader': 1.5.0 '@nolyfill/safe-buffer': npm:safe-buffer@^5.2.1 '@stylistic/eslint-plugin': https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8 @@ -20,7 +15,9 @@ overrides: array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1 array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1 assert: npm:@nolyfill/assert@^1 + brace-expansion: ~2.0 brace-expansion@<2.0.2: 2.0.2 + canvas: ^3.2.0 devalue@<5.3.2: 5.3.2 es-iterator-helpers: npm:@nolyfill/es-iterator-helpers@^1 esbuild@<0.27.2: 0.27.2 @@ -36,13 +33,16 @@ overrides: object.fromentries: npm:@nolyfill/object.fromentries@^1 object.groupby: npm:@nolyfill/object.groupby@^1 object.values: npm:@nolyfill/object.values@^1 + pbkdf2: ~3.1.3 pbkdf2@<3.1.3: 3.1.3 + prismjs: ~1.30 prismjs@<1.30.0: 1.30.0 safe-buffer: ^5.2.1 safe-regex-test: npm:@nolyfill/safe-regex-test@^1 safer-buffer: npm:@nolyfill/safer-buffer@^1 side-channel: npm:@nolyfill/side-channel@^1 solid-js: 1.9.11 + string-width: ~4.2.3 string.prototype.includes: npm:@nolyfill/string.prototype.includes@^1 string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1 string.prototype.repeat: npm:@nolyfill/string.prototype.repeat@^1 @@ -354,9 +354,6 @@ importers: tldts: specifier: 7.0.17 version: 7.0.17 - ufo: - specifier: 1.6.3 - version: 1.6.3 use-context-selector: specifier: 2.0.0 version: 2.0.0(react@19.2.4)(scheduler@0.27.0) @@ -409,9 +406,6 @@ importers: '@rgrove/parse-xml': specifier: 4.2.0 version: 4.2.0 - '@serwist/turbopack': - specifier: 9.5.4 - version: 9.5.4(@swc/helpers@0.5.18)(esbuild-wasm@0.27.2)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react@19.2.4)(typescript@5.9.3) '@storybook/addon-docs': specifier: 10.2.13 version: 10.2.13(@types/react@19.2.9)(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)) @@ -529,9 +523,6 @@ importers: cross-env: specifier: 10.1.0 version: 10.1.0 - esbuild: - specifier: 0.27.2 - version: 0.27.2 eslint: specifier: 10.0.2 version: 10.0.2(jiti@1.21.7) @@ -589,9 +580,6 @@ importers: sass: specifier: 1.93.2 version: 1.93.2 - serwist: - specifier: 9.5.4 - version: 9.5.4(browserslist@4.28.1)(typescript@5.9.3) storybook: specifier: 10.2.13 version: 10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1659,10 +1647,6 @@ packages: resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -2191,6 +2175,11 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@pivanov/utils@0.0.2': + resolution: {integrity: sha512-q9CN0bFWxWgMY5hVVYyBgez1jGiLBa6I+LkG37ycylPhFvEGOOeaADGtUSu46CaZasPnlY8fCdVJZmrgKb1EPA==} + peerDependencies: + react: '>=18' + react-dom: '>=18' '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2745,48 +2734,6 @@ packages: peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@serwist/build@9.5.4': - resolution: {integrity: sha512-FTiNsNb3luKsLIxjKCvkPiqFZSbx7yVNOFGSUhp4lyfzgnelT1M3/lMC88kLiak90emkuFjSkQgwa6OnyhMZlQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@serwist/turbopack@9.5.4': - resolution: {integrity: sha512-HerOIc2z3LWbFVq/gXK44I99KdF+x0uBI7cPHb+Q3q0WpF50d/i5fV5pZZXCf3LCqtc9oH0VlY6FWDcjWjHI8g==} - engines: {node: '>=18.0.0'} - peerDependencies: - esbuild: 0.27.2 - esbuild-wasm: '>=0.25.0 <1.0.0' - next: '>=14.0.0' - react: '>=18.0.0' - typescript: '>=5.0.0' - peerDependenciesMeta: - esbuild: - optional: true - esbuild-wasm: - optional: true - typescript: - optional: true - - '@serwist/utils@9.5.4': - resolution: {integrity: sha512-uyriGQF1qjNEHXXfsd8XJ5kfK3/MezEaUw//XdHjZeJ0LvLamrgnLJGQQoyJqUfEPCiJ4jJwc4uYMB9LjLiHxA==} - peerDependencies: - browserslist: '>=4' - peerDependenciesMeta: - browserslist: - optional: true - - '@serwist/window@9.5.4': - resolution: {integrity: sha512-52t2G+TgiWDdRwGG0ArU28uy6/oQYICQfNLHs4ywybyS6mHy3BxHFl+JjB5vhg8znIG1LMpGvOmS5b7AuPVYDw==} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - '@shuding/opentype.js@1.4.0-beta.0': resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} engines: {node: '>= 8.0.0'} @@ -2938,87 +2885,12 @@ packages: '@svgdotjs/svg.js@3.2.5': resolution: {integrity: sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==} - '@swc/core-darwin-arm64@1.15.11': - resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-x64@1.15.11': - resolution: {integrity: sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-linux-arm-gnueabihf@1.15.11': - resolution: {integrity: sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.15.11': - resolution: {integrity: sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-arm64-musl@1.15.11': - resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-x64-gnu@1.15.11': - resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - - '@swc/core-linux-x64-musl@1.15.11': - resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] - - '@swc/core-win32-arm64-msvc@1.15.11': - resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] - - '@swc/core-win32-ia32-msvc@1.15.11': - resolution: {integrity: sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==} - engines: {node: '>=10'} - cpu: [ia32] - os: [win32] - - '@swc/core-win32-x64-msvc@1.15.11': - resolution: {integrity: sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==} - engines: {node: '>=10'} - cpu: [x64] - os: [win32] - - '@swc/core@1.15.11': - resolution: {integrity: sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==} - engines: {node: '>=10'} - peerDependencies: - '@swc/helpers': '>=0.5.17' - peerDependenciesMeta: - '@swc/helpers': - optional: true - - '@swc/counter@0.1.3': - resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} - '@swc/types@0.1.25': - resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@t3-oss/env-core@0.13.10': resolution: {integrity: sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==} peerDependencies: @@ -4261,10 +4133,6 @@ packages: resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} engines: {node: '>= 12.0.0'} - common-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} - compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -4726,11 +4594,6 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild-wasm@0.27.2: - resolution: {integrity: sha512-eUTnl8eh+v8UZIZh4MrMOKDAc8Lm7+NqP3pyuTORGFY1s/o9WoiJgKnwXy+te2J3hX7iRbFSHEyig7GsPeeJyw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -5205,10 +5068,6 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -5283,11 +5142,6 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -5631,9 +5485,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -5917,9 +5768,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -5940,9 +5788,6 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.5: resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} engines: {node: 20 || >=22} @@ -6237,10 +6082,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -6425,9 +6266,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -6493,10 +6331,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -6650,10 +6484,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - pretty-bytes@6.1.1: - resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} - engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -7119,14 +6949,6 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - serwist@9.5.4: - resolution: {integrity: sha512-uTHBzpIeA6rE3oyRt392MbtNQDs2JVZelKD1KkT18UkhX6HRwCeassoI1Nd1h52DqYqa7ZfBeldJ4awy+PYrnQ==} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -7198,11 +7020,6 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions - space-separated-tokens@1.1.5: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} @@ -7475,9 +7292,6 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -7944,9 +7758,6 @@ packages: web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -7985,9 +7796,6 @@ packages: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -8002,14 +7810,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -9280,15 +9080,6 @@ snapshots: dependencies: '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 4.2.3 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -9915,6 +9706,10 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@pivanov/utils@0.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) '@pkgjs/parseargs@0.11.0': optional: true @@ -10393,52 +10188,6 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.4 - '@serwist/build@9.5.4(browserslist@4.28.1)(typescript@5.9.3)': - dependencies: - '@serwist/utils': 9.5.4(browserslist@4.28.1) - common-tags: 1.8.2 - glob: 10.5.0 - pretty-bytes: 6.1.1 - source-map: 0.8.0-beta.0 - zod: 4.3.6 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - browserslist - - '@serwist/turbopack@9.5.4(@swc/helpers@0.5.18)(esbuild-wasm@0.27.2)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react@19.2.4)(typescript@5.9.3)': - dependencies: - '@serwist/build': 9.5.4(browserslist@4.28.1)(typescript@5.9.3) - '@serwist/utils': 9.5.4(browserslist@4.28.1) - '@serwist/window': 9.5.4(browserslist@4.28.1)(typescript@5.9.3) - '@swc/core': 1.15.11(@swc/helpers@0.5.18) - browserslist: 4.28.1 - kolorist: 1.8.0 - next: 16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2) - react: 19.2.4 - semver: 7.7.3 - serwist: 9.5.4(browserslist@4.28.1)(typescript@5.9.3) - zod: 4.3.6 - optionalDependencies: - esbuild: 0.27.2 - esbuild-wasm: 0.27.2 - typescript: 5.9.3 - transitivePeerDependencies: - - '@swc/helpers' - - '@serwist/utils@9.5.4(browserslist@4.28.1)': - optionalDependencies: - browserslist: 4.28.1 - - '@serwist/window@9.5.4(browserslist@4.28.1)(typescript@5.9.3)': - dependencies: - '@types/trusted-types': 2.0.7 - serwist: 9.5.4(browserslist@4.28.1)(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - browserslist - '@shuding/opentype.js@1.4.0-beta.0': dependencies: fflate: 0.7.4 @@ -10620,55 +10369,6 @@ snapshots: '@svgdotjs/svg.js@3.2.5': {} - '@swc/core-darwin-arm64@1.15.11': - optional: true - - '@swc/core-darwin-x64@1.15.11': - optional: true - - '@swc/core-linux-arm-gnueabihf@1.15.11': - optional: true - - '@swc/core-linux-arm64-gnu@1.15.11': - optional: true - - '@swc/core-linux-arm64-musl@1.15.11': - optional: true - - '@swc/core-linux-x64-gnu@1.15.11': - optional: true - - '@swc/core-linux-x64-musl@1.15.11': - optional: true - - '@swc/core-win32-arm64-msvc@1.15.11': - optional: true - - '@swc/core-win32-ia32-msvc@1.15.11': - optional: true - - '@swc/core-win32-x64-msvc@1.15.11': - optional: true - - '@swc/core@1.15.11(@swc/helpers@0.5.18)': - dependencies: - '@swc/counter': 0.1.3 - '@swc/types': 0.1.25 - optionalDependencies: - '@swc/core-darwin-arm64': 1.15.11 - '@swc/core-darwin-x64': 1.15.11 - '@swc/core-linux-arm-gnueabihf': 1.15.11 - '@swc/core-linux-arm64-gnu': 1.15.11 - '@swc/core-linux-arm64-musl': 1.15.11 - '@swc/core-linux-x64-gnu': 1.15.11 - '@swc/core-linux-x64-musl': 1.15.11 - '@swc/core-win32-arm64-msvc': 1.15.11 - '@swc/core-win32-ia32-msvc': 1.15.11 - '@swc/core-win32-x64-msvc': 1.15.11 - '@swc/helpers': 0.5.18 - - '@swc/counter@0.1.3': {} - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -10677,10 +10377,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/types@0.1.25': - dependencies: - '@swc/counter': 0.1.3 - '@t3-oss/env-core@0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)': optionalDependencies: typescript: 5.9.3 @@ -11186,7 +10882,8 @@ snapshots: '@types/sortablejs@1.15.8': {} - '@types/trusted-types@2.0.7': {} + '@types/trusted-types@2.0.7': + optional: true '@types/unist@2.0.11': {} @@ -12090,8 +11787,6 @@ snapshots: comment-parser@1.4.5: {} - common-tags@1.8.2: {} - compare-versions@6.1.1: {} confbox@0.1.8: {} @@ -12560,9 +12255,6 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild-wasm@0.27.2: - optional: true - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -13259,11 +12951,6 @@ snapshots: flatted@3.3.3: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - format@0.2.2: {} formatly@0.3.0: @@ -13319,15 +13006,6 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -13713,12 +13391,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jest-worker@27.5.1: dependencies: '@types/node': 24.10.12 @@ -13987,8 +13659,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash.sortby@4.7.0: {} - lodash@4.17.23: {} log-update@6.1.0: @@ -14012,8 +13682,6 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 - lru-cache@10.4.3: {} - lru-cache@11.2.5: {} lru-cache@11.2.6: {} @@ -14601,8 +14269,6 @@ snapshots: minimist@1.2.8: {} - minipass@7.1.2: {} - minipass@7.1.3: {} minizlib@3.1.0: @@ -14791,8 +14457,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} - package-manager-detector@1.6.0: {} pako@0.2.9: {} @@ -14864,11 +14528,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -15019,8 +14678,6 @@ snapshots: prelude-ls@1.2.1: {} - pretty-bytes@6.1.1: {} - pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -15640,15 +15297,6 @@ snapshots: server-only@0.0.1: {} - serwist@9.5.4(browserslist@4.28.1)(typescript@5.9.3): - dependencies: - '@serwist/utils': 9.5.4(browserslist@4.28.1) - idb: 8.0.3 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - browserslist - sharp@0.33.5: dependencies: color: 4.2.3 @@ -15766,10 +15414,6 @@ snapshots: source-map@0.7.6: {} - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 - space-separated-tokens@1.1.5: {} space-separated-tokens@2.0.2: {} @@ -16054,10 +15698,6 @@ snapshots: dependencies: tldts: 7.0.17 - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -16491,8 +16131,6 @@ snapshots: web-vitals@5.1.0: {} - webidl-conversions@4.0.2: {} - webidl-conversions@8.0.1: {} webpack-sources@3.3.4: {} @@ -16544,12 +16182,6 @@ snapshots: tr46: 6.0.0 webidl-conversions: 8.0.1 - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -16561,18 +16193,6 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 4.2.3 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 From 110063871cf988d825fee834c25d6e2ded3006fc Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 4 Mar 2026 18:18:20 +0800 Subject: [PATCH 3/3] chore: clean up sw file (#32973) --- web/app/sw.ts | 58 --------------------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 web/app/sw.ts diff --git a/web/app/sw.ts b/web/app/sw.ts deleted file mode 100644 index e01ad21004..0000000000 --- a/web/app/sw.ts +++ /dev/null @@ -1,58 +0,0 @@ -/// -/// - -import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist' -import { defaultCache } from '@serwist/turbopack/worker' -import { Serwist } from 'serwist' -import { withLeadingSlash } from 'ufo' - -declare global { - // eslint-disable-next-line ts/consistent-type-definitions - interface WorkerGlobalScope extends SerwistGlobalConfig { - __SW_MANIFEST: (PrecacheEntry | string)[] | undefined - } -} - -declare const self: ServiceWorkerGlobalScope - -const scopePathname = new URL(self.registration.scope).pathname -const basePath = scopePathname.replace(/\/serwist\/$/, '').replace(/\/$/, '') -const offlineUrl = `${basePath}/_offline.html` - -const normalizeManifestUrl = (url: string): string => { - if (url.startsWith('/serwist/')) - return url.replace(/^\/serwist\//, '/') - - return withLeadingSlash(url) -} - -const manifest = self.__SW_MANIFEST?.map((entry) => { - if (typeof entry === 'string') - return normalizeManifestUrl(entry) - - return { - ...entry, - url: normalizeManifestUrl(entry.url), - } -}) - -const serwist = new Serwist({ - precacheEntries: manifest, - skipWaiting: true, - disableDevLogs: true, - clientsClaim: true, - navigationPreload: true, - runtimeCaching: defaultCache, - fallbacks: { - entries: [ - { - url: offlineUrl, - matcher({ request }) { - return request.destination === 'document' - }, - }, - ], - }, -}) - -serwist.addEventListeners()