UNPKG

@nocobase/flow-engine

Version:

A standalone flow engine for NocoBase, managing workflows, models, and actions.

1,022 lines (799 loc) 34.9 kB
/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ import { describe, test, expect, beforeEach, vi } from 'vitest'; import { getT, isInheritedFrom, resolveDefaultParams, resolveStepUiSchema, FlowExitException, defineAction, compileUiSchema, resolveCreateModelOptions as resolveDefaultOptions, FLOW_ENGINE_NAMESPACE, } from '../index'; import { FlowModel } from '../../models/flowModel'; import { FlowEngine } from '../../flowEngine'; import type { FlowDefinitionOptions, ActionDefinition, DeepPartial, ModelConstructor, StepParams, StepDefinition, } from '../../types'; import { FlowRuntimeContext } from '../../flowContext'; // Helper functions const createMockFlowEngine = (): FlowEngine => { return new FlowEngine(); }; interface MockFlowModelOptions { uid?: string; flowEngine?: FlowEngine; stepParams?: StepParams; sortIndex?: number; subModels?: Record<string, FlowModel | FlowModel[]>; use?: string; } const createMockFlowModel = (overrides: MockFlowModelOptions = {}): FlowModel => { const flowEngine = overrides.flowEngine || createMockFlowEngine(); const options = { uid: 'test-model-uid', use: 'FlowModel', stepParams: {}, sortIndex: 0, subModels: {}, ...overrides, }; const model = flowEngine.createModel(options); // Mock additional methods for testing flowEngine.translate = vi.fn((key: string, options?: Record<string, unknown>) => { if (options?.returnOriginal) return key; return `translated_${key}`; }); flowEngine.getAction = vi.fn().mockReturnValue(null); return model; }; // Test setup let mockModel: FlowModel; let mockFlowEngine: FlowEngine; beforeEach(() => { mockFlowEngine = createMockFlowEngine(); mockModel = createMockFlowModel({ flowEngine: mockFlowEngine }); vi.clearAllMocks(); }); describe('Utils', () => { // ==================== getT() FUNCTION ==================== describe('getT()', () => { describe('basic translation functionality', () => { test('should return translation function when flowEngine.translate exists', () => { const translateFn = getT(mockModel); expect(typeof translateFn).toBe('function'); }); test('should call flowEngine.translate with correct parameters', () => { const translateFn = getT(mockModel); translateFn('test.key', { custom: 'option' }); expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('test.key', { ns: [FLOW_ENGINE_NAMESPACE, 'client'], nsMode: 'fallback', custom: 'option', }); }); test('should return translated result from flowEngine', () => { const translateFn = getT(mockModel); const result = translateFn('hello.world'); expect(result).toBe('translated_hello.world'); }); }); describe('namespace handling', () => { test('should add flow-engine namespace by default', () => { const translateFn = getT(mockModel); translateFn('key'); expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', { ns: [FLOW_ENGINE_NAMESPACE, 'client'], nsMode: 'fallback', }); }); test('should merge with existing options', () => { const translateFn = getT(mockModel); translateFn('key', { ns: ['custom'], extraOption: 'value' }); // The implementation spreads options after defaults, so options override defaults expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', { ns: ['custom'], // options.ns overrides default ns nsMode: 'fallback', extraOption: 'value', }); }); test('should allow nsMode override', () => { const translateFn = getT(mockModel); translateFn('key', { nsMode: 'strict' }); // The implementation spreads options after defaults, so options override defaults expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', { ns: [FLOW_ENGINE_NAMESPACE, 'client'], nsMode: 'strict', // options.nsMode overrides default nsMode }); }); }); describe('fallback mechanism', () => { test('should return fallback function when no flowEngine', () => { const modelWithoutEngine = { flowEngine: null } as unknown as FlowModel; const translateFn = getT(modelWithoutEngine); expect(typeof translateFn).toBe('function'); expect(translateFn('test.key')).toBe('test.key'); }); test('should return fallback function when no translate method', () => { const modelWithoutTranslate = { flowEngine: { translate: null }, } as unknown as FlowModel; const translateFn = getT(modelWithoutTranslate); expect(translateFn('test.key')).toBe('test.key'); }); }); describe('error handling', () => { test('should handle translate method throwing errors', () => { mockModel.flowEngine.translate = vi.fn(() => { throw new Error('Translation error'); }); expect(() => { const translateFn = getT(mockModel); translateFn('test.key'); }).toThrow('Translation error'); }); test('should handle null options parameter', () => { const translateFn = getT(mockModel); translateFn('key', null); expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', { ns: [FLOW_ENGINE_NAMESPACE, 'client'], nsMode: 'fallback', }); }); test('should handle undefined options parameter', () => { const translateFn = getT(mockModel); translateFn('key'); expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', { ns: [FLOW_ENGINE_NAMESPACE, 'client'], nsMode: 'fallback', }); }); }); }); // ==================== isInheritedFrom() FUNCTION ==================== describe('isInheritedFrom()', () => { let BaseClass: ModelConstructor; let MiddleClass: ModelConstructor; let DerivedClass: ModelConstructor; let UnrelatedClass: ModelConstructor; beforeEach(() => { BaseClass = class extends FlowModel {} as ModelConstructor; MiddleClass = class MiddleClass extends BaseClass {} as ModelConstructor; DerivedClass = class DerivedClass extends MiddleClass {} as ModelConstructor; UnrelatedClass = class extends FlowModel {} as ModelConstructor; }); describe('basic inheritance checking', () => { test('should return true for direct inheritance', () => { const result = isInheritedFrom(MiddleClass, BaseClass); expect(result).toBe(true); }); test('should return false for same class', () => { const result = isInheritedFrom(BaseClass, BaseClass); expect(result).toBe(false); }); test('should return false for unrelated classes', () => { const result = isInheritedFrom(UnrelatedClass, BaseClass); expect(result).toBe(false); }); }); describe('multi-level inheritance', () => { test('should return true for multi-level inheritance', () => { const result = isInheritedFrom(DerivedClass, BaseClass); expect(result).toBe(true); }); test('should return true for immediate parent', () => { const result = isInheritedFrom(DerivedClass, MiddleClass); expect(result).toBe(true); }); test('should handle deep inheritance chains', () => { class Level1 extends DerivedClass {} class Level2 extends Level1 {} class Level3 extends Level2 {} expect(isInheritedFrom(Level3 as unknown as ModelConstructor, BaseClass)).toBe(true); expect(isInheritedFrom(Level3 as unknown as ModelConstructor, MiddleClass)).toBe(true); expect(isInheritedFrom(Level3 as unknown as ModelConstructor, DerivedClass)).toBe(true); expect(isInheritedFrom(Level3 as unknown as ModelConstructor, Level2 as unknown as ModelConstructor)).toBe( true, ); }); }); describe('prototype chain validation', () => { test('should traverse prototype chain correctly', () => { // Create a complex inheritance chain const A = class extends FlowModel {} as ModelConstructor; const B = class B extends A {} as ModelConstructor; const C = class C extends B {} as ModelConstructor; const D = class D extends C {} as ModelConstructor; expect(isInheritedFrom(D, C)).toBe(true); expect(isInheritedFrom(D, B)).toBe(true); expect(isInheritedFrom(D, A)).toBe(true); expect(isInheritedFrom(C, A)).toBe(true); expect(isInheritedFrom(B, A)).toBe(true); }); test('should handle null prototype correctly', () => { const NullProtoClass = function () {} as unknown as ModelConstructor; Object.setPrototypeOf((NullProtoClass as unknown as { prototype: unknown }).prototype, null); expect(() => { isInheritedFrom(NullProtoClass, BaseClass); }).not.toThrow(); }); }); }); // ==================== resolveDefaultParams() FUNCTION ==================== describe('resolveDefaultParams()', () => { let mockContext: FlowRuntimeContext<FlowModel>; beforeEach(() => { mockContext = new FlowRuntimeContext(mockModel, 'testFlow', 'runtime'); mockContext.defineProperty('inputArgs', { value: { testExtra: 'testExtra' } }); }); describe('static parameter resolution', () => { test('should return static object directly', async () => { const staticParams = { param1: 'value1', param2: 'value2' }; const result = await resolveDefaultParams(staticParams, mockContext); expect(result).toEqual(staticParams); }); test('should return empty object for undefined params', async () => { const result = await resolveDefaultParams(undefined, mockContext); expect(result).toEqual({}); }); test('should return empty object for null params', async () => { const result = await resolveDefaultParams(null, mockContext); expect(result).toEqual({}); }); test('should handle complex static objects', async () => { const complexParams = { user: { name: 'John', age: 30 }, settings: { theme: 'dark', notifications: true }, array: [1, 2, 3], }; const result = await resolveDefaultParams(complexParams, mockContext); expect(result).toEqual(complexParams); }); }); describe('function parameter resolution', () => { test('should call function with context and return result', async () => { const paramsFn = vi.fn().mockReturnValue({ dynamic: 'value' }); const result = await resolveDefaultParams(paramsFn, mockContext); expect(paramsFn).toHaveBeenCalledWith(mockContext); expect(result).toEqual({ dynamic: 'value' }); }); test('should handle function accessing context properties', async () => { const paramsFn = vi.fn((ctx: FlowRuntimeContext<FlowModel>) => ({ modelUid: ctx.model.uid, extraData: ctx.inputArgs.testExtra, })); const result = await resolveDefaultParams(paramsFn, mockContext); expect(result).toEqual({ modelUid: mockModel.uid, extraData: 'testExtra', }); }); }); describe('async processing', () => { test('should handle async function correctly', async () => { const asyncParamsFn = vi.fn().mockResolvedValue({ async: 'result' }); const result = await resolveDefaultParams(asyncParamsFn, mockContext); expect(asyncParamsFn).toHaveBeenCalledWith(mockContext); expect(result).toEqual({ async: 'result' }); }); test('should handle async function with delay', async () => { const asyncParamsFn = vi.fn( () => new Promise((resolve) => setTimeout(() => resolve({ delayed: 'value' }), 10)), ); const result = await resolveDefaultParams(asyncParamsFn, mockContext); expect(result).toEqual({ delayed: 'value' }); }); }); }); // ==================== FlowExitException CLASS ==================== describe('FlowExitException', () => { describe('constructor', () => { test('should create exception with all parameters', () => { const exception = new FlowExitException('testFlow', 'model-123', 'Custom exit message'); expect(exception.flowKey).toBe('testFlow'); expect(exception.modelUid).toBe('model-123'); expect(exception.message).toBe('Custom exit message'); expect(exception.name).toBe('FlowExitException'); }); test('should create exception with default message', () => { const exception = new FlowExitException('testFlow', 'model-123'); expect(exception.flowKey).toBe('testFlow'); expect(exception.modelUid).toBe('model-123'); expect(exception.message).toBe("Flow 'testFlow' on model 'model-123' exited via ctx.exit()."); expect(exception.name).toBe('FlowExitException'); }); test('should create exception with empty string message', () => { const exception = new FlowExitException('testFlow', 'model-123', ''); // Empty string is falsy, so the default message is used in the constructor expect(exception.message).toBe("Flow 'testFlow' on model 'model-123' exited via ctx.exit()."); }); }); describe('property access', () => { test('should have readonly properties accessible', () => { const exception = new FlowExitException('flowKey', 'modelUid'); expect(exception.flowKey).toBe('flowKey'); expect(exception.modelUid).toBe('modelUid'); }); }); }); // ==================== defineAction() FUNCTION ==================== describe('defineAction()', () => { describe('basic functionality', () => { test('should return action definition unchanged', () => { const actionDef: ActionDefinition = { name: 'testAction', handler: vi.fn(), }; const result = defineAction(actionDef); expect(result).toBe(actionDef); expect(result).toEqual(actionDef); }); test('should handle complex action definition', () => { const complexAction: ActionDefinition = { name: 'complexAction', handler: vi.fn().mockResolvedValue('result'), defaultParams: { param1: 'value1' }, uiSchema: { field1: { type: 'string', title: 'Field 1' }, }, }; const result = defineAction(complexAction); expect(result).toBe(complexAction); expect(result.name).toBe('complexAction'); expect(result.defaultParams).toEqual({ param1: 'value1' }); expect(result.uiSchema).toEqual({ field1: { type: 'string', title: 'Field 1' }, }); }); }); }); // ==================== compileUiSchema() FUNCTION ==================== describe('compileUiSchema()', () => { let mockScope: Record<string, unknown>; beforeEach(() => { mockScope = { t: vi.fn((key: string) => `translated_${key}`), randomString: vi.fn(() => 'random123'), user: { name: 'John', role: 'admin' }, }; }); describe('expression compilation', () => { test('should compile simple expressions', () => { const result = compileUiSchema(mockScope, "{{ t('Hello World') }}"); expect(mockScope.t).toHaveBeenCalledWith('Hello World'); expect(typeof result).toBe('string'); }); test('should compile expressions with variables', () => { const result = compileUiSchema(mockScope, '{{ user.name }}'); expect(result).toBe('John'); }); test('should compile complex expressions', () => { const result = compileUiSchema(mockScope, "{{ user.role === 'admin' ? 'Administrator' : 'User' }}"); expect(result).toBe('Administrator'); }); test('should handle non-expression strings', () => { const result = compileUiSchema(mockScope, 'Plain string without expressions'); expect(result).toBe('Plain string without expressions'); }); }); describe('caching mechanism', () => { test('should cache compiled results', () => { const schema = "{{ t('Cached Test') }}"; const result1 = compileUiSchema(mockScope, schema); const result2 = compileUiSchema(mockScope, schema); expect(result1).toBe(result2); // Schema.compile should be called once and then cached expect(mockScope.t).toHaveBeenCalledTimes(1); }); test('should bypass cache when noCache option is true', () => { const schema = "{{ t('No Cache Test') }}"; compileUiSchema(mockScope, schema, { noCache: false }); compileUiSchema(mockScope, schema, { noCache: true }); // t function should be called twice when bypassing cache expect(mockScope.t).toHaveBeenCalledTimes(2); }); test('should cache object compilations', () => { const schema = { title: "{{ t('Object Title') }}", description: 'Static description', }; const result1 = compileUiSchema(mockScope, schema); const result2 = compileUiSchema(mockScope, schema); expect(result1).toBe(result2); expect(result1.title).toBeDefined(); expect(result1.description).toBe('Static description'); }); test('should cache array compilations', () => { const schema = [{ title: "{{ t('Item 1') }}" }, { title: "{{ t('Item 2') }}" }]; const result1 = compileUiSchema(mockScope, schema); const result2 = compileUiSchema(mockScope, schema); expect(result1).toBe(result2); expect(Array.isArray(result1)).toBe(true); expect(result1).toHaveLength(2); }); }); describe('object compilation', () => { test('should compile objects with template strings', () => { const schema = { title: "{{ t('Form Title') }}", description: 'Static description', user: '{{ user.name }}', role: '{{ user.role }}', }; const result = compileUiSchema(mockScope, schema); expect(typeof result.title).toBe('string'); expect(result.description).toBe('Static description'); expect(result.user).toBe('John'); expect(result.role).toBe('admin'); }); test('should handle nested objects', () => { const schema = { form: { title: "{{ t('Nested Form') }}", fields: { username: { label: "{{ t('Username') }}", placeholder: "{{ t('Enter username') }}", }, }, }, }; const result = compileUiSchema(mockScope, schema); expect(typeof result.form.title).toBe('string'); expect(typeof result.form.fields.username.label).toBe('string'); expect(typeof result.form.fields.username.placeholder).toBe('string'); }); test('should handle arrays within objects', () => { const schema = { items: [ { title: "{{ t('Item 1') }}", value: 1 }, { title: "{{ t('Item 2') }}", value: 2 }, ], metadata: { count: "{{ user.role === 'admin' ? 'unlimited' : '10' }}", }, }; const result = compileUiSchema(mockScope, schema); expect(Array.isArray(result.items)).toBe(true); expect(result.items).toHaveLength(2); expect(result.items[0].value).toBe(1); expect(result.items[1].value).toBe(2); expect(result.metadata.count).toBe('unlimited'); }); test('should preserve non-template properties', () => { const schema = { type: 'object', properties: { name: { type: 'string', title: "{{ t('Name Field') }}", maxLength: 100, }, }, }; const result = compileUiSchema(mockScope, schema); expect(result.type).toBe('object'); expect(result.properties.name.type).toBe('string'); expect(typeof result.properties.name.title).toBe('string'); expect(result.properties.name.maxLength).toBe(100); }); }); }); // ==================== resolveDefaultOptions() FUNCTION ==================== describe('resolveDefaultOptions()', () => { describe('static options resolution', () => { test('should return static object directly', async () => { const staticOptions = { option1: 'value1', option2: 'value2' }; const result = await resolveDefaultOptions(staticOptions, mockModel.context); expect(result).toEqual(staticOptions); }); test('should return empty object for undefined options', async () => { const result = await resolveDefaultOptions(undefined, mockModel.context); expect(result).toEqual({}); }); test('should return empty object for null options', async () => { const result = await resolveDefaultOptions(null, mockModel.context); expect(result).toEqual({}); }); test('should handle complex static objects', async () => { const complexOptions = { use: 'TableModel', config: { columns: ['id', 'name'], pagination: true, }, stepParams: { default: { step1: { dataSourceKey: 'main' }, }, }, }; const result = await resolveDefaultOptions(complexOptions, mockModel.context); expect(result).toEqual(complexOptions); }); }); describe('function options resolution', () => { test('should call function with parent model and return result', async () => { const optionsFn = vi.fn().mockReturnValue({ dynamic: 'value' }); const result = await resolveDefaultOptions(optionsFn, mockModel.context); expect(optionsFn).toHaveBeenCalledWith(mockModel.context, undefined); expect(result).toEqual({ dynamic: 'value' }); }); test('should handle function accessing model properties', async () => { const optionsFn = vi.fn((parentContext: any) => ({ use: 'DynamicModel', parentUid: parentContext.model.uid, sortIndex: parentContext.model.sortIndex || 0, })); const result = await resolveDefaultOptions(optionsFn, mockModel.context); expect(result).toEqual({ use: 'DynamicModel', parentUid: mockModel.uid, sortIndex: 0, }); }); test('should handle async function correctly', async () => { const asyncOptionsFn = vi.fn().mockResolvedValue({ async: 'result' }); const result = await resolveDefaultOptions(asyncOptionsFn, mockModel.context); expect(asyncOptionsFn).toHaveBeenCalledWith(mockModel.context, undefined); expect(result).toEqual({ async: 'result' }); }); test('should handle async function with delay', async () => { const asyncOptionsFn = vi.fn( () => new Promise((resolve) => setTimeout(() => resolve({ delayed: 'value' }), 10)), ); const result = await resolveDefaultOptions(asyncOptionsFn, mockModel.context); expect(result).toEqual({ delayed: 'value' }); }); }); describe('error handling', () => { test('should handle function throwing errors and return empty object', async () => { const errorFn = vi.fn(() => { throw new Error('Options generation error'); }); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const result = await resolveDefaultOptions(errorFn, mockModel.context); expect(result).toEqual({}); expect(consoleSpy).toHaveBeenCalledWith('Error resolving createModelOptions function:', expect.any(Error)); consoleSpy.mockRestore(); }); test('should handle async function rejection and return empty object', async () => { const rejectFn = vi.fn().mockRejectedValue(new Error('Async error')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const result = await resolveDefaultOptions(rejectFn, mockModel.context); expect(result).toEqual({}); expect(consoleSpy).toHaveBeenCalledWith('Error resolving createModelOptions function:', expect.any(Error)); consoleSpy.mockRestore(); }); test('should handle function returning null and convert to empty object', async () => { const nullFn = vi.fn().mockReturnValue(null); const result = await resolveDefaultOptions(nullFn, mockModel.context); expect(result).toEqual({}); }); test('should handle function returning undefined and convert to empty object', async () => { const undefinedFn = vi.fn().mockReturnValue(undefined); const result = await resolveDefaultOptions(undefinedFn, mockModel.context); expect(result).toEqual({}); }); }); }); // ==================== resolveStepUiSchema() FUNCTION ==================== describe('resolveStepUiSchema()', () => { let mockFlow: any; let mockStep: StepDefinition; let mockAction: any; beforeEach(() => { mockFlow = { key: 'testFlow', title: 'Test Flow', steps: { testStep: { handler: vi.fn(), }, }, }; mockStep = { handler: vi.fn().mockResolvedValue('step-result'), title: 'Test Step', }; mockAction = { name: 'testAction', handler: vi.fn().mockResolvedValue('action-result'), title: 'Test Action', }; }); describe('basic functionality', () => { test('should return null when no uiSchema is available', async () => { // Step with no uiSchema and no action const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toBeNull(); }); test('should return null when step and action both have empty uiSchema', async () => { mockStep.uiSchema = {}; mockAction.uiSchema = {}; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toBeNull(); }); test('should return step uiSchema when only step has uiSchema', async () => { const stepUiSchema = { field1: { type: 'string', title: 'Field 1' }, field2: { type: 'number', title: 'Field 2' }, }; mockStep.uiSchema = stepUiSchema; const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toEqual(stepUiSchema); }); test('should return action uiSchema when only action has uiSchema', async () => { const actionUiSchema = { actionField: { type: 'string', title: 'Action Field' }, }; mockAction.uiSchema = actionUiSchema; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toEqual(actionUiSchema); }); }); describe('merging behavior', () => { test('should return step uiSchema when both step and action have uiSchema', async () => { const actionUiSchema = { field1: { type: 'string', title: 'Action Field 1' }, field2: { type: 'number', title: 'Action Field 2' }, }; const stepUiSchema = { field1: { type: 'string', title: 'Step Field 1' }, // Should override action field3: { type: 'boolean', title: 'Step Field 3' }, // Should be added }; mockAction.uiSchema = actionUiSchema; mockStep.uiSchema = stepUiSchema; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toEqual({ field1: { type: 'string', title: 'Step Field 1' }, field3: { type: 'boolean', title: 'Step Field 3' }, }); }); }); describe('dynamic uiSchema resolution', () => { test('should resolve function-based step uiSchema', async () => { const dynamicStepUiSchema = vi.fn().mockResolvedValue({ dynamicField: { type: 'string', title: 'Dynamic Field' }, }); mockStep.uiSchema = dynamicStepUiSchema; const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(dynamicStepUiSchema).toHaveBeenCalled(); expect(result).toEqual({ dynamicField: { type: 'string', title: 'Dynamic Field' }, }); }); test('should resolve function-based action uiSchema', async () => { const dynamicActionUiSchema = vi.fn().mockResolvedValue({ actionDynamicField: { type: 'number', title: 'Action Dynamic Field' }, }); mockAction.uiSchema = dynamicActionUiSchema; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(dynamicActionUiSchema).toHaveBeenCalled(); expect(result).toEqual({ actionDynamicField: { type: 'number', title: 'Action Dynamic Field' }, }); }); test('should resolve both dynamic step and action uiSchemas', async () => { const dynamicActionUiSchema = vi.fn().mockResolvedValue({ actionField: { type: 'string', title: 'Action Field' }, sharedField: { type: 'string', title: 'Action Shared' }, }); const dynamicStepUiSchema = vi.fn().mockResolvedValue({ stepField: { type: 'number', title: 'Step Field' }, sharedField: { type: 'string', title: 'Step Shared' }, // Should override }); mockAction.uiSchema = dynamicActionUiSchema; mockStep.uiSchema = dynamicStepUiSchema; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(dynamicActionUiSchema).not.toHaveBeenCalled(); expect(dynamicStepUiSchema).toHaveBeenCalled(); expect(result).toEqual({ stepField: { type: 'number', title: 'Step Field' }, sharedField: { type: 'string', title: 'Step Shared' }, }); }); test('should return null when dynamic uiSchemas resolve to empty objects', async () => { const emptyActionUiSchema = vi.fn().mockResolvedValue({}); const emptyStepUiSchema = vi.fn().mockResolvedValue({}); mockAction.uiSchema = emptyActionUiSchema; mockStep.uiSchema = emptyStepUiSchema; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toBeNull(); }); test('should return null when dynamic uiSchemas resolve to null/undefined', async () => { const nullActionUiSchema = vi.fn().mockResolvedValue(null); const undefinedStepUiSchema = vi.fn().mockResolvedValue(undefined); mockAction.uiSchema = nullActionUiSchema; mockStep.uiSchema = undefinedStepUiSchema; mockStep.use = 'testAction'; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(mockAction); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toBeNull(); }); }); describe('action handling', () => { test('should handle missing action gracefully', async () => { mockStep.use = 'nonExistentAction'; mockStep.uiSchema = { field1: { type: 'string', title: 'Field 1' }, }; mockModel.flowEngine.getAction = vi.fn().mockReturnValue(null); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toEqual({ field1: { type: 'string', title: 'Field 1' }, }); }); test('should handle action without uiSchema', async () => { mockStep.use = 'testAction'; mockStep.uiSchema = { field1: { type: 'string', title: 'Field 1' }, }; mockModel.flowEngine.getAction = vi.fn().mockReturnValue({ ...mockAction, uiSchema: undefined, }); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toEqual({ field1: { type: 'string', title: 'Field 1' }, }); }); test('should handle getAction throwing error', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); mockStep.use = 'errorAction'; mockStep.uiSchema = { field1: { type: 'string', title: 'Field 1' }, }; mockModel.flowEngine.getAction = vi.fn(() => { throw new Error('Action retrieval error'); }); const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(consoleSpy).toHaveBeenCalledWith('Failed to get action errorAction:', expect.any(Error)); expect(result).toEqual({ field1: { type: 'string', title: 'Field 1' }, }); consoleSpy.mockRestore(); }); test('should handle missing getAction method', async () => { // Mock a flowEngine without getAction method const originalGetAction = mockModel.flowEngine.getAction; mockModel.flowEngine.getAction = undefined; mockStep.use = 'testAction'; mockStep.uiSchema = { field1: { type: 'string', title: 'Field 1' }, }; const result = await resolveStepUiSchema(mockModel, mockFlow, mockStep); expect(result).toEqual({ field1: { type: 'string', title: 'Field 1' }, }); // Restore the original method mockModel.flowEngine.getAction = originalGetAction; }); }); }); });