mirror of https://github.com/langgenius/dify.git
1976 lines
59 KiB
TypeScript
1976 lines
59 KiB
TypeScript
import type { WorkflowToolModalPayload } from './index'
|
|
import type { WorkflowToolProviderResponse } from '@/app/components/tools/types'
|
|
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import * as React from 'react'
|
|
import { InputVarType, VarType } from '@/app/components/workflow/types'
|
|
import WorkflowToolConfigureButton from './configure-button'
|
|
import WorkflowToolAsModal from './index'
|
|
import MethodSelector from './method-selector'
|
|
|
|
// Mock Next.js navigation
|
|
const mockPush = vi.fn()
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({
|
|
push: mockPush,
|
|
replace: vi.fn(),
|
|
prefetch: vi.fn(),
|
|
}),
|
|
usePathname: () => '/app/workflow-app-id',
|
|
useSearchParams: () => new URLSearchParams(),
|
|
}))
|
|
|
|
// Mock app context
|
|
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
|
|
vi.mock('@/context/app-context', () => ({
|
|
useAppContext: () => ({
|
|
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
|
}),
|
|
}))
|
|
|
|
// Mock API services - only mock external services
|
|
const mockFetchWorkflowToolDetailByAppID = vi.fn()
|
|
const mockCreateWorkflowToolProvider = vi.fn()
|
|
const mockSaveWorkflowToolProvider = vi.fn()
|
|
vi.mock('@/service/tools', () => ({
|
|
fetchWorkflowToolDetailByAppID: (...args: unknown[]) => mockFetchWorkflowToolDetailByAppID(...args),
|
|
createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args),
|
|
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
|
}))
|
|
|
|
// Mock invalidate workflow tools hook
|
|
const mockInvalidateAllWorkflowTools = vi.fn()
|
|
vi.mock('@/service/use-tools', () => ({
|
|
useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools,
|
|
}))
|
|
|
|
// Mock Toast - need to verify notification calls
|
|
const mockToastNotify = vi.fn()
|
|
vi.mock('@/app/components/base/toast', () => ({
|
|
default: {
|
|
notify: (options: { type: string, message: string }) => mockToastNotify(options),
|
|
},
|
|
}))
|
|
|
|
// Mock useTags hook used by LabelSelector - returns empty tags for testing
|
|
vi.mock('@/app/components/plugins/hooks', () => ({
|
|
useTags: () => ({
|
|
tags: [
|
|
{ name: 'label1', label: 'Label 1' },
|
|
{ name: 'label2', label: 'Label 2' },
|
|
],
|
|
}),
|
|
}))
|
|
|
|
// Mock Drawer - simplified for testing, preserves behavior
|
|
vi.mock('@/app/components/base/drawer-plus', () => ({
|
|
default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => {
|
|
if (!isShow)
|
|
return null
|
|
return (
|
|
<div data-testid="drawer" role="dialog">
|
|
<div data-testid="drawer-title">{title}</div>
|
|
<button data-testid="drawer-close" onClick={onHide}>Close</button>
|
|
{body}
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mock EmojiPicker - simplified for testing
|
|
vi.mock('@/app/components/base/emoji-picker', () => ({
|
|
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
|
|
<div data-testid="emoji-picker">
|
|
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#f0f0f0')}>Select Emoji</button>
|
|
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock AppIcon - simplified for testing
|
|
vi.mock('@/app/components/base/app-icon', () => ({
|
|
default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => (
|
|
<div data-testid="app-icon" onClick={onClick} data-icon={icon} data-background={background}>
|
|
{icon}
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock LabelSelector - simplified for testing
|
|
vi.mock('@/app/components/tools/labels/selector', () => ({
|
|
default: ({ value, onChange }: { value: string[], onChange: (labels: string[]) => void }) => (
|
|
<div data-testid="label-selector">
|
|
<span data-testid="label-values">{value.join(',')}</span>
|
|
<button data-testid="add-label" onClick={() => onChange([...value, 'new-label'])}>Add Label</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock PortalToFollowElem for dropdown tests
|
|
let mockPortalOpenState = false
|
|
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
PortalToFollowElem: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => {
|
|
mockPortalOpenState = open
|
|
return (
|
|
<div data-testid="portal-elem" data-open={open} onClick={() => onOpenChange(!open)}>
|
|
{children}
|
|
</div>
|
|
)
|
|
},
|
|
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick: () => void, className?: string }) => (
|
|
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
|
{children}
|
|
</div>
|
|
),
|
|
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
|
if (!mockPortalOpenState)
|
|
return null
|
|
return <div data-testid="portal-content" className={className}>{children}</div>
|
|
},
|
|
}))
|
|
|
|
// Test data factories
|
|
const createMockEmoji = (overrides = {}) => ({
|
|
content: '🔧',
|
|
background: '#ffffff',
|
|
...overrides,
|
|
})
|
|
|
|
const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
|
variable: 'test_var',
|
|
label: 'Test Variable',
|
|
type: InputVarType.textInput,
|
|
required: true,
|
|
max_length: 100,
|
|
options: [],
|
|
...overrides,
|
|
} as InputVar)
|
|
|
|
const createMockVariable = (overrides: Partial<Variable> = {}): Variable => ({
|
|
variable: 'output_var',
|
|
value_type: 'string',
|
|
...overrides,
|
|
} as Variable)
|
|
|
|
const createMockWorkflowToolDetail = (overrides: Partial<WorkflowToolProviderResponse> = {}): WorkflowToolProviderResponse => ({
|
|
workflow_app_id: 'workflow-app-123',
|
|
workflow_tool_id: 'workflow-tool-456',
|
|
label: 'Test Tool',
|
|
name: 'test_tool',
|
|
icon: createMockEmoji(),
|
|
description: 'A test workflow tool',
|
|
synced: true,
|
|
tool: {
|
|
author: 'test-author',
|
|
name: 'test_tool',
|
|
label: { en_US: 'Test Tool', zh_Hans: '测试工具' },
|
|
description: { en_US: 'Test description', zh_Hans: '测试描述' },
|
|
labels: ['label1', 'label2'],
|
|
parameters: [
|
|
{
|
|
name: 'test_var',
|
|
label: { en_US: 'Test Variable', zh_Hans: '测试变量' },
|
|
human_description: { en_US: 'A test variable', zh_Hans: '测试变量' },
|
|
type: 'string',
|
|
form: 'llm',
|
|
llm_description: 'Test variable description',
|
|
required: true,
|
|
default: '',
|
|
},
|
|
],
|
|
output_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output_var: {
|
|
type: 'string',
|
|
description: 'Output description',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
privacy_policy: 'https://example.com/privacy',
|
|
...overrides,
|
|
})
|
|
|
|
const createDefaultConfigureButtonProps = (overrides = {}) => ({
|
|
disabled: false,
|
|
published: false,
|
|
detailNeedUpdate: false,
|
|
workflowAppId: 'workflow-app-123',
|
|
icon: createMockEmoji(),
|
|
name: 'Test Workflow',
|
|
description: 'Test workflow description',
|
|
inputs: [createMockInputVar()],
|
|
outputs: [createMockVariable()],
|
|
handlePublish: vi.fn().mockResolvedValue(undefined),
|
|
onRefreshData: vi.fn(),
|
|
...overrides,
|
|
})
|
|
|
|
const createDefaultModalPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({
|
|
icon: createMockEmoji(),
|
|
label: 'Test Tool',
|
|
name: 'test_tool',
|
|
description: 'Test description',
|
|
parameters: [
|
|
{
|
|
name: 'param1',
|
|
description: 'Parameter 1',
|
|
form: 'llm',
|
|
required: true,
|
|
type: 'string',
|
|
},
|
|
],
|
|
outputParameters: [
|
|
{
|
|
name: 'output1',
|
|
description: 'Output 1',
|
|
},
|
|
],
|
|
labels: ['label1'],
|
|
privacy_policy: '',
|
|
workflow_app_id: 'workflow-app-123',
|
|
...overrides,
|
|
})
|
|
|
|
// ============================================================================
|
|
// WorkflowToolConfigureButton Tests
|
|
// ============================================================================
|
|
describe('WorkflowToolConfigureButton', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPortalOpenState = false
|
|
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail())
|
|
})
|
|
|
|
// Rendering Tests (REQUIRED)
|
|
describe('Rendering', () => {
|
|
it('should render without crashing', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render configure required badge when not published', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: false })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('workflow.common.configureRequired')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not render configure required badge when published', async () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('workflow.common.configureRequired')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should render disabled state with cursor-not-allowed', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ disabled: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
const container = document.querySelector('.cursor-not-allowed')
|
|
expect(container).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render disabledReason when provided', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({
|
|
disabledReason: 'Please save the workflow first',
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Please save the workflow first')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render loading state when published and fetching details', async () => {
|
|
// Arrange
|
|
mockFetchWorkflowToolDetailByAppID.mockImplementation(() => new Promise(() => {})) // Never resolves
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
const loadingElement = document.querySelector('.pt-2')
|
|
expect(loadingElement).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should render configure and manage buttons when published', async () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
|
expect(screen.getByText('workflow.common.manageInTools')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should render different UI for non-workspace manager', () => {
|
|
// Arrange
|
|
mockIsCurrentWorkspaceManager.mockReturnValue(false)
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
const textElement = screen.getByText('workflow.common.workflowAsTool')
|
|
expect(textElement).toHaveClass('text-text-tertiary')
|
|
})
|
|
})
|
|
|
|
// Props Testing (REQUIRED)
|
|
describe('Props', () => {
|
|
it('should handle all required props', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act & Assert - should not throw
|
|
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle undefined inputs and outputs', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({
|
|
inputs: undefined,
|
|
outputs: undefined,
|
|
})
|
|
|
|
// Act & Assert
|
|
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle empty inputs and outputs arrays', () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({
|
|
inputs: [],
|
|
outputs: [],
|
|
})
|
|
|
|
// Act & Assert
|
|
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should call handlePublish when updating workflow tool', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const handlePublish = vi.fn().mockResolvedValue(undefined)
|
|
mockSaveWorkflowToolProvider.mockResolvedValue({})
|
|
const props = createDefaultConfigureButtonProps({ published: true, handlePublish })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
|
})
|
|
await user.click(screen.getByText('workflow.common.configure'))
|
|
|
|
// Fill required fields and save
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
const saveButton = screen.getByText('common.operation.save')
|
|
await user.click(saveButton)
|
|
|
|
// Confirm in modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
|
|
})
|
|
await user.click(screen.getByText('common.operation.confirm'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(handlePublish).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
// State Management Tests
|
|
describe('State Management', () => {
|
|
it('should fetch detail when published and mount', async () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledWith('workflow-app-123')
|
|
})
|
|
})
|
|
|
|
it('should refetch detail when detailNeedUpdate changes to true', async () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: true, detailNeedUpdate: false })
|
|
|
|
// Act
|
|
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// Rerender with detailNeedUpdate true
|
|
rerender(<WorkflowToolConfigureButton {...props} detailNeedUpdate={true} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
it('should toggle modal visibility', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Click to open modal
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should not open modal when disabled', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = createDefaultConfigureButtonProps({ disabled: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should not open modal when published (use configure button instead)', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
|
})
|
|
|
|
// Click the main area (should not open modal)
|
|
const mainArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(mainArea!)
|
|
|
|
// Should not open modal from main click
|
|
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
|
|
|
|
// Click configure button
|
|
await user.click(screen.getByText('workflow.common.configure'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Memoization Tests
|
|
describe('Memoization - outdated detection', () => {
|
|
it('should detect outdated when parameter count differs', async () => {
|
|
// Arrange
|
|
const detail = createMockWorkflowToolDetail()
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
|
const props = createDefaultConfigureButtonProps({
|
|
published: true,
|
|
inputs: [
|
|
createMockInputVar({ variable: 'test_var' }),
|
|
createMockInputVar({ variable: 'extra_var' }),
|
|
],
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert - should show outdated warning
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should detect outdated when parameter not found', async () => {
|
|
// Arrange
|
|
const detail = createMockWorkflowToolDetail()
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
|
const props = createDefaultConfigureButtonProps({
|
|
published: true,
|
|
inputs: [createMockInputVar({ variable: 'different_var' })],
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should detect outdated when required property differs', async () => {
|
|
// Arrange
|
|
const detail = createMockWorkflowToolDetail()
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
|
const props = createDefaultConfigureButtonProps({
|
|
published: true,
|
|
inputs: [createMockInputVar({ variable: 'test_var', required: false })], // Detail has required: true
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.workflowAsToolTip')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should not show outdated when parameters match', async () => {
|
|
// Arrange
|
|
const detail = createMockWorkflowToolDetail()
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
|
const props = createDefaultConfigureButtonProps({
|
|
published: true,
|
|
inputs: [createMockInputVar({ variable: 'test_var', required: true, type: InputVarType.textInput })],
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByText('workflow.common.workflowAsToolTip')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// User Interactions Tests
|
|
describe('User Interactions', () => {
|
|
it('should navigate to tools page when manage button clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.manageInTools')).toBeInTheDocument()
|
|
})
|
|
|
|
await user.click(screen.getByText('workflow.common.manageInTools'))
|
|
|
|
// Assert
|
|
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
|
|
})
|
|
|
|
it('should create workflow tool provider on first publish', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Open modal
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
// Fill in required name field
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'my_tool')
|
|
|
|
// Click save
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockCreateWorkflowToolProvider).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should show success toast after creating workflow tool', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'my_tool')
|
|
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
|
type: 'success',
|
|
message: 'common.api.actionSuccess',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should show error toast when create fails', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockCreateWorkflowToolProvider.mockRejectedValue(new Error('Create failed'))
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'my_tool')
|
|
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: 'Create failed',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should call onRefreshData after successful create', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onRefreshData = vi.fn()
|
|
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
|
const props = createDefaultConfigureButtonProps({ onRefreshData })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'my_tool')
|
|
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(onRefreshData).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should invalidate all workflow tools after successful create', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
|
const props = createDefaultConfigureButtonProps()
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'my_tool')
|
|
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Edge Cases (REQUIRED)
|
|
describe('Edge Cases', () => {
|
|
it('should handle API returning undefined', async () => {
|
|
// Arrange - API returns undefined (simulating empty response or handled error)
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(undefined)
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert - should not crash and wait for API call
|
|
await waitFor(() => {
|
|
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
|
|
})
|
|
|
|
// Component should still render without crashing
|
|
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle rapid publish/unpublish state changes', async () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: false })
|
|
|
|
// Act
|
|
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Toggle published state rapidly
|
|
await act(async () => {
|
|
rerender(<WorkflowToolConfigureButton {...props} published={true} />)
|
|
})
|
|
await act(async () => {
|
|
rerender(<WorkflowToolConfigureButton {...props} published={false} />)
|
|
})
|
|
await act(async () => {
|
|
rerender(<WorkflowToolConfigureButton {...props} published={true} />)
|
|
})
|
|
|
|
// Assert - should not crash
|
|
expect(mockFetchWorkflowToolDetailByAppID).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle detail with empty parameters', async () => {
|
|
// Arrange
|
|
const detail = createMockWorkflowToolDetail()
|
|
detail.tool.parameters = []
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
|
const props = createDefaultConfigureButtonProps({ published: true, inputs: [] })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should handle detail with undefined output_schema', async () => {
|
|
// Arrange
|
|
const detail = createMockWorkflowToolDetail()
|
|
// @ts-expect-error - testing undefined case
|
|
detail.tool.output_schema = undefined
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(detail)
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act & Assert
|
|
expect(() => render(<WorkflowToolConfigureButton {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle paragraph type input conversion', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = createDefaultConfigureButtonProps({
|
|
inputs: [createMockInputVar({ variable: 'test_var', type: InputVarType.paragraph })],
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
// Assert - should render without error
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Accessibility Tests
|
|
describe('Accessibility', () => {
|
|
it('should have accessible buttons when published', async () => {
|
|
// Arrange
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
const buttons = screen.getAllByRole('button')
|
|
expect(buttons.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
it('should disable configure button when not workspace manager', async () => {
|
|
// Arrange
|
|
mockIsCurrentWorkspaceManager.mockReturnValue(false)
|
|
const props = createDefaultConfigureButtonProps({ published: true })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
const configureButton = screen.getByText('workflow.common.configure')
|
|
expect(configureButton).toBeDisabled()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// WorkflowToolAsModal Tests
|
|
// ============================================================================
|
|
describe('WorkflowToolAsModal', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPortalOpenState = false
|
|
})
|
|
|
|
// Rendering Tests (REQUIRED)
|
|
describe('Rendering', () => {
|
|
it('should render drawer with correct title', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('drawer-title')).toHaveTextContent('workflow.common.workflowAsTool')
|
|
})
|
|
|
|
it('should render name input field', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render name for tool call input', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render description textarea', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render tool input table', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.toolInput.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render tool output table', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.toolOutput.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render reserved output parameters', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('text')).toBeInTheDocument()
|
|
expect(screen.getByText('files')).toBeInTheDocument()
|
|
expect(screen.getByText('json')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render label selector', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('label-selector')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render privacy policy input', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render delete button when editing and onRemove provided', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: false,
|
|
payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }),
|
|
onHide: vi.fn(),
|
|
onRemove: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not render delete button when adding', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
onRemove: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// Props Testing (REQUIRED)
|
|
describe('Props', () => {
|
|
it('should initialize state from payload', () => {
|
|
// Arrange
|
|
const payload = createDefaultModalPayload({
|
|
label: 'Custom Label',
|
|
name: 'custom_name',
|
|
description: 'Custom description',
|
|
})
|
|
const props = {
|
|
isAdd: true,
|
|
payload,
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByDisplayValue('Custom Label')).toBeInTheDocument()
|
|
expect(screen.getByDisplayValue('custom_name')).toBeInTheDocument()
|
|
expect(screen.getByDisplayValue('Custom description')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should pass labels to label selector', () => {
|
|
// Arrange
|
|
const payload = createDefaultModalPayload({ labels: ['tag1', 'tag2'] })
|
|
const props = {
|
|
isAdd: true,
|
|
payload,
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('label-values')).toHaveTextContent('tag1,tag2')
|
|
})
|
|
})
|
|
|
|
// State Management Tests
|
|
describe('State Management', () => {
|
|
it('should update label state on input change', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ label: '' }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
|
|
await user.type(labelInput, 'New Label')
|
|
|
|
// Assert
|
|
expect(labelInput).toHaveValue('New Label')
|
|
})
|
|
|
|
it('should update name state on input change', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ name: '' }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'new_name')
|
|
|
|
// Assert
|
|
expect(nameInput).toHaveValue('new_name')
|
|
})
|
|
|
|
it('should update description state on textarea change', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ description: '' }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder')
|
|
await user.type(descInput, 'New description')
|
|
|
|
// Assert
|
|
expect(descInput).toHaveValue('New description')
|
|
})
|
|
|
|
it('should show emoji picker on icon click', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const iconButton = screen.getByTestId('app-icon')
|
|
await user.click(iconButton)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should update emoji on selection', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Open emoji picker
|
|
const iconButton = screen.getByTestId('app-icon')
|
|
await user.click(iconButton)
|
|
|
|
// Select emoji
|
|
await user.click(screen.getByTestId('select-emoji'))
|
|
|
|
// Assert
|
|
const updatedIcon = screen.getByTestId('app-icon')
|
|
expect(updatedIcon).toHaveAttribute('data-icon', '🚀')
|
|
expect(updatedIcon).toHaveAttribute('data-background', '#f0f0f0')
|
|
})
|
|
|
|
it('should close emoji picker on close button', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
const iconButton = screen.getByTestId('app-icon')
|
|
await user.click(iconButton)
|
|
|
|
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
|
|
|
await user.click(screen.getByTestId('close-emoji-picker'))
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('emoji-picker')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should update labels when label selector changes', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ labels: ['initial'] }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByTestId('add-label'))
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('label-values')).toHaveTextContent('initial,new-label')
|
|
})
|
|
|
|
it('should update privacy policy on input change', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ privacy_policy: '' }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder')
|
|
await user.type(privacyInput, 'https://example.com/privacy')
|
|
|
|
// Assert
|
|
expect(privacyInput).toHaveValue('https://example.com/privacy')
|
|
})
|
|
})
|
|
|
|
// User Interactions Tests
|
|
describe('User Interactions', () => {
|
|
it('should call onHide when cancel button clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onHide = vi.fn()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide,
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.cancel'))
|
|
|
|
// Assert
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onHide when drawer close button clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onHide = vi.fn()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide,
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByTestId('drawer-close'))
|
|
|
|
// Assert
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onRemove when delete button clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onRemove = vi.fn()
|
|
const props = {
|
|
isAdd: false,
|
|
payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }),
|
|
onHide: vi.fn(),
|
|
onRemove,
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.delete'))
|
|
|
|
// Assert
|
|
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onCreate when save clicked in add mode', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onCreate = vi.fn()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
onCreate,
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
|
|
name: 'test_tool',
|
|
workflow_app_id: 'workflow-app-123',
|
|
}))
|
|
})
|
|
|
|
it('should show confirm modal when save clicked in edit mode', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: false,
|
|
payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }),
|
|
onHide: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should call onSave after confirm in edit mode', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onSave = vi.fn()
|
|
const props = {
|
|
isAdd: false,
|
|
payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }),
|
|
onHide: vi.fn(),
|
|
onSave,
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
await user.click(screen.getByText('common.operation.confirm'))
|
|
|
|
// Assert
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
workflow_tool_id: 'tool-123',
|
|
}))
|
|
})
|
|
|
|
it('should update parameter description on input', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({
|
|
parameters: [{
|
|
name: 'param1',
|
|
description: '', // Start with empty description
|
|
form: 'llm',
|
|
required: true,
|
|
type: 'string',
|
|
}],
|
|
}),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const descInput = screen.getByPlaceholderText('tools.createTool.toolInput.descriptionPlaceholder')
|
|
await user.type(descInput, 'New parameter description')
|
|
|
|
// Assert
|
|
expect(descInput).toHaveValue('New parameter description')
|
|
})
|
|
})
|
|
|
|
// Validation Tests
|
|
describe('Validation', () => {
|
|
it('should show error when label is empty', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ label: '' }),
|
|
onHide: vi.fn(),
|
|
onCreate: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: expect.any(String),
|
|
})
|
|
})
|
|
|
|
it('should show error when name is empty', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ label: 'Test', name: '' }),
|
|
onHide: vi.fn(),
|
|
onCreate: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: expect.any(String),
|
|
})
|
|
})
|
|
|
|
it('should show validation error for invalid name format', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ name: '' }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'invalid name with spaces')
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.nameForToolCallTip')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should accept valid name format', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ name: '' }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'valid_name_123')
|
|
|
|
// Assert
|
|
expect(screen.queryByText('tools.createTool.nameForToolCallTip')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// Edge Cases (REQUIRED)
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty parameters array', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ parameters: [] }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act & Assert
|
|
expect(() => render(<WorkflowToolAsModal {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle empty output parameters', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({ outputParameters: [] }),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act & Assert
|
|
expect(() => render(<WorkflowToolAsModal {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle parameter with __image name specially', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({
|
|
parameters: [{
|
|
name: '__image',
|
|
description: 'Image parameter',
|
|
form: 'llm',
|
|
required: true,
|
|
type: 'file',
|
|
}],
|
|
}),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert - __image should show method as text, not selector
|
|
expect(screen.getByText('tools.createTool.toolInput.methodParameter')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show warning for reserved output parameter name collision', () => {
|
|
// Arrange
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload({
|
|
outputParameters: [{
|
|
name: 'text', // Collides with reserved
|
|
description: 'Custom text output',
|
|
type: VarType.string,
|
|
}],
|
|
}),
|
|
onHide: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
|
|
// Assert - should show both reserved and custom with warning icon
|
|
const textElements = screen.getAllByText('text')
|
|
expect(textElements.length).toBe(2)
|
|
})
|
|
|
|
it('should handle undefined onSave gracefully', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: false,
|
|
payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }),
|
|
onHide: vi.fn(),
|
|
// onSave is undefined
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Show confirm modal
|
|
await waitFor(() => {
|
|
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
|
|
})
|
|
|
|
// Assert - should not crash
|
|
await user.click(screen.getByText('common.operation.confirm'))
|
|
})
|
|
|
|
it('should handle undefined onCreate gracefully', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: true,
|
|
payload: createDefaultModalPayload(),
|
|
onHide: vi.fn(),
|
|
// onCreate is undefined
|
|
}
|
|
|
|
// Act & Assert - should not crash
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
})
|
|
|
|
it('should close confirm modal on close button', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
isAdd: false,
|
|
payload: createDefaultModalPayload({ workflow_tool_id: 'tool-123' }),
|
|
onHide: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<WorkflowToolAsModal {...props} />)
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
|
|
})
|
|
|
|
// Click cancel in confirm modal
|
|
const cancelButtons = screen.getAllByText('common.operation.cancel')
|
|
await user.click(cancelButtons[cancelButtons.length - 1])
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('tools.createTool.confirmTitle')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// MethodSelector Tests
|
|
// ============================================================================
|
|
describe('MethodSelector', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPortalOpenState = false
|
|
})
|
|
|
|
// Rendering Tests (REQUIRED)
|
|
describe('Rendering', () => {
|
|
it('should render without crashing', () => {
|
|
// Arrange
|
|
const props = {
|
|
value: 'llm',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should display parameter method text when value is llm', () => {
|
|
// Arrange
|
|
const props = {
|
|
value: 'llm',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.toolInput.methodParameter')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should display setting method text when value is form', () => {
|
|
// Arrange
|
|
const props = {
|
|
value: 'form',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.toolInput.methodSetting')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should display setting method text when value is undefined', () => {
|
|
// Arrange
|
|
const props = {
|
|
value: undefined,
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('tools.createTool.toolInput.methodSetting')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// User Interactions Tests
|
|
describe('User Interactions', () => {
|
|
it('should open dropdown on trigger click', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
value: 'llm',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should call onChange with llm when parameter option clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onChange = vi.fn()
|
|
const props = {
|
|
value: 'form',
|
|
onChange,
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
|
|
const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0]
|
|
await user.click(paramOption)
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith('llm')
|
|
})
|
|
|
|
it('should call onChange with form when setting option clicked', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const onChange = vi.fn()
|
|
const props = {
|
|
value: 'llm',
|
|
onChange,
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
|
|
const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting')
|
|
await user.click(settingOption)
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith('form')
|
|
})
|
|
|
|
it('should toggle dropdown state on multiple clicks', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
value: 'llm',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
|
|
// First click - open
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
|
|
|
// Second click - close
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// Props Tests (REQUIRED)
|
|
describe('Props', () => {
|
|
it('should show check icon for selected llm value', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
value: 'llm',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert - the first option (llm) should have a check icon container
|
|
const content = screen.getByTestId('portal-content')
|
|
expect(content).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show check icon for selected form value', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const props = {
|
|
value: 'form',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<MethodSelector {...props} />)
|
|
await user.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert
|
|
const content = screen.getByTestId('portal-content')
|
|
expect(content).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// Edge Cases (REQUIRED)
|
|
describe('Edge Cases', () => {
|
|
it('should handle rapid value changes', async () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const props = {
|
|
value: 'llm',
|
|
onChange,
|
|
}
|
|
|
|
// Act
|
|
const { rerender } = render(<MethodSelector {...props} />)
|
|
rerender(<MethodSelector {...props} value="form" />)
|
|
rerender(<MethodSelector {...props} value="llm" />)
|
|
rerender(<MethodSelector {...props} value="form" />)
|
|
|
|
// Assert - should not crash
|
|
expect(screen.getByText('tools.createTool.toolInput.methodSetting')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle empty string value', () => {
|
|
// Arrange
|
|
const props = {
|
|
value: '',
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
// Act & Assert
|
|
expect(() => render(<MethodSelector {...props} />)).not.toThrow()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Integration Tests
|
|
// ============================================================================
|
|
describe('Integration Tests', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPortalOpenState = false
|
|
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
|
mockFetchWorkflowToolDetailByAppID.mockResolvedValue(createMockWorkflowToolDetail())
|
|
})
|
|
|
|
// Complete workflow: open modal -> fill form -> save
|
|
describe('Complete Workflow', () => {
|
|
it('should complete full create workflow', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
|
const onRefreshData = vi.fn()
|
|
const props = createDefaultConfigureButtonProps({ onRefreshData })
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Open modal
|
|
const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex')
|
|
await user.click(triggerArea!)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
// Fill form
|
|
const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
|
|
await user.clear(labelInput)
|
|
await user.type(labelInput, 'My Custom Tool')
|
|
|
|
const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder')
|
|
await user.type(nameInput, 'my_custom_tool')
|
|
|
|
const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder')
|
|
await user.clear(descInput)
|
|
await user.type(descInput, 'A custom tool for testing')
|
|
|
|
// Save
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockCreateWorkflowToolProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'my_custom_tool',
|
|
label: 'My Custom Tool',
|
|
description: 'A custom tool for testing',
|
|
}),
|
|
)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(onRefreshData).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should complete full update workflow', async () => {
|
|
// Arrange
|
|
const user = userEvent.setup()
|
|
const handlePublish = vi.fn().mockResolvedValue(undefined)
|
|
mockSaveWorkflowToolProvider.mockResolvedValue({})
|
|
const props = createDefaultConfigureButtonProps({
|
|
published: true,
|
|
handlePublish,
|
|
})
|
|
|
|
// Act
|
|
render(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Wait for detail to load
|
|
await waitFor(() => {
|
|
expect(screen.getByText('workflow.common.configure')).toBeInTheDocument()
|
|
})
|
|
|
|
// Open modal
|
|
await user.click(screen.getByText('workflow.common.configure'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
|
})
|
|
|
|
// Modify description
|
|
const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder')
|
|
await user.clear(descInput)
|
|
await user.type(descInput, 'Updated description')
|
|
|
|
// Save
|
|
await user.click(screen.getByText('common.operation.save'))
|
|
|
|
// Confirm
|
|
await waitFor(() => {
|
|
expect(screen.getByText('tools.createTool.confirmTitle')).toBeInTheDocument()
|
|
})
|
|
await user.click(screen.getByText('common.operation.confirm'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(handlePublish).toHaveBeenCalled()
|
|
expect(mockSaveWorkflowToolProvider).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Test callbacks and state synchronization
|
|
describe('Callback Stability', () => {
|
|
it('should maintain callback references across rerenders', async () => {
|
|
// Arrange
|
|
const handlePublish = vi.fn().mockResolvedValue(undefined)
|
|
const onRefreshData = vi.fn()
|
|
const props = createDefaultConfigureButtonProps({
|
|
handlePublish,
|
|
onRefreshData,
|
|
})
|
|
|
|
// Act
|
|
const { rerender } = render(<WorkflowToolConfigureButton {...props} />)
|
|
rerender(<WorkflowToolConfigureButton {...props} />)
|
|
rerender(<WorkflowToolConfigureButton {...props} />)
|
|
|
|
// Assert - component should not crash and callbacks should be stable
|
|
expect(screen.getByText('workflow.common.workflowAsTool')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|