test(workflow): add validation tests for workflow and node component rendering part 3 (#33012)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-03-05 14:34:07 +08:00
committed by GitHub
parent 7432b58f82
commit 1819b87a56
3 changed files with 301 additions and 42 deletions

View File

@@ -1,5 +1,5 @@
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import Conversion from '../conversion'
@@ -9,6 +9,12 @@ import PublishToast from '../publish-toast'
import RagPipelineChildren from '../rag-pipeline-children'
import PipelineScreenShot from '../screenshot'
afterEach(async () => {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
})
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),

View File

@@ -0,0 +1,136 @@
/**
* Validation tests for renderWorkflowComponent and renderNodeComponent.
*/
import type { Shape } from '../store/workflow'
import { act, screen } from '@testing-library/react'
import * as React from 'react'
import { FlowType } from '@/types/common'
import { useHooksStore } from '../hooks-store/store'
import { useStore, useWorkflowStore } from '../store/workflow'
import { renderNodeComponent, renderWorkflowComponent } from './workflow-test-env'
// ---------------------------------------------------------------------------
// Test components that read from workflow contexts
// ---------------------------------------------------------------------------
function StoreReader() {
const showConfirm = useStore(s => s.showConfirm)
return React.createElement('div', { 'data-testid': 'store-reader' }, showConfirm ? 'has-confirm' : 'no-confirm')
}
function StoreWriter() {
const store = useWorkflowStore()
return React.createElement(
'button',
{
'data-testid': 'store-writer',
'onClick': () => store.setState({ showConfirm: { title: 'Test', onConfirm: () => {} } } as Partial<Shape>),
},
'Write',
)
}
function HooksStoreReader() {
const flowId = useHooksStore(s => s.configsMap?.flowId ?? 'none')
return React.createElement('div', { 'data-testid': 'hooks-reader' }, flowId)
}
function NodeRenderer(props: { id: string, data: { title: string }, selected?: boolean }) {
return React.createElement(
'div',
{ 'data-testid': 'node-render' },
`${props.id}:${props.data.title}:${props.selected ? 'sel' : 'nosel'}`,
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('renderWorkflowComponent', () => {
it('should provide WorkflowContext with default store', () => {
renderWorkflowComponent(React.createElement(StoreReader))
expect(screen.getByTestId('store-reader')).toHaveTextContent('no-confirm')
})
it('should apply initialStoreState', () => {
renderWorkflowComponent(React.createElement(StoreReader), {
initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } },
})
expect(screen.getByTestId('store-reader')).toHaveTextContent('has-confirm')
})
it('should return a live store that components can mutate', () => {
const { store } = renderWorkflowComponent(
React.createElement(React.Fragment, null, React.createElement(StoreReader), React.createElement(StoreWriter)),
)
expect(store.getState().showConfirm).toBeUndefined()
act(() => {
screen.getByTestId('store-writer').click()
})
expect(store.getState().showConfirm).toBeDefined()
expect(screen.getByTestId('store-reader')).toHaveTextContent('has-confirm')
})
it('should provide HooksStoreContext when hooksStoreProps given', () => {
renderWorkflowComponent(React.createElement(HooksStoreReader), {
hooksStoreProps: { configsMap: { flowId: 'test-123', flowType: FlowType.appFlow, fileSettings: {} } },
})
expect(screen.getByTestId('hooks-reader')).toHaveTextContent('test-123')
})
it('should throw when HooksStoreContext is not provided', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
expect(() => {
renderWorkflowComponent(React.createElement(HooksStoreReader))
}).toThrow('Missing HooksStoreContext.Provider')
}
finally {
consoleSpy.mockRestore()
}
})
it('should forward extra render options (container)', () => {
const container = document.createElement('section')
document.body.appendChild(container)
try {
renderWorkflowComponent(React.createElement(StoreReader), { container })
expect(container.querySelector('[data-testid="store-reader"]')).toBeTruthy()
}
finally {
document.body.removeChild(container)
}
})
})
describe('renderNodeComponent', () => {
it('should render node with default id and selected=false', () => {
renderNodeComponent(NodeRenderer, { title: 'Hello' })
expect(screen.getByTestId('node-render')).toHaveTextContent('test-node-1:Hello:nosel')
})
it('should accept custom nodeId and selected', () => {
renderNodeComponent(NodeRenderer, { title: 'World' }, {
nodeId: 'custom-42',
selected: true,
})
expect(screen.getByTestId('node-render')).toHaveTextContent('custom-42:World:sel')
})
it('should provide WorkflowContext to node components', () => {
function NodeWithStore(props: { id: string, data: Record<string, unknown> }) {
const controlMode = useStore(s => s.controlMode)
return React.createElement('div', { 'data-testid': 'node-store' }, `${props.id}:${controlMode}`)
}
renderNodeComponent(NodeWithStore, {}, {
initialStoreState: { controlMode: 'hand' as Shape['controlMode'] },
})
expect(screen.getByTestId('node-store')).toHaveTextContent('test-node-1:hand')
})
})

View File

@@ -1,7 +1,7 @@
/**
* Workflow test environment — composable providers + render helpers.
*
* ## Quick start
* ## Quick start (hook)
*
* ```ts
* import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
@@ -29,13 +29,43 @@
* expect(rfState.setNodes).toHaveBeenCalled()
* })
* ```
*
* ## Quick start (component)
*
* ```ts
* import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
*
* it('renders correctly', () => {
* const { getByText, store } = renderWorkflowComponent(
* <MyComponent someProp="value" />,
* { initialStoreState: { showConfirm: undefined } },
* )
* expect(getByText('value')).toBeInTheDocument()
* expect(store.getState().showConfirm).toBeUndefined()
* })
* ```
*
* ## Quick start (node component)
*
* ```ts
* import { renderNodeComponent } from '../../__tests__/workflow-test-env'
*
* it('renders node', () => {
* const { getByText, store } = renderNodeComponent(
* MyNodeComponent,
* { type: BlockEnum.Code, title: 'My Node', desc: '' },
* { nodeId: 'n-1', initialStoreState: { ... } },
* )
* expect(getByText('My Node')).toBeInTheDocument()
* })
* ```
*/
import type { RenderHookOptions, RenderHookResult } from '@testing-library/react'
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
import type { Shape as HooksStoreShape } from '../hooks-store/store'
import type { Shape } from '../store/workflow'
import type { Edge, Node, WorkflowRunningData } from '../types'
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
import { renderHook } from '@testing-library/react'
import { render, renderHook } from '@testing-library/react'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
import { temporal } from 'zundo'
@@ -83,11 +113,14 @@ export function createTestWorkflowStore(initialState?: Partial<Shape>): Workflow
}
export function createTestHooksStore(props?: Partial<HooksStoreShape>): HooksStore {
return createHooksStore(props ?? {})
const store = createHooksStore(props ?? {})
if (props)
store.setState(props)
return store
}
// ---------------------------------------------------------------------------
// renderWorkflowHook — composable hook renderer
// Shared provider options & wrapper factory
// ---------------------------------------------------------------------------
type HistoryStoreConfig = {
@@ -95,17 +128,68 @@ type HistoryStoreConfig = {
edges?: Edge[]
}
type WorkflowTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & {
type WorkflowProviderOptions = {
initialStoreState?: Partial<Shape>
hooksStoreProps?: Partial<HooksStoreShape>
historyStore?: HistoryStoreConfig
}
type WorkflowTestResult<R, P> = RenderHookResult<R, P> & {
type StoreInstances = {
store: WorkflowStore
hooksStore?: HooksStore
}
function createStoresFromOptions(options: WorkflowProviderOptions): StoreInstances {
const store = createTestWorkflowStore(options.initialStoreState)
const hooksStore = options.hooksStoreProps !== undefined
? createTestHooksStore(options.hooksStoreProps)
: undefined
return { store, hooksStore }
}
function createWorkflowWrapper(
stores: StoreInstances,
historyConfig?: HistoryStoreConfig,
) {
const historyCtxValue = historyConfig
? createTestHistoryStoreContext(historyConfig)
: undefined
return ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
if (historyCtxValue) {
inner = React.createElement(
WorkflowHistoryStoreContext.Provider,
{ value: historyCtxValue },
inner,
)
}
if (stores.hooksStore) {
inner = React.createElement(
HooksStoreContext.Provider,
{ value: stores.hooksStore },
inner,
)
}
return React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
)
}
}
// ---------------------------------------------------------------------------
// renderWorkflowHook — composable hook renderer
// ---------------------------------------------------------------------------
type WorkflowHookTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & WorkflowProviderOptions
type WorkflowHookTestResult<R, P> = RenderHookResult<R, P> & StoreInstances
/**
* Renders a hook inside composable workflow providers.
*
@@ -116,44 +200,77 @@ type WorkflowTestResult<R, P> = RenderHookResult<R, P> & {
*/
export function renderWorkflowHook<R, P = undefined>(
hook: (props: P) => R,
options?: WorkflowTestOptions<P>,
): WorkflowTestResult<R, P> {
options?: WorkflowHookTestOptions<P>,
): WorkflowHookTestResult<R, P> {
const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...rest } = options ?? {}
const store = createTestWorkflowStore(initialStoreState)
const hooksStore = hooksStoreProps !== undefined
? createTestHooksStore(hooksStoreProps)
: undefined
const wrapper = ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
if (historyConfig) {
const historyCtxValue = createTestHistoryStoreContext(historyConfig)
inner = React.createElement(
WorkflowHistoryStoreContext.Provider,
{ value: historyCtxValue },
inner,
)
}
if (hooksStore) {
inner = React.createElement(
HooksStoreContext.Provider,
{ value: hooksStore },
inner,
)
}
return React.createElement(
WorkflowContext.Provider,
{ value: store },
inner,
)
}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
const wrapper = createWorkflowWrapper(stores, historyConfig)
const renderResult = renderHook(hook, { wrapper, ...rest })
return { ...renderResult, store, hooksStore }
return { ...renderResult, ...stores }
}
// ---------------------------------------------------------------------------
// renderWorkflowComponent — composable component renderer
// ---------------------------------------------------------------------------
type WorkflowComponentTestOptions = Omit<RenderOptions, 'wrapper'> & WorkflowProviderOptions
type WorkflowComponentTestResult = RenderResult & StoreInstances
/**
* Renders a React element inside composable workflow providers.
*
* Provides the same context layers as `renderWorkflowHook`:
* - **Always**: `WorkflowContext` (real zustand store)
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
*/
export function renderWorkflowComponent(
ui: React.ReactElement,
options?: WorkflowComponentTestOptions,
): WorkflowComponentTestResult {
const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...renderOptions } = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
const wrapper = createWorkflowWrapper(stores, historyConfig)
const renderResult = render(ui, { wrapper, ...renderOptions })
return { ...renderResult, ...stores }
}
// ---------------------------------------------------------------------------
// renderNodeComponent — convenience wrapper for node components
// ---------------------------------------------------------------------------
type NodeComponentProps<T = Record<string, unknown>> = {
id: string
data: T
selected?: boolean
}
type NodeTestOptions = WorkflowComponentTestOptions & {
nodeId?: string
selected?: boolean
}
/**
* Renders a workflow node component inside composable workflow providers.
*
* Automatically provides `id`, `data`, and `selected` props that
* ReactFlow would normally inject into custom node components.
*/
export function renderNodeComponent<T extends Record<string, unknown>>(
Component: React.ComponentType<NodeComponentProps<T>>,
data: T,
options?: NodeTestOptions,
): WorkflowComponentTestResult {
const { nodeId = 'test-node-1', selected = false, ...rest } = options ?? {}
return renderWorkflowComponent(
React.createElement(Component, { id: nodeId, data, selected }),
rest,
)
}
// ---------------------------------------------------------------------------