@nocobase/flow-engine
Version:
A standalone flow engine for NocoBase, managing workflows, models, and actions.
513 lines (435 loc) • 15.3 kB
text/typescript
/**
* 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 { vitest } from 'vitest';
import { FlowEngine } from '../../flowEngine';
import { FlowModel } from '../flowModel';
describe('InstanceFlowRegistry (extended)', () => {
const createModel = () => {
const engine = new FlowEngine();
class MyModel extends FlowModel {
foo = 'bar';
}
engine.registerModels({ MyModel });
const model = engine.createModel({
use: 'MyModel',
});
return model;
};
test('setStep and hasStep', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('flow1', {
title: 'Flow 1',
steps: { step1: { title: 'Step 1' } as any },
});
expect(flow.hasStep('step1')).toBe(true);
flow.setStep('step1', { title: 'Step 1 (updated)' });
expect(flow.getStep('step1').serialize()).toEqual({
flowKey: 'flow1',
key: 'step1',
title: 'Step 1 (updated)',
sort: 1,
});
});
test('removeStep', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('flow1', {
title: 'Flow 1',
steps: {
step1: { title: 'Step 1' } as any,
step2: { title: 'Step 2' } as any,
},
});
expect(flow.getSteps().size).toBe(2);
flow.removeStep('step1');
expect(flow.hasStep('step1')).toBe(false);
expect(flow.getSteps().size).toBe(1);
});
test('addFlows adds multiple flows locally', () => {
const model = createModel();
model.flowRegistry.addFlows({
flowA: { title: 'A', steps: {} },
flowB: { title: 'B', steps: { s1: { title: 'S1' } as any } },
});
expect(model.flowRegistry.hasFlow('flowA')).toBe(true);
expect(model.flowRegistry.hasFlow('flowB')).toBe(true);
const flow = model.flowRegistry.getFlow('flowB');
const step = flow?.getStep('s1');
expect(step?.serialize()).toEqual({
title: 'S1',
key: 's1',
flowKey: 'flowB',
sort: 1,
});
});
test('saveFlow/saveStep/destroyStep/destroyFlow call model.saveStepParams()', async () => {
const model = createModel();
const saveStepParamsSpy = vitest.spyOn(model as any, 'saveStepParams').mockResolvedValue(undefined);
const flow = model.flowRegistry.addFlow('flow1', {
title: 'Flow 1',
steps: {
step1: { title: 'Step 1' } as any,
step2: { title: 'Step 2' } as any,
},
});
await flow.save();
expect(saveStepParamsSpy).toHaveBeenCalledTimes(1);
await flow.destroyStep('step2');
expect(saveStepParamsSpy).toHaveBeenCalledTimes(2);
expect(flow.hasStep('step2')).toBe(false);
await flow.destroy();
expect(saveStepParamsSpy).toHaveBeenCalledTimes(3);
expect(model.flowRegistry.hasFlow('flow1')).toBe(false);
});
// InstanceFlowRegistry moveStep tests
test('moveStep reorders steps with integer sort values', async () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('testFlow', {
title: 'Test Flow',
steps: {
step1: { title: 'Step 1' } as any,
step2: { title: 'Step 2' } as any,
step3: { title: 'Step 3' } as any,
},
});
// Move step3 before step2
await model.flowRegistry.moveStep('testFlow', 'step3', 'step2');
// Verify new order
const reorderedSteps = Object.keys(flow.steps);
expect(reorderedSteps).toEqual(['step1', 'step3', 'step2']);
// Verify sort values are integers and in correct order
expect(flow.getStep('step1')?.serialize().sort).toBe(1);
expect(flow.getStep('step3')?.serialize().sort).toBe(2);
expect(flow.getStep('step2')?.serialize().sort).toBe(3);
});
test('moveStep calls model.saveStepParams after reordering', async () => {
const model = createModel();
const saveStepParamsSpy = vitest.spyOn(model as any, 'saveStepParams').mockResolvedValue(undefined);
model.flowRegistry.addFlow('testFlow', {
title: 'Test Flow',
steps: {
step1: { title: 'Step 1' } as any,
step2: { title: 'Step 2' } as any,
},
});
await model.flowRegistry.moveStep('testFlow', 'step1', 'step2');
expect(saveStepParamsSpy).toHaveBeenCalledTimes(1);
saveStepParamsSpy.mockRestore();
});
test('moveStep throws error for non-existent flow', async () => {
const model = createModel();
const saveStepParamsSpy = vitest.spyOn(model as any, 'saveStepParams').mockResolvedValue(undefined);
await expect(model.flowRegistry.moveStep('nonExistentFlow', 'step1', 'step2')).rejects.toThrow(
"Flow 'nonExistentFlow' not found",
);
expect(saveStepParamsSpy).not.toHaveBeenCalled();
saveStepParamsSpy.mockRestore();
});
test('moveStep throws error for non-existent steps', async () => {
const model = createModel();
const saveStepParamsSpy = vitest.spyOn(model as any, 'saveStepParams').mockResolvedValue(undefined);
model.flowRegistry.addFlow('testFlow', {
title: 'Test Flow',
steps: {
step1: { title: 'Step 1' } as any,
step2: { title: 'Step 2' } as any,
},
});
await expect(model.flowRegistry.moveStep('testFlow', 'nonExistentStep', 'step2')).rejects.toThrow(
"Source step 'nonExistentStep' not found",
);
expect(saveStepParamsSpy).not.toHaveBeenCalled();
saveStepParamsSpy.mockRestore();
});
test('toData merges steps into options snapshot', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('flow1', {
title: 'Flow 1',
steps: { step1: { title: 'Step 1', sort: 10 } as any },
sort: 1,
manual: true,
});
const data = flow.toData();
expect(data.key).toBe('flow1');
expect(data.title).toBe('Flow 1');
expect(data.sort).toBe(1);
expect(data.manual).toBe(true);
expect(data.steps.step1).toEqual({
title: 'Step 1',
sort: 10,
key: 'step1',
flowKey: 'flow1',
});
// 修改原步骤对象,不应影响已生成的快照
(flow.getStep('step1') as any).title = 'Changed';
expect(data.steps.step1.title).toBe('Step 1');
});
test('model serialize', () => {
const model = createModel();
model.flowRegistry.addFlow('flow1', {
title: 'Flow 1',
steps: { step1: { title: 'Step 1', sort: 10 } as any },
sort: 1,
manual: true,
});
const flows = model.serialize().flowRegistry;
console.log(flows);
expect(flows.flow1).toBeDefined();
expect(flows.flow1).toEqual({
key: 'flow1',
title: 'Flow 1',
sort: 1,
manual: true,
steps: {
step1: {
title: 'Step 1',
sort: 10,
key: 'step1',
flowKey: 'flow1',
},
},
});
});
test('model init flowRegistry', () => {
const engine = new FlowEngine();
class MyModel extends FlowModel {
foo = 'bar';
}
engine.registerModels({ MyModel });
const model = engine.createModel({
use: 'MyModel',
flowRegistry: {
flow1: {
title: 'Flow 1',
steps: { step1: { title: 'Step 1', sort: 10 } as any },
sort: 1,
manual: true,
},
},
});
const flows = model.serialize().flowRegistry;
console.log(flows);
expect(flows.flow1).toBeDefined();
expect(flows.flow1).toEqual({
key: 'flow1',
title: 'Flow 1',
sort: 1,
manual: true,
steps: {
step1: {
title: 'Step 1',
sort: 10,
key: 'step1',
flowKey: 'flow1',
},
},
});
});
// mapFlows 方法
test('mapFlows iterates over all flows', () => {
const model = createModel();
model.flowRegistry.addFlows({
flow1: { title: 'Flow 1', steps: {} },
flow2: { title: 'Flow 2', steps: {} },
flow3: { title: 'Flow 3', steps: {} },
});
const titles = model.flowRegistry.mapFlows((flow) => flow.title);
expect(titles).toEqual(['Flow 1', 'Flow 2', 'Flow 3']);
const keys = model.flowRegistry.mapFlows((flow) => flow.key);
expect(keys).toEqual(['flow1', 'flow2', 'flow3']);
});
// getFlows 返回 Map
test('getFlows returns Map of flows', () => {
const model = createModel();
model.flowRegistry.addFlow('test', { title: 'Test', steps: {} });
const flows = model.flowRegistry.getFlows();
expect(flows instanceof Map).toBe(true);
expect(flows.size).toBe(1);
expect(flows.has('test')).toBe(true);
});
// removeFlow 独立测试
test('removeFlow deletes flow from registry', () => {
const model = createModel();
model.flowRegistry.addFlow('test', { title: 'Test', steps: {} });
expect(model.flowRegistry.hasFlow('test')).toBe(true);
model.flowRegistry.removeFlow('test');
expect(model.flowRegistry.hasFlow('test')).toBe(false);
});
// FlowDefinition setters
test('FlowDefinition setters update properties', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
manual: false,
on: 'update',
steps: {},
});
flow.title = 'Updated Title';
flow.manual = true;
flow.on = 'create';
expect(flow.title).toBe('Updated Title');
expect(flow.manual).toBe(true);
expect(flow.on).toBe('create');
});
// FlowDefinition setOptions
test('FlowDefinition setOptions updates flow properties', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', { title: 'Test', steps: {} });
flow.setOptions({
title: 'New Title',
sort: 10,
manual: true,
on: 'delete',
});
expect(flow.title).toBe('New Title');
expect(flow.sort).toBe(10);
expect(flow.manual).toBe(true);
expect(flow.on).toBe('delete');
});
// mapSteps 方法
test('FlowDefinition mapSteps iterates over all steps', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
steps: {
step1: { title: 'Step 1', sort: 10 } as any,
step2: { title: 'Step 2', sort: 20 } as any,
step3: { title: 'Step 3', sort: 30 } as any,
},
});
const stepTitles = flow.mapSteps((step) => step.title);
expect(stepTitles).toEqual(['Step 1', 'Step 2', 'Step 3']);
const stepKeys = flow.mapSteps((step) => step.key);
expect(stepKeys).toEqual(['step1', 'step2', 'step3']);
});
// FlowStep 属性和方法
test('FlowStep properties and methods', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
steps: {
step1: {
title: 'Step 1',
uiSchema: { type: 'object' },
defaultParams: { param1: 'value1' },
use: 'someAction',
sort: 10,
} as any,
},
});
const step = flow.getStep('step1');
expect(step?.key).toBe('step1');
expect(step?.flowKey).toBe('test');
expect(step?.title).toBe('Step 1');
expect(step?.uiSchema).toEqual({ type: 'object' });
expect(step?.defaultParams).toEqual({ param1: 'value1' });
expect(step?.use).toBe('someAction');
// 测试 title setter
if (step) {
step.title = 'Updated Step Title';
expect(step.title).toBe('Updated Step Title');
}
});
// FlowStep setOptions
test('FlowStep setOptions updates step properties', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
steps: {
step1: { title: 'Step 1' } as any,
},
});
const step = flow.getStep('step1');
if (step) {
step.setOptions({
title: 'Updated Step',
uiSchema: {
test: {
type: 'string',
},
},
defaultParams: { newParam: 'newValue' },
use: 'newAction',
});
expect(step.title).toBe('Updated Step');
expect(step.uiSchema).toEqual({ test: { type: 'string' } });
expect(step.defaultParams).toEqual({ newParam: 'newValue' });
expect(step.use).toBe('newAction');
}
});
// 边界情况和错误处理
test('handles non-existent flow operations gracefully', () => {
const model = createModel();
expect(model.flowRegistry.getFlow('nonexistent')).toBeUndefined();
expect(model.flowRegistry.hasFlow('nonexistent')).toBe(false);
// removeFlow on non-existent should not throw
expect(() => model.flowRegistry.removeFlow('nonexistent')).not.toThrow();
// mapFlows on empty registry
const emptyResults = model.flowRegistry.mapFlows((flow) => flow.title);
expect(emptyResults).toEqual([]);
});
// addStep 与现有 key 的行为
test('addStep with existing key updates step', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
steps: { step1: { title: 'Original' } as any },
});
expect(flow.getSteps().size).toBe(1);
expect(flow.getStep('step1')?.title).toBe('Original');
// 使用相同的 key 添加步骤应该更新现有步骤
const updatedStep = flow.addStep('step1', { title: 'Updated' });
expect(updatedStep.title).toBe('Updated');
expect(flow.getSteps().size).toBe(1); // 仍然只有一个步骤
expect(flow.getStep('step1')?.title).toBe('Updated');
});
// FlowStep 的异步方法
test('FlowStep async methods call flowDef methods', async () => {
const model = createModel();
const saveStepParamsSpy = vitest.spyOn(model as any, 'saveStepParams').mockResolvedValue(undefined);
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
steps: { step1: { title: 'Step 1' } as any },
});
const step = flow.getStep('step1');
// 测试 step.save()
if (step) {
await step.save();
expect(saveStepParamsSpy).toHaveBeenCalledTimes(1);
// 测试 step.remove()
step.remove();
expect(flow.hasStep('step1')).toBe(false);
}
// 重新添加步骤来测试 destroy
flow.addStep('step2', { title: 'Step 2' });
const step2 = flow.getStep('step2');
if (step2) {
await step2.destroy();
expect(saveStepParamsSpy).toHaveBeenCalledTimes(2);
expect(flow.hasStep('step2')).toBe(false);
}
});
// FlowDefinition defaultParams 的默认值
test('FlowStep defaultParams returns empty object when undefined', () => {
const model = createModel();
const flow = model.flowRegistry.addFlow('test', {
title: 'Test',
steps: { step1: { title: 'Step 1' } as any }, // 没有 defaultParams
});
const step = flow.getStep('step1');
expect(step?.defaultParams).toEqual({});
});
// 空的 addFlows 调用
test('addFlows handles empty or null input', () => {
const model = createModel();
expect(() => model.flowRegistry.addFlows({})).not.toThrow();
expect(() => model.flowRegistry.addFlows(null as any)).not.toThrow();
expect(() => model.flowRegistry.addFlows(undefined as any)).not.toThrow();
expect(model.flowRegistry.getFlows().size).toBe(0);
});
});