diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx new file mode 100644 index 0000000000..7d27b20051 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx @@ -0,0 +1,93 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import CopyId from '../copy-id' + +const mockCopy = vi.fn() +let mockTranslationReturnsEmpty = false + +vi.mock('copy-to-clipboard', () => ({ + default: (...args: unknown[]) => mockCopy(...args), +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => mockTranslationReturnsEmpty ? '' : key, + }), + } +}) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent?: React.ReactNode }) => ( +
+
{popupContent}
+ {children} +
+ ), +})) + +describe('CopyId', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockTranslationReturnsEmpty = false + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should copy content after the debounced click handler runs', () => { + render() + + act(() => { + fireEvent.click(screen.getByText('tool-node-id')) + vi.advanceTimersByTime(100) + }) + + expect(mockCopy).toHaveBeenCalledWith('tool-node-id') + }) + + it('should toggle tooltip feedback between copy and copied states', () => { + render() + + expect(screen.getByTestId('tooltip-content')).toHaveTextContent(/embedded\.copy$/) + + act(() => { + fireEvent.click(screen.getByText('tool-node-id')) + vi.advanceTimersByTime(100) + }) + + expect(screen.getByTestId('tooltip-content')).toHaveTextContent(/embedded\.copied$/) + + act(() => { + fireEvent.mouseLeave(screen.getByText('tool-node-id').closest('.inline-flex')!) + vi.advanceTimersByTime(100) + }) + + expect(screen.getByTestId('tooltip-content')).toHaveTextContent(/embedded\.copy$/) + }) + + it('should stop click propagation from the wrapper container', () => { + const parentClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('tool-node-id').closest('.inline-flex')!) + + expect(parentClick).not.toHaveBeenCalled() + }) + + it('should fall back to an empty tooltip when translations resolve to empty strings', () => { + mockTranslationReturnsEmpty = true + + render() + + expect(screen.getByTestId('tooltip-content')).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 9d38d112e9..a1a39ce07d 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -19,6 +19,7 @@ import InputVarList from '../input-var-list' const mockUseAvailableVarList = vi.fn() const mockFetchNextPage = vi.fn() +let mockLanguage = 'en_US' const mockApps: App[] = [ { id: 'app-1', @@ -59,7 +60,7 @@ class MockMutationObserver { } vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ - useLanguage: () => 'en_US', + useLanguage: () => mockLanguage, useModelList: () => ({ data: [{ provider: 'openai', @@ -190,24 +191,33 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference onOpen, schema, defaultVarKindType, + filterVar, }: { onChange: (value: string[] | string, kind: ToolVarType) => void onOpen?: () => void schema?: { variable?: string } defaultVarKindType?: ToolVarType + filterVar?: (payload: { type: VarType }) => boolean }) => ( - +
+ + {filterVar && ( +
+ {`${String(filterVar({ type: VarType.file }))}:${String(filterVar({ type: VarType.arrayFile }))}:${String(filterVar({ type: VarType.string }))}`} +
+ )} +
), })) @@ -356,6 +366,7 @@ describe('InputVarList', () => { beforeEach(() => { vi.clearAllMocks() + mockLanguage = 'en_US' mockUseAvailableVarList.mockReturnValue({ availableVars: [{ nodeId: 'node-1', @@ -393,6 +404,9 @@ describe('InputVarList', () => { expect(screen.getByText('String')).toBeInTheDocument() expect(screen.getByText('Required')).toBeInTheDocument() expect(screen.getByText('query-tip')).toBeInTheDocument() + const availableVarConfig = mockUseAvailableVarList.mock.calls[0]?.[1] as { filterVar?: (payload: { type: VarType }) => boolean } + expect(availableVarConfig.filterVar?.({ type: VarType.secret })).toBe(true) + expect(availableVarConfig.filterVar?.({ type: VarType.file })).toBe(false) await user.type(screen.getByLabelText('workflow.nodes.http.insertVarPlaceholder'), 'hello') @@ -510,4 +524,82 @@ describe('InputVarList', () => { }, }) }) + + it('should render tool and select parameter labels and update existing picker values', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + renderInputVarList( + , + ) + + expect(screen.getByText('ToolSelector')).toBeInTheDocument() + expect(screen.getByText('Select')).toBeInTheDocument() + expect(screen.getByText('Files')).toBeInTheDocument() + expect(screen.getByTestId('filter-var')).toHaveTextContent('true:true:false') + + await user.click(screen.getByRole('button', { name: 'pick-choice' })) + + expect(onChange).toHaveBeenCalledWith({ + choice: { + type: ToolVarType.variable, + value: ['node-1', 'file'], + }, + }) + }) + + it('should fall back to en_US labels and tooltips for the active language and preserve constant picker defaults', async () => { + mockLanguage = 'zh_Hans' + const user = userEvent.setup() + const onChange = vi.fn() + + renderInputVarList( + , + ) + + expect(screen.getByText('Limit')).toBeInTheDocument() + expect(screen.getByText('Limit tooltip')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'pick-limit' })) + + expect(onChange).toHaveBeenCalledWith({ + limit: { + type: ToolVarType.constant, + value: '42', + }, + }) + }) }) diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/tool/components/context-generate-modal/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b8242e8eb4 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/__tests__/index.spec.tsx @@ -0,0 +1,554 @@ +import type { ContextGenerateModalHandle } from '../index' +import type { ContextGenerateResponse } from '@/service/debug' +import { act, fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import { NodeRunningStatus, VarType } from '@/app/components/workflow/types' +import ContextGenerateModal from '../index' + +const { + mockHandleFetchSuggestedQuestions, + mockAbortSuggestedQuestions, + mockResetSuggestions, + mockHandleReset, + mockHandleGenerate, + mockHandleModelChange, + mockHandleCompletionParamsChange, + mockHandleNodeDataUpdateWithSyncDraft, + mockSetInitShowLastRunTab, + mockSetPendingSingleRun, + mockLeftPanel, + mockRightPanel, +} = vi.hoisted(() => ({ + mockHandleFetchSuggestedQuestions: vi.fn(() => Promise.resolve()), + mockAbortSuggestedQuestions: vi.fn(), + mockResetSuggestions: vi.fn(), + mockHandleReset: vi.fn(), + mockHandleGenerate: vi.fn(), + mockHandleModelChange: vi.fn(), + mockHandleCompletionParamsChange: vi.fn(), + mockHandleNodeDataUpdateWithSyncDraft: vi.fn(), + mockSetInitShowLastRunTab: vi.fn(), + mockSetPendingSingleRun: vi.fn(), + mockLeftPanel: vi.fn(), + mockRightPanel: vi.fn(), +})) +let mockConfigsMap: { flowId?: string } | undefined = { flowId: 'flow-1' } + +type MockWorkflowNode = { + id: string + data: { + code_language: CodeLanguage + code: string + outputs?: { + result: { type: VarType, children: null } + } + variables?: Array<{ variable: string, value_selector: string[] | null }> + _singleRunningStatus?: NodeRunningStatus + } +} + +vi.mock('@/app/components/base/ui/dialog', () => ({ + Dialog: ({ + children, + onOpenChange, + }: { + children: React.ReactNode + onOpenChange?: (open: boolean) => void + }) => ( +
+ + + {children} +
+ ), + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogCloseButton: () => , +})) + +vi.mock('@/app/components/workflow/hooks-store', () => ({ + useHooksStore: (selector: (state: { configsMap: typeof mockConfigsMap }) => unknown) => selector({ + configsMap: mockConfigsMap, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-node-data-update', () => ({ + useNodeDataUpdate: () => ({ + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, + }), +})) + +const workflowNodes: MockWorkflowNode[] = [ + { + id: 'code-node', + data: { + code_language: CodeLanguage.python3, + code: 'print("hello")', + outputs: { + result: { type: VarType.string, children: null }, + }, + variables: [{ variable: 'result', value_selector: ['result'] }], + _singleRunningStatus: NodeRunningStatus.NotStart, + }, + }, +] + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { nodes: typeof workflowNodes }) => unknown) => selector({ nodes: workflowNodes }), + useWorkflowStore: () => ({ + getState: () => ({ + setInitShowLastRunTab: mockSetInitShowLastRunTab, + setPendingSingleRun: mockSetPendingSingleRun, + }), + }), +})) + +vi.mock('../hooks/use-resizable-panels', () => ({ + default: () => ({ + rightContainerRef: { current: null }, + resolvedCodePanelHeight: 240, + handleResizeStart: vi.fn(), + }), +})) + +const defaultCurrentVersion: ContextGenerateResponse = { + variables: [{ variable: 'result', value_selector: ['result'] }], + outputs: { result: { type: 'string' } }, + code_language: CodeLanguage.javascript, + code: 'return result', + message: '', + error: '', +} + +const mockUseContextGenerate = vi.fn() +vi.mock('../hooks/use-context-generate', async () => { + const actual = await vi.importActual('../hooks/use-context-generate') + return { + ...actual, + default: (...args: unknown[]) => mockUseContextGenerate(...args), + } +}) + +vi.mock('../components/left-panel', () => ({ + default: (props: { + onReset?: () => void + }) => { + mockLeftPanel(props) + return ( +
+ +
+ ) + }, +})) + +vi.mock('../components/right-panel', () => ({ + default: (props: { + onRun?: () => void + onApply?: () => void + onClose?: () => void + }) => { + mockRightPanel(props) + return ( +
+ + + +
+ ) + }, +})) + +type MockContextGenerateReturn = { + current: ContextGenerateResponse | null + currentVersionIndex: number + setCurrentVersionIndex: ReturnType + promptMessages: Array<{ id?: string, role: string, content: string }> + inputValue: string + setInputValue: ReturnType + suggestedQuestions: string[] + hasFetchedSuggestions: boolean + isGenerating: boolean + model: { + provider: string + name: string + mode: string + completion_params: Record + } + handleModelChange: typeof mockHandleModelChange + handleCompletionParamsChange: typeof mockHandleCompletionParamsChange + handleGenerate: typeof mockHandleGenerate + handleReset: typeof mockHandleReset + handleFetchSuggestedQuestions: typeof mockHandleFetchSuggestedQuestions + abortSuggestedQuestions: typeof mockAbortSuggestedQuestions + resetSuggestions: typeof mockResetSuggestions + defaultAssistantMessage: string + versionOptions: Array<{ index: number, label: string }> + currentVersionLabel: string + isInitView: boolean + availableVars: unknown[] + availableNodes: unknown[] +} + +const createHookReturn = (overrides: Partial = {}): MockContextGenerateReturn => ({ + current: defaultCurrentVersion, + currentVersionIndex: 0, + setCurrentVersionIndex: vi.fn(), + promptMessages: [], + inputValue: '', + setInputValue: vi.fn(), + suggestedQuestions: [], + hasFetchedSuggestions: true, + isGenerating: false, + model: { + provider: 'openai', + name: 'gpt-4o', + mode: 'chat', + completion_params: {}, + }, + handleModelChange: mockHandleModelChange, + handleCompletionParamsChange: mockHandleCompletionParamsChange, + handleGenerate: mockHandleGenerate, + handleReset: mockHandleReset, + handleFetchSuggestedQuestions: mockHandleFetchSuggestedQuestions, + abortSuggestedQuestions: mockAbortSuggestedQuestions, + resetSuggestions: mockResetSuggestions, + defaultAssistantMessage: 'Default assistant message', + versionOptions: [{ index: 0, label: 'Version 1' }], + currentVersionLabel: 'Version 1', + isInitView: false, + availableVars: [], + availableNodes: [], + ...overrides, +}) + +describe('ContextGenerateModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfigsMap = { flowId: 'flow-1' } + mockUseContextGenerate.mockReturnValue(createHookReturn()) + workflowNodes[0].data = { + code_language: CodeLanguage.python3, + code: 'print("hello")', + outputs: { + result: { type: VarType.string, children: null }, + }, + variables: [{ variable: 'result', value_selector: ['result'] }], + _singleRunningStatus: NodeRunningStatus.NotStart, + } + }) + + it('should expose onOpen through the imperative ref and pass fallback data to the right panel', async () => { + const ref = { current: null } as React.MutableRefObject + + render( + , + ) + + await act(async () => { + ref.current?.onOpen() + }) + + expect(mockHandleFetchSuggestedQuestions).toHaveBeenCalledTimes(1) + expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({ + displayVersion: defaultCurrentVersion, + canApply: true, + canRun: true, + })) + }) + + it('should apply generated code to the node and close when apply is triggered', () => { + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'apply' })) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({ + id: 'code-node', + data: expect.objectContaining({ + code_language: CodeLanguage.javascript, + code: 'return result', + }), + }), { sync: true }) + expect(mockAbortSuggestedQuestions).toHaveBeenCalled() + expect(mockResetSuggestions).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + it('should fall back to pending single run when there is no internal view callback', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'run' })) + + expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true) + expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ + nodeId: 'code-node', + action: 'run', + }) + }) + + it('should delegate run to the internal view flow when provided', () => { + const onOpenInternalViewAndRun = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'run' })) + + expect(onOpenInternalViewAndRun).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockSetPendingSingleRun).not.toHaveBeenCalled() + }) + + it('should render fallback code data when there is no generated version and expose non-runnable empty states', () => { + mockUseContextGenerate.mockReturnValue(createHookReturn({ + current: null, + isInitView: false, + })) + + render( + , + ) + + expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({ + displayVersion: expect.objectContaining({ + code: 'print("hello")', + }), + canApply: false, + canRun: true, + })) + }) + + it('should handle reset and dialog close flows by refetching suggestions and clearing transient state', () => { + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'left-reset' })) + fireEvent.click(screen.getByRole('button', { name: 'dialog-close' })) + + expect(mockAbortSuggestedQuestions).toHaveBeenCalledTimes(2) + expect(mockHandleReset).toHaveBeenCalledTimes(1) + expect(mockResetSuggestions).toHaveBeenCalledTimes(2) + expect(mockHandleFetchSuggestedQuestions).toHaveBeenCalledWith({ force: true }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should treat running code nodes as running and no-op run when the code node id is missing', () => { + workflowNodes[0].data._singleRunningStatus = NodeRunningStatus.Running + mockUseContextGenerate.mockReturnValue(createHookReturn({ + current: null, + })) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'run' })) + + expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({ + isRunning: false, + canRun: false, + canApply: false, + })) + expect(mockSetPendingSingleRun).not.toHaveBeenCalled() + }) + + it('should build fallback data from the stored code node when outputs are missing and the flow id is absent', () => { + mockConfigsMap = undefined + workflowNodes[0].data = { + code_language: CodeLanguage.python3, + code: '', + outputs: undefined, + variables: [{ variable: 'result', value_selector: null }], + _singleRunningStatus: NodeRunningStatus.NotStart, + } + mockUseContextGenerate.mockReturnValue(createHookReturn({ + current: null, + isInitView: false, + })) + + render( + , + ) + + expect(mockUseContextGenerate).toHaveBeenCalledWith(expect.objectContaining({ + storageKey: 'unknown-tool-node-query', + })) + expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({ + displayVersion: expect.objectContaining({ + code: '', + outputs: {}, + variables: [{ variable: 'result', value_selector: [] }], + }), + canRun: false, + })) + }) + + it('should keep the modal open when the dialog open state stays true and expose the init-view right panel state', () => { + const onClose = vi.fn() + mockUseContextGenerate.mockReturnValue(createHookReturn({ + current: null, + isInitView: true, + })) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'dialog-open' })) + + expect(onClose).not.toHaveBeenCalled() + expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({ + isInitView: true, + displayVersion: null, + })) + }) + + it('should skip applying when current data is missing and normalize unsupported output types on apply', () => { + const onClose = vi.fn() + mockUseContextGenerate.mockReturnValueOnce(createHookReturn({ + current: null, + isInitView: false, + })) + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'apply' })) + expect(mockHandleNodeDataUpdateWithSyncDraft).not.toHaveBeenCalled() + + mockUseContextGenerate.mockReturnValue(createHookReturn({ + current: { + ...defaultCurrentVersion, + outputs: { + custom: { type: 'unsupported-type' }, + }, + variables: [{ variable: 'custom', value_selector: null as unknown as string[] }], + }, + isInitView: false, + })) + + rerender( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'apply' })) + + expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({ + id: 'code-node', + data: expect.objectContaining({ + outputs: { + custom: { + type: VarType.string, + children: null, + }, + }, + variables: [{ variable: 'custom', value_selector: [] }], + }), + }), { sync: true }) + }) + + it('should run without applying when no current generated version exists', () => { + const onOpenInternalViewAndRun = vi.fn() + mockUseContextGenerate.mockReturnValue(createHookReturn({ + current: null, + isInitView: false, + })) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'run' })) + + expect(mockHandleNodeDataUpdateWithSyncDraft).not.toHaveBeenCalled() + expect(onOpenInternalViewAndRun).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/__tests__/chat-view.spec.tsx b/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/__tests__/chat-view.spec.tsx new file mode 100644 index 0000000000..28794a55cd --- /dev/null +++ b/web/app/components/workflow/nodes/tool/components/context-generate-modal/components/__tests__/chat-view.spec.tsx @@ -0,0 +1,170 @@ +import type { ContextGenerateChatMessage } from '../../hooks/use-context-generate' +import type { VersionOption } from '../../types' +import type { WorkflowVariableBlockType } from '@/app/components/base/prompt-editor/types' +import type { Model } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import ChatView from '../chat-view' + +const mockPromptEditor = vi.fn() + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: (props: { + value: string + editable: boolean + onChange: (value: string) => void + onEnter: () => void + }) => { + mockPromptEditor(props) + return ( +
+