mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 09:58:04 +08:00
test(workflow): reorganize specs into __tests__ and align with shared test infrastructure (#33625)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,323 @@
|
||||
import type { Shape as HooksStoreShape } from '../../hooks-store/store'
|
||||
import type { RunFile } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createStartNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { InputVarType, WorkflowRunningStatus } from '../../types'
|
||||
import InputsPanel from '../inputs-panel'
|
||||
|
||||
const mockCheckInputsForm = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
|
||||
useCheckInputsForms: () => ({
|
||||
checkInputsForm: mockCheckInputsForm,
|
||||
}),
|
||||
}))
|
||||
|
||||
const fileSettingsWithImage = {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
},
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
number_limits: 3,
|
||||
image_file_size_limit: 10,
|
||||
} satisfies FileUpload & { image_file_size_limit: number }
|
||||
|
||||
const uploadedRunFile = {
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
upload_file_id: 'file-2',
|
||||
} as unknown as RunFile
|
||||
|
||||
const uploadingRunFile = {
|
||||
transfer_method: TransferMethod.local_file,
|
||||
} as unknown as RunFile
|
||||
|
||||
const createHooksStoreProps = (
|
||||
overrides: Partial<HooksStoreShape> = {},
|
||||
): Partial<HooksStoreShape> => ({
|
||||
handleRun: vi.fn(),
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: fileSettingsWithImage,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderInputsPanel = (
|
||||
startNode: ReturnType<typeof createStartNode>,
|
||||
options?: Parameters<typeof renderWorkflowComponent>[1],
|
||||
) => {
|
||||
return renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[startNode]} edges={[]} fitView />
|
||||
<InputsPanel onRun={vi.fn()} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
describe('InputsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckInputsForm.mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render current inputs, defaults, and the image uploader from the start node', () => {
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
{
|
||||
type: InputVarType.number,
|
||||
variable: 'count',
|
||||
label: 'Count',
|
||||
required: false,
|
||||
default: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
question: 'overridden question',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('overridden question')).toHaveFocus()
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(2)
|
||||
expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should update workflow inputs and image files when users edit the form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
},
|
||||
)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Question'), 'changed question')
|
||||
expect(store.getState().inputs).toEqual({ question: 'changed question' })
|
||||
|
||||
await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'),
|
||||
'https://example.com/image.png',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().files).toEqual([{
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/image.png',
|
||||
upload_file_id: '',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('should not start a run when input validation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckInputsForm.mockReturnValue(false)
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
edges={[]}
|
||||
fitView
|
||||
/>
|
||||
<InputsPanel onRun={onRun} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps({ handleRun }),
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(mockCheckInputsForm).toHaveBeenCalledWith(
|
||||
{ question: 'default question' },
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ variable: 'question' }),
|
||||
expect.objectContaining({ variable: '__image' }),
|
||||
]),
|
||||
)
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
expect(handleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start a run with processed inputs when validation succeeds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={[
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: InputVarType.checkbox,
|
||||
variable: 'confirmed',
|
||||
label: 'Confirmed',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
edges={[]}
|
||||
fitView
|
||||
/>
|
||||
<InputsPanel onRun={onRun} />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
question: 'run this',
|
||||
confirmed: 'truthy',
|
||||
},
|
||||
files: [uploadedRunFile],
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps({
|
||||
handleRun,
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(onRun).toHaveBeenCalledTimes(1)
|
||||
expect(handleRun).toHaveBeenCalledWith({
|
||||
inputs: {
|
||||
question: 'run this',
|
||||
confirmed: true,
|
||||
},
|
||||
files: [uploadedRunFile],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled States', () => {
|
||||
it('should disable the run button while a local file is still uploading', () => {
|
||||
renderInputsPanel(createStartNode(), {
|
||||
initialStoreState: {
|
||||
files: [uploadingRunFile],
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps({
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable the run button while the workflow is already running', () => {
|
||||
renderInputsPanel(createStartNode(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
},
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
163
web/app/components/workflow/panel/__tests__/record.spec.tsx
Normal file
163
web/app/components/workflow/panel/__tests__/record.spec.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import Record from '../record'
|
||||
|
||||
const mockHandleUpdateWorkflowCanvas = vi.fn()
|
||||
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)')
|
||||
|
||||
let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowUpdate: () => ({
|
||||
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run', () => ({
|
||||
default: ({
|
||||
runDetailUrl,
|
||||
tracingListUrl,
|
||||
getResultCallback,
|
||||
}: {
|
||||
runDetailUrl: string
|
||||
tracingListUrl: string
|
||||
getResultCallback: (res: WorkflowRunDetailResponse) => void
|
||||
}) => {
|
||||
latestGetResultCallback = getResultCallback
|
||||
return (
|
||||
<div
|
||||
data-run-detail-url={runDetailUrl}
|
||||
data-testid="run"
|
||||
data-tracing-list-url={tracingListUrl}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
|
||||
}))
|
||||
|
||||
const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({
|
||||
id: 'run-1',
|
||||
version: '1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputs: '{}',
|
||||
inputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
outputs: '{}',
|
||||
outputs_truncated: false,
|
||||
total_steps: 1,
|
||||
created_by_role: 'account',
|
||||
created_at: 1,
|
||||
finished_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Record', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestGetResultCallback = undefined
|
||||
})
|
||||
|
||||
it('renders the run title and passes run and trace URLs to the run panel', () => {
|
||||
const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({
|
||||
runUrl: `/runs/${runId}`,
|
||||
traceUrl: `/traces/${runId}`,
|
||||
}))
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
finished_at: 1700000000000,
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1')
|
||||
expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1')
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2)
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1')
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1')
|
||||
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000)
|
||||
})
|
||||
|
||||
it('updates the workflow canvas with a fallback viewport when the response omits one', () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1' })]
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(latestGetResultCallback).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
latestGetResultCallback?.(createRunDetail({
|
||||
graph: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes,
|
||||
edges,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the response viewport when one is available', () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1' })]
|
||||
const viewport = { x: 12, y: 24, zoom: 0.75 }
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
latestGetResultCallback?.(createRunDetail({
|
||||
graph: {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user