diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 99318b07b3..0d3b638bab 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -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' }), diff --git a/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx b/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx new file mode 100644 index 0000000000..d9a4efa12e --- /dev/null +++ b/web/app/components/workflow/__tests__/workflow-test-env.spec.tsx @@ -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), + }, + '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 }) { + 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') + }) +}) diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx index 6109d8a7f4..00d6829964 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -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( + * , + * { 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): Workflow } export function createTestHooksStore(props?: Partial): 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

= Omit, 'wrapper'> & { +type WorkflowProviderOptions = { initialStoreState?: Partial hooksStoreProps?: Partial historyStore?: HistoryStoreConfig } -type WorkflowTestResult = RenderHookResult & { +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

= Omit, 'wrapper'> & WorkflowProviderOptions + +type WorkflowHookTestResult = RenderHookResult & StoreInstances + /** * Renders a hook inside composable workflow providers. * @@ -116,44 +200,77 @@ type WorkflowTestResult = RenderHookResult & { */ export function renderWorkflowHook( hook: (props: P) => R, - options?: WorkflowTestOptions

, -): WorkflowTestResult { + options?: WorkflowHookTestOptions

, +): WorkflowHookTestResult { 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 & 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> = { + 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>( + Component: React.ComponentType>, + data: T, + options?: NodeTestOptions, +): WorkflowComponentTestResult { + const { nodeId = 'test-node-1', selected = false, ...rest } = options ?? {} + return renderWorkflowComponent( + React.createElement(Component, { id: nodeId, data, selected }), + rest, + ) } // ---------------------------------------------------------------------------