mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
test(workflow): add unit tests for workflow components (#33910)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@ -0,0 +1,162 @@
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { CUSTOM_NODE } from '@/app/components/workflow/constants'
|
||||
import { DeliveryMethodType, UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import HumanInputFormList from '../human-input-form-list'
|
||||
|
||||
const mockNodes: Array<{
|
||||
id: string
|
||||
type: string
|
||||
data: {
|
||||
delivery_methods: Array<Record<string, unknown>>
|
||||
}
|
||||
}> = []
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => mockNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (state: { userProfile: { email: string } }) => T) => selector({
|
||||
userProfile: { email: 'debug@example.com' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
|
||||
form_id: 'form-1',
|
||||
node_id: 'human-node-1',
|
||||
node_title: 'Need Approval',
|
||||
form_content: 'Before {{#$output.reason#}} after',
|
||||
inputs: [{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'reason',
|
||||
default: {
|
||||
selector: [],
|
||||
type: 'constant',
|
||||
value: 'prefill',
|
||||
},
|
||||
}],
|
||||
actions: [{
|
||||
id: 'approve',
|
||||
title: 'Approve',
|
||||
button_style: UserActionButtonType.Primary,
|
||||
}],
|
||||
form_token: 'token-1',
|
||||
resolved_default_values: {},
|
||||
display_in_ui: true,
|
||||
expiration_time: 2_000_000_000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('HumanInputFormList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodes.splice(0, mockNodes.length)
|
||||
})
|
||||
|
||||
it('should render only visible forms, derive delivery method tips, and submit updated inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onHumanInputFormSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
mockNodes.push(
|
||||
{
|
||||
id: 'human-node-1',
|
||||
type: CUSTOM_NODE,
|
||||
data: {
|
||||
delivery_methods: [{
|
||||
id: 'email-1',
|
||||
type: DeliveryMethodType.Email,
|
||||
enabled: true,
|
||||
config: {
|
||||
recipients: {
|
||||
whole_workspace: false,
|
||||
items: [],
|
||||
},
|
||||
subject: 'Need approval',
|
||||
body: 'Please review',
|
||||
debug_mode: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'human-node-2',
|
||||
type: CUSTOM_NODE,
|
||||
data: {
|
||||
delivery_methods: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[
|
||||
createFormData(),
|
||||
createFormData({
|
||||
form_id: 'form-2',
|
||||
node_id: 'human-node-2',
|
||||
node_title: 'Hidden Form',
|
||||
display_in_ui: false,
|
||||
}),
|
||||
]}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Need Approval')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Hidden Form')).not.toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('prefill')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('expiration-time')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tips')).toBeInTheDocument()
|
||||
|
||||
await user.clear(screen.getByDisplayValue('prefill'))
|
||||
await user.type(screen.getByTestId('content-item-textarea'), 'updated reason')
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
expect(onHumanInputFormSubmit).toHaveBeenCalledWith('token-1', {
|
||||
inputs: {
|
||||
reason: 'updated reason',
|
||||
},
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
|
||||
it('should omit delivery tips when the node has no enabled delivery methods', () => {
|
||||
mockNodes.push({
|
||||
id: 'human-node-1',
|
||||
type: CUSTOM_NODE,
|
||||
data: {
|
||||
delivery_methods: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[
|
||||
createFormData(),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an empty container when there are no visible forms', () => {
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('content-wrapper')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,115 +1,252 @@
|
||||
import type { PanelProps } from '../index'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Panel from '../index'
|
||||
|
||||
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
|
||||
|
||||
class MockResizeObserver implements ResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
type MockNodeData = {
|
||||
selected?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => (props: { latestVersionId?: string }) => {
|
||||
mockVersionHistoryPanel(props)
|
||||
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
|
||||
},
|
||||
type MockNode = {
|
||||
id: string
|
||||
type: string
|
||||
data: MockNodeData
|
||||
}
|
||||
|
||||
type MockPanelStoreState = {
|
||||
showEnvPanel: boolean
|
||||
isRestoring: boolean
|
||||
showWorkflowVersionHistoryPanel: boolean
|
||||
workflowCanvasWidth: number
|
||||
previewPanelWidth: number
|
||||
setPreviewPanelWidth: (value: number) => void
|
||||
setRightPanelWidth: (value: number) => void
|
||||
setOtherPanelWidth: (value: number) => void
|
||||
}
|
||||
|
||||
type MockResizeMode = 'borderBox' | 'contentRect' | 'fallback'
|
||||
|
||||
let mockResizeModes: MockResizeMode[] = []
|
||||
let mockResizeObservers: MockResizeObserver[] = []
|
||||
|
||||
const createResizeEntry = (mode: MockResizeMode): ResizeObserverEntry => ({
|
||||
borderBoxSize: mode === 'borderBox'
|
||||
? [{ inlineSize: 720, blockSize: 0 }] as ResizeObserverSize[]
|
||||
: [],
|
||||
contentBoxSize: [],
|
||||
devicePixelContentBoxSize: [],
|
||||
contentRect: {
|
||||
width: mode === 'contentRect' ? 530 : 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRectReadOnly,
|
||||
target: document.createElement('div'),
|
||||
} as unknown as ResizeObserverEntry)
|
||||
|
||||
class MockResizeObserver {
|
||||
callback: ResizeObserverCallback
|
||||
|
||||
observe = vi.fn(() => {
|
||||
if (!mockResizeModes.length)
|
||||
return
|
||||
|
||||
this.callback(
|
||||
mockResizeModes.map(createResizeEntry),
|
||||
this as unknown as ResizeObserver,
|
||||
)
|
||||
})
|
||||
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback
|
||||
mockResizeObservers.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
let mockNodes: MockNode[] = []
|
||||
let mockPanelStoreState: MockPanelStoreState
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStore: (selector: (state: { getNodes: () => MockNode[] }) => unknown) => selector({
|
||||
getNodes: () => mockNodes,
|
||||
}),
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => mockNodes,
|
||||
setNodes: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const mod = await import('../../__tests__/reactflow-mock-state')
|
||||
const base = mod.createReactFlowModuleMock()
|
||||
|
||||
return {
|
||||
...base,
|
||||
useStore: vi.fn(selector => selector({
|
||||
getNodes: () => mod.rfState.nodes,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../env-panel', () => ({
|
||||
default: () => <div data-testid="env-panel" />,
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: <T,>(selector: (state: MockPanelStoreState) => T) => selector(mockPanelStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('../../nodes', () => ({
|
||||
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
|
||||
Panel: ({ id, data }: { id: string, data: MockNodeData }) => (
|
||||
<div data-testid="node-panel">{`${id}:${data.title || 'untitled'}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const versionHistoryPanelProps = {
|
||||
latestVersionId: 'version-1',
|
||||
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
|
||||
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
|
||||
vi.mock('@/app/components/workflow/panel/env-panel', () => ({
|
||||
default: () => <div data-testid="env-panel">env-panel</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/version-history-panel', () => ({
|
||||
default: ({ latestVersionId }: { latestVersionId?: string }) => (
|
||||
<div data-testid="version-history-panel">{latestVersionId || 'none'}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const ReactModule = await import('react')
|
||||
|
||||
return {
|
||||
default: (
|
||||
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
|
||||
) => {
|
||||
const DynamicComponent = (props: Record<string, unknown>) => {
|
||||
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
|
||||
|
||||
ReactModule.useEffect(() => {
|
||||
let mounted = true
|
||||
loader().then((mod) => {
|
||||
if (mounted)
|
||||
setLoaded(() => mod.default)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return Loaded ? <Loaded {...props} /> : null
|
||||
}
|
||||
|
||||
return DynamicComponent
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('Panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodes = []
|
||||
mockResizeModes = []
|
||||
mockResizeObservers = []
|
||||
mockPanelStoreState = {
|
||||
showEnvPanel: false,
|
||||
isRestoring: false,
|
||||
showWorkflowVersionHistoryPanel: false,
|
||||
workflowCanvasWidth: 0,
|
||||
previewPanelWidth: 420,
|
||||
setPreviewPanelWidth: vi.fn(),
|
||||
setRightPanelWidth: vi.fn(),
|
||||
setOtherPanelWidth: vi.fn(),
|
||||
}
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
|
||||
width: 640,
|
||||
height: 320,
|
||||
top: 0,
|
||||
right: 640,
|
||||
bottom: 320,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Version History Panel', () => {
|
||||
it('should render the version history panel when the panel is open and props are provided', () => {
|
||||
renderWorkflowComponent(
|
||||
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
it('should render slots, selected node details, and secondary panels while constraining oversized preview widths', async () => {
|
||||
mockNodes = [{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
data: {
|
||||
selected: true,
|
||||
title: 'Selected Node',
|
||||
},
|
||||
}]
|
||||
mockPanelStoreState = {
|
||||
...mockPanelStoreState,
|
||||
showEnvPanel: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
workflowCanvasWidth: 1000,
|
||||
previewPanelWidth: 520,
|
||||
}
|
||||
|
||||
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
|
||||
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
latestVersionId: 'version-1',
|
||||
}))
|
||||
})
|
||||
render(
|
||||
<Panel
|
||||
components={{
|
||||
left: <div>left-slot</div>,
|
||||
right: <div>right-slot</div>,
|
||||
}}
|
||||
versionHistoryPanelProps={{
|
||||
latestVersionId: 'version-1',
|
||||
restoreVersionUrl: versionId => `/apps/app-1/workflows/${versionId}/restore`,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
it('should not render the version history panel when the panel is open but props are missing', () => {
|
||||
renderWorkflowComponent(
|
||||
<Panel />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(screen.getByText('left-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('right-slot')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-panel')).toHaveTextContent('node-1:Selected Node')
|
||||
expect(screen.getByTestId('env-panel')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('version-history-panel')).toHaveTextContent('version-1')
|
||||
expect(mockPanelStoreState.setPreviewPanelWidth).toHaveBeenCalledWith(400)
|
||||
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640)
|
||||
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should skip node and auxiliary panels when there is no selected node or open side panel state', () => {
|
||||
render(
|
||||
<Panel
|
||||
components={{
|
||||
left: <div>left-only</div>,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
it('should not render the version history panel when the panel is closed', () => {
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
selected: true,
|
||||
},
|
||||
}),
|
||||
] as typeof rfState.nodes
|
||||
expect(screen.getByText('left-only')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('node-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('env-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(mockPanelStoreState.setPreviewPanelWidth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
renderWorkflowComponent(
|
||||
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
it('should derive observer widths from border-box, content-rect, and fallback values and disconnect on unmount', () => {
|
||||
mockResizeModes = ['borderBox', 'contentRect', 'fallback']
|
||||
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
|
||||
})
|
||||
const { unmount } = render(<Panel />)
|
||||
|
||||
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(720)
|
||||
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(530)
|
||||
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640)
|
||||
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(720)
|
||||
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(530)
|
||||
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockResizeObservers).toHaveLength(2)
|
||||
mockResizeObservers.forEach(observer => expect(observer.disconnect).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,354 @@
|
||||
import type { Shape } from '../../store/workflow'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { createNodeTracing, createWorkflowRunningData } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { submitHumanInputForm } from '@/service/workflow'
|
||||
import WorkflowPreview from '../workflow-preview'
|
||||
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/result-panel', () => ({
|
||||
default: ({ status }: { status?: string }) => <div data-testid="result-panel">{status}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/result-text', () => ({
|
||||
default: ({
|
||||
outputs,
|
||||
isPaused,
|
||||
isRunning,
|
||||
onClick,
|
||||
}: {
|
||||
outputs?: string
|
||||
isPaused?: boolean
|
||||
isRunning?: boolean
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="result-text">{JSON.stringify({ outputs, isPaused, isRunning })}</div>
|
||||
<button type="button" onClick={onClick}>open-detail</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
default: ({ list }: { list: unknown[] }) => <div data-testid="tracing-panel">{list.length}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({
|
||||
default: ({ onRun }: { onRun: () => void }) => (
|
||||
<button type="button" onClick={onRun}>
|
||||
run-inputs
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/human-input-form-list', () => ({
|
||||
default: ({
|
||||
humanInputFormDataList,
|
||||
onHumanInputFormSubmit,
|
||||
}: {
|
||||
humanInputFormDataList: unknown[]
|
||||
onHumanInputFormSubmit?: (token: string, formData: Record<string, string>) => Promise<void>
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="human-form-list">{humanInputFormDataList.length}</div>
|
||||
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { answer: 'ok' })}>
|
||||
submit-human-form
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/human-input-filled-form-list', () => ({
|
||||
default: ({ humanInputFilledFormDataList }: { humanInputFilledFormDataList: unknown[] }) => (
|
||||
<div data-testid="filled-form-list">{humanInputFilledFormDataList.length}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockCopy = vi.mocked(copy)
|
||||
const mockToastSuccess = vi.mocked(toast.success)
|
||||
const mockSubmitHumanInputForm = vi.mocked(submitHumanInputForm)
|
||||
|
||||
type WorkflowResult = NonNullable<ReturnType<typeof createWorkflowRunningData>['result']>
|
||||
|
||||
const createWorkflowResult = (overrides: Partial<WorkflowResult> = {}): WorkflowResult => ({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createHumanInputFormData = (
|
||||
overrides: Partial<HumanInputFormData> = {},
|
||||
): HumanInputFormData => ({
|
||||
form_id: 'form-1',
|
||||
node_id: 'human-node-1',
|
||||
node_title: 'Need Approval',
|
||||
form_content: 'Before {{#$output.reason#}} after',
|
||||
inputs: [],
|
||||
actions: [],
|
||||
form_token: 'token-1',
|
||||
resolved_default_values: {},
|
||||
display_in_ui: true,
|
||||
expiration_time: 2_000_000_000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createHumanInputFilledFormData = (
|
||||
overrides: Partial<HumanInputFilledFormData> = {},
|
||||
): HumanInputFilledFormData => ({
|
||||
node_id: 'node-1',
|
||||
node_title: 'Need Approval',
|
||||
rendered_content: 'rendered',
|
||||
action_id: 'approve',
|
||||
action_text: 'Approve',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('WorkflowPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the input tab active, switch to result after running, and close the preview panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showInputsPanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
previewPanelWidth: 420,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'run-inputs' }))
|
||||
expect(screen.getByTestId('result-text')).toBeInTheDocument()
|
||||
|
||||
await user.click(container.querySelector('.flex.items-center.justify-between .cursor-pointer.p-1') as HTMLElement)
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should switch to detail when the workflow is listening', () => {
|
||||
renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isListening: true,
|
||||
workflowRunningData: createWorkflowRunningData({
|
||||
result: createWorkflowResult({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Running)
|
||||
})
|
||||
|
||||
it('should switch to detail when a finished run has no outputs or files', () => {
|
||||
renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowRunningData: {
|
||||
...createWorkflowRunningData({
|
||||
result: createWorkflowResult({
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
files: [],
|
||||
}),
|
||||
}),
|
||||
resultText: '',
|
||||
} as NonNullable<Shape['workflowRunningData']>,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Succeeded)
|
||||
})
|
||||
|
||||
it('should render paused human input results and submit pending forms', async () => {
|
||||
const user = userEvent.setup()
|
||||
const pausedData = createWorkflowRunningData({
|
||||
result: createWorkflowResult({
|
||||
status: WorkflowRunningStatus.Paused,
|
||||
files: [],
|
||||
}),
|
||||
humanInputFormDataList: [createHumanInputFormData()],
|
||||
humanInputFilledFormDataList: [createHumanInputFilledFormData()],
|
||||
})
|
||||
|
||||
renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowRunningData: pausedData,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('human-form-list')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('filled-form-list')).toHaveTextContent('1')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-human-form' }))
|
||||
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
})
|
||||
|
||||
it('should copy successful string output and show a success toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowRunningData: {
|
||||
...createWorkflowRunningData({
|
||||
result: createWorkflowResult({
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
files: [],
|
||||
}),
|
||||
}),
|
||||
resultText: 'final answer',
|
||||
} as NonNullable<Shape['workflowRunningData']>,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('runLog.result'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.copy' }))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('final answer')
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
|
||||
})
|
||||
|
||||
it('should show a loading state for an empty detail panel', () => {
|
||||
renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isListening: true,
|
||||
workflowRunningData: undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show a loading state for an empty tracing panel', () => {
|
||||
renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
workflowRunningData: createWorkflowRunningData({
|
||||
tracing: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('0')
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep inert tabs disabled without run data and switch among result, detail, and tracing when data exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showInputsPanel: true,
|
||||
workflowRunningData: undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('runLog.result'))
|
||||
await user.click(screen.getByText('runLog.detail'))
|
||||
await user.click(screen.getByText('runLog.tracing'))
|
||||
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
|
||||
|
||||
store.setState({
|
||||
workflowRunningData: {
|
||||
...createWorkflowRunningData({
|
||||
result: createWorkflowResult({
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
files: [],
|
||||
}),
|
||||
tracing: [createNodeTracing()],
|
||||
}),
|
||||
resultText: 'ready',
|
||||
} as NonNullable<Shape['workflowRunningData']>,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('runLog.result'))
|
||||
expect(screen.getByTestId('result-text')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('runLog.detail'))
|
||||
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('runLog.tracing'))
|
||||
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('1')
|
||||
|
||||
await user.click(screen.getByText('runLog.result'))
|
||||
await user.click(screen.getByRole('button', { name: 'open-detail' }))
|
||||
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should resize the preview panel within the allowed workflow canvas bounds', async () => {
|
||||
const { container, store } = renderWorkflowComponent(
|
||||
<WorkflowPreview />,
|
||||
{
|
||||
initialStoreState: {
|
||||
previewPanelWidth: 450,
|
||||
workflowCanvasWidth: 1000,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const resizeHandle = container.querySelector('.cursor-col-resize') as HTMLElement
|
||||
|
||||
fireEvent.mouseDown(resizeHandle)
|
||||
fireEvent.mouseMove(window, { clientX: 700 })
|
||||
fireEvent.mouseMove(window, { clientX: 100 })
|
||||
fireEvent.mouseUp(window)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().previewPanelWidth).toBe(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user