mirror of
https://github.com/langgenius/dify.git
synced 2026-03-06 15:45:14 +00:00
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
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:
@@ -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' }),
|
||||
|
||||
136
web/app/components/workflow/__tests__/workflow-test-env.spec.tsx
Normal file
136
web/app/components/workflow/__tests__/workflow-test-env.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user