@nocobase/flow-engine
Version:
A standalone flow engine for NocoBase, managing workflows, models, and actions.
1,564 lines (1,358 loc) • 67.6 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 { describe, expect, it, vi } from 'vitest';
import { FlowContext, FlowRuntimeContext } from '../flowContext';
import { FlowEngine } from '../flowEngine';
import { FlowModel } from '../models/flowModel';
describe('FlowContext properties and methods', () => {
it('should return static property value', () => {
const ctx = new FlowContext();
ctx.defineProperty('foo', { value: 123 });
expect(ctx.foo).toBe(123);
});
it('should return sync value from get', () => {
const ctx = new FlowContext();
ctx.defineProperty('bar', { get: () => 456 });
expect(ctx.bar).toBe(456);
});
it('should return async value from get', async () => {
const ctx = new FlowContext();
ctx.defineProperty('baz', { get: async () => 'hello' });
expect(await ctx.baz).toBe('hello');
});
it('should support context reference in get', () => {
const ctx = new FlowContext();
ctx.defineProperty('a', { get: () => 'a' });
ctx.defineProperty('b', { get: (ctx) => ctx.a + 'b' });
expect(ctx.b).toBe('ab');
});
it('should support context reference in get', () => {
const ctx1 = new FlowContext();
ctx1.defineProperty('a', { get: () => 'a' });
const ctx = new FlowContext();
ctx.addDelegate(ctx1);
ctx.defineProperty('b', { get: () => ctx.a + 'b' });
expect(ctx.b).toBe('ab');
});
it('should pass correct context instance to getter function in delegate chain', () => {
class FlowContext1 extends FlowContext {}
class FlowContext2 extends FlowContext {}
const ctx1 = new FlowContext1();
ctx1.defineProperty('a', { cache: false, get: (ctx) => ctx });
const ctx2 = new FlowContext2();
ctx2.addDelegate(ctx1);
expect(ctx1.a).toBe(ctx1);
expect(ctx2.a).toBe(ctx2);
});
it('should support async context reference in get', async () => {
const ctx = new FlowContext();
ctx.defineProperty('c', { get: async () => 'c' });
ctx.defineProperty('d', { get: async (ctx) => (await ctx.c) + 'd' });
expect(await ctx.d).toBe('cd');
});
it('should queue and reuse promise for concurrent async get calls', async () => {
const ctx = new FlowContext();
const getter = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return 'async-value';
});
ctx.defineProperty('concurrent', { get: getter });
// 并发调用
const [v1, v2, v3] = await Promise.all([ctx.concurrent, ctx.concurrent, ctx.concurrent]);
expect(v1).toBe('async-value');
expect(v2).toBe('async-value');
expect(v3).toBe('async-value');
// 只会调用一次 getter
expect(getter).toHaveBeenCalledTimes(1);
// 再次调用,因已缓存,不会再调用 getter
const v4 = await ctx.concurrent;
expect(v4).toBe('async-value');
expect(getter).toHaveBeenCalledTimes(1);
});
it('should cache get result by default', async () => {
const ctx = new FlowContext();
const getter = vi.fn().mockResolvedValue(789);
ctx.defineProperty('cached', { get: getter });
await ctx.cached;
await ctx.cached;
expect(getter).toHaveBeenCalledTimes(1);
});
it('should not cache get result when cache=false', async () => {
const ctx = new FlowContext();
const getter = vi.fn().mockResolvedValue(101112);
ctx.defineProperty('noCache', { get: getter, cache: false });
await ctx.noCache;
await ctx.noCache;
expect(getter).toHaveBeenCalledTimes(2);
});
it('should remove cache and pending for property', async () => {
const ctx = new FlowContext();
const getter = vi.fn().mockResolvedValue('cached-value');
ctx.defineProperty('foo', { get: getter });
// 首次获取,触发缓存
await ctx.foo;
expect(getter).toHaveBeenCalledTimes(1);
// 再次获取,命中缓存
await ctx.foo;
expect(getter).toHaveBeenCalledTimes(1);
// 清除缓存
ctx.removeCache('foo');
// 再次获取,应重新调用 getter
await ctx.foo;
expect(getter).toHaveBeenCalledTimes(2);
});
it('should remove cache and pending recursively in delegates', async () => {
const delegate = new FlowContext();
const getter = vi.fn().mockResolvedValue('delegate-value');
delegate.defineProperty('bar', { get: getter });
const ctx = new FlowContext();
ctx.addDelegate(delegate);
// 首次获取,触发缓存
await ctx.bar;
expect(getter).toHaveBeenCalledTimes(1);
// 清除缓存(应递归到 delegate)
ctx.removeCache('bar');
// 再次获取,应重新调用 getter
await ctx.bar;
expect(getter).toHaveBeenCalledTimes(2);
});
it('should support delegate context property', () => {
const delegate = new FlowContext();
delegate.defineProperty('shared', { value: 'from delegate' });
const ctx = new FlowContext();
ctx.addDelegate(delegate);
expect(ctx.shared).toBe('from delegate');
});
it('should throw sync error in get', () => {
const ctx = new FlowContext();
ctx.defineProperty('error', {
get: () => {
throw new Error('Oops');
},
});
expect(() => ctx.error).toThrow('Oops');
});
it('should throw async error in get', async () => {
const ctx = new FlowContext();
ctx.defineProperty('errorAsync', {
get: async () => {
throw new Error('Async Oops');
},
});
await expect(ctx.errorAsync).rejects.toThrow('Async Oops');
});
it('should find property in multi-level delegate chain', () => {
const root = new FlowContext();
root.defineProperty('deep', { value: 42 });
const mid = new FlowContext();
mid.addDelegate(root);
const ctx = new FlowContext();
ctx.addDelegate(mid);
expect(ctx.deep).toBe(42);
});
it('should allow local property to override delegate property', () => {
const delegate = new FlowContext();
delegate.defineProperty('foo', { value: 'delegate' });
const ctx = new FlowContext();
ctx.addDelegate(delegate);
ctx.defineProperty('foo', { value: 'local' });
expect(ctx.foo).toBe('local');
});
it('should return undefined for undefined property', () => {
const ctx = new FlowContext();
expect(ctx.nonExistent).toBeUndefined();
});
it('should override property when redefined', () => {
const ctx = new FlowContext();
ctx.defineProperty('dup', { value: 1 });
ctx.defineProperty('dup', { value: 2 });
expect(ctx.dup).toBe(2);
});
it('should support flat and nested meta in getPropertyMetaTree', () => {
const ctx = new FlowContext();
ctx.defineProperty('foo', {
meta: { type: 'string', title: 'Foo' },
});
ctx.defineProperty('bar', {
meta: {
type: 'object',
title: 'Bar',
properties: {
baz: { type: 'number', title: 'Baz' },
qux: { type: 'string', title: 'Qux' },
},
},
});
const tree = ctx.getPropertyMetaTree();
expect(tree).toEqual([
{
name: 'foo',
title: 'Foo',
type: 'string',
interface: undefined,
uiSchema: undefined,
paths: ['foo'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: undefined,
},
{
name: 'bar',
title: 'Bar',
type: 'object',
interface: undefined,
uiSchema: undefined,
paths: ['bar'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: [
{
name: 'baz',
title: 'Baz',
type: 'number',
interface: undefined,
uiSchema: undefined,
paths: ['bar', 'baz'],
parentTitles: ['Bar'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
{
name: 'qux',
title: 'Qux',
type: 'string',
interface: undefined,
uiSchema: undefined,
paths: ['bar', 'qux'],
parentTitles: ['Bar'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
],
},
]);
});
it('should support delegate meta and local override in getPropertyMetaTree', () => {
const delegate = new FlowContext();
delegate.defineProperty('foo', {
meta: { type: 'string', title: 'Delegate Foo', interface: 'text', uiSchema: { type: 'text' } },
});
delegate.defineProperty('bar', {
meta: { type: 'number', title: 'Delegate Bar', interface: 'number', uiSchema: { type: 'number' } },
});
const ctx = new FlowContext();
ctx.addDelegate(delegate);
ctx.defineProperty('bar', {
meta: {
type: 'object',
title: 'Local Bar',
interface: 'object',
uiSchema: { type: 'object' },
properties: {
x: { type: 'string', title: 'X', interface: 'text', uiSchema: { type: 'text' } },
},
},
});
const tree = ctx.getPropertyMetaTree();
expect(tree).toEqual([
{
name: 'foo',
title: 'Delegate Foo',
type: 'string',
interface: 'text',
uiSchema: { type: 'text' },
paths: ['foo'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: undefined,
},
{
name: 'bar',
title: 'Local Bar',
type: 'object',
interface: 'object',
uiSchema: { type: 'object' },
paths: ['bar'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: [
{
name: 'x',
title: 'X',
type: 'string',
interface: 'text',
uiSchema: { type: 'text' },
paths: ['bar', 'x'],
parentTitles: ['Local Bar'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
],
},
]);
});
it('should support single-level mixed sync and async properties in getPropertyMetaTree', async () => {
const ctx = new FlowContext();
// 同步属性
ctx.defineProperty('syncProp', {
meta: {
type: 'object',
title: 'Sync Property',
properties: {
field1: { type: 'string', title: 'Field 1' },
field2: { type: 'number', title: 'Field 2' },
},
},
});
// 异步属性
ctx.defineProperty('asyncProp', {
meta: {
type: 'object',
title: 'Async Property',
properties: async () => {
// 模拟异步加载
await new Promise((resolve) => setTimeout(resolve, 10));
return {
dynamicField1: { type: 'string', title: 'Dynamic Field 1' },
dynamicField2: { type: 'boolean', title: 'Dynamic Field 2' },
};
},
},
});
const tree = ctx.getPropertyMetaTree();
// 检查同步属性
expect(tree[0]).toEqual({
name: 'syncProp',
title: 'Sync Property',
type: 'object',
interface: undefined,
uiSchema: undefined,
paths: ['syncProp'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: [
{
name: 'field1',
title: 'Field 1',
type: 'string',
interface: undefined,
uiSchema: undefined,
paths: ['syncProp', 'field1'],
parentTitles: ['Sync Property'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
{
name: 'field2',
title: 'Field 2',
type: 'number',
interface: undefined,
uiSchema: undefined,
paths: ['syncProp', 'field2'],
parentTitles: ['Sync Property'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
],
});
// 检查异步属性
expect(tree[1].name).toBe('asyncProp');
expect(tree[1].title).toBe('Async Property');
expect(typeof tree[1].children).toBe('function');
// 调用异步函数获取子节点
const asyncChildren = await (tree[1].children as () => Promise<any>)();
expect(asyncChildren).toEqual([
{
name: 'dynamicField1',
title: 'Dynamic Field 1',
type: 'string',
interface: undefined,
uiSchema: undefined,
paths: ['asyncProp', 'dynamicField1'],
parentTitles: ['Async Property'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
{
name: 'dynamicField2',
title: 'Dynamic Field 2',
type: 'boolean',
interface: undefined,
uiSchema: undefined,
paths: ['asyncProp', 'dynamicField2'],
parentTitles: ['Async Property'],
disabled: false,
disabledReason: undefined,
children: undefined,
},
]);
});
it('should support multi-level mixed sync and async properties in getPropertyMetaTree', async () => {
const ctx = new FlowContext();
ctx.defineProperty('complexProp', {
meta: {
type: 'object',
title: 'Complex Property',
properties: {
// 第一层:同步属性包含异步子属性
syncWithAsync: {
type: 'object',
title: 'Sync with Async',
properties: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return {
asyncChild: { type: 'string', title: 'Async Child' },
syncChild: {
type: 'object',
title: 'Sync Child',
properties: {
deepField: { type: 'number', title: 'Deep Field' },
},
},
};
},
},
// 第一层:异步属性包含同步和异步子属性
asyncWithMixed: {
type: 'object',
title: 'Async with Mixed',
properties: async () => {
await new Promise((resolve) => setTimeout(resolve, 15));
return {
syncChild: {
type: 'string',
title: 'Sync Child in Async',
properties: {
deepSync: { type: 'boolean', title: 'Deep Sync' },
},
},
asyncChild: {
type: 'object',
title: 'Async Child in Async',
properties: async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return {
veryDeep: { type: 'string', title: 'Very Deep Field' },
};
},
},
};
},
},
},
},
});
const tree = ctx.getPropertyMetaTree();
const complexNode = tree[0];
expect(complexNode.name).toBe('complexProp');
expect(complexNode.title).toBe('Complex Property');
expect(Array.isArray(complexNode.children)).toBe(true);
const children = complexNode.children as any[];
expect(children).toHaveLength(2);
// 检查第一个子节点(同步属性包含异步子属性)
const syncWithAsyncNode = children[0];
expect(syncWithAsyncNode.name).toBe('syncWithAsync');
expect(typeof syncWithAsyncNode.children).toBe('function');
const syncWithAsyncChildren = await syncWithAsyncNode.children();
expect(syncWithAsyncChildren).toHaveLength(2);
expect(syncWithAsyncChildren[0].name).toBe('asyncChild');
expect(syncWithAsyncChildren[1].name).toBe('syncChild');
expect(Array.isArray(syncWithAsyncChildren[1].children)).toBe(true);
// 检查第二个子节点(异步属性包含混合子属性)
const asyncWithMixedNode = children[1];
expect(asyncWithMixedNode.name).toBe('asyncWithMixed');
expect(typeof asyncWithMixedNode.children).toBe('function');
const asyncWithMixedChildren = await asyncWithMixedNode.children();
expect(asyncWithMixedChildren).toHaveLength(2);
// 检查同步子节点
const syncChildInAsync = asyncWithMixedChildren[0];
expect(syncChildInAsync.name).toBe('syncChild');
expect(Array.isArray(syncChildInAsync.children)).toBe(true);
// 检查异步子节点
const asyncChildInAsync = asyncWithMixedChildren[1];
expect(asyncChildInAsync.name).toBe('asyncChild');
expect(typeof asyncChildInAsync.children).toBe('function');
const veryDeepChildren = await asyncChildInAsync.children();
expect(veryDeepChildren).toHaveLength(1);
expect(veryDeepChildren[0].name).toBe('veryDeep');
expect(veryDeepChildren[0].title).toBe('Very Deep Field');
});
it('should define and call instance method with defineMethod', () => {
const ctx = new FlowContext();
ctx.defineMethod('hello', function (name: string) {
return `Hello, ${name}!`;
});
expect(ctx.hello('World')).toBe('Hello, World!');
});
it('should support method lookup and this binding in delegate chain', () => {
const delegate = new FlowContext();
delegate.defineMethod('add', function (a: number, b: number) {
// this 指向 delegate
return a + b + (this.extra || 0);
});
delegate.extra = 1;
expect(delegate.add(1, 2)).toBe(4);
const ctx = new FlowContext();
ctx.addDelegate(delegate);
ctx.extra = 10;
expect(ctx.add(1, 2)).toBe(13);
// 确认 this 绑定到 delegate
ctx.extra = 100;
expect(ctx.add(1, 2)).toBe(103);
});
it('should allow local defineMethod to override delegate method', () => {
const delegate = new FlowContext();
delegate.defineMethod('greet', function (name: string) {
return `Hello from delegate, ${name}`;
});
const ctx = new FlowContext();
ctx.addDelegate(delegate);
// 覆盖 delegate 的 greet 方法
ctx.defineMethod('greet', function (name: string) {
return `Hello from local, ${name}`;
});
expect(ctx.greet('Copilot')).toBe('Hello from local, Copilot');
// delegate 仍然保持原方法
expect(delegate.greet('Copilot')).toBe('Hello from delegate, Copilot');
});
it('should define and call method that uses property', () => {
const ctx = new FlowContext();
ctx.defineProperty('foo', { value: 10 });
ctx.defineMethod('getFooPlus', function (n: number) {
return this.foo + n;
});
expect(ctx.getFooPlus(5)).toBe(15);
});
it('should call delegate method and access delegate property', () => {
const delegate = new FlowContext();
delegate.defineProperty('bar', { value: 20 });
delegate.defineMethod('getBarDouble', function () {
return this.bar * 2;
});
const ctx = new FlowContext();
ctx.addDelegate(delegate);
expect(ctx.getBarDouble()).toBe(40);
});
it('should allow local method to access local and delegate properties', () => {
const delegate = new FlowContext();
delegate.defineProperty('bar', { value: 7 });
const ctx = new FlowContext();
ctx.addDelegate(delegate);
ctx.defineProperty('foo', { value: 3 });
ctx.defineMethod('sumFooBar', function () {
return ctx.foo + ctx.bar;
});
expect(ctx.sumFooBar()).toBe(10);
});
it('should override delegate method and still access delegate property', () => {
const delegate = new FlowContext();
delegate.defineProperty('bar', { value: 100 });
delegate.defineMethod('getBar', function () {
return this.bar;
});
const ctx = new FlowContext();
ctx.addDelegate(delegate);
ctx.defineMethod('getBar', function () {
// 访问 delegate 的属性
return ctx.bar + 1;
});
expect(ctx.getBar()).toBe(101);
});
it('should support multi-level delegate method and property lookup', () => {
const root = new FlowContext();
root.defineProperty('num', { value: 5 });
root.defineMethod('getNum', function () {
return this.num;
});
const mid = new FlowContext();
mid.addDelegate(root);
const ctx = new FlowContext();
ctx.addDelegate(mid);
expect(ctx.getNum()).toBe(5);
});
it('should support multi-level delegate method and property lookup', () => {
const root = new FlowContext();
root.defineMethod('getSelf', function () {
return this;
});
const mid = new FlowContext();
mid.addDelegate(root);
const ctx = new FlowContext();
ctx.addDelegate(mid);
expect(root.getSelf()).toBe(root);
expect(mid.getSelf()).toBe(mid);
expect(ctx.getSelf()).toBe(ctx);
});
it('should support addDelegate and removeDelegate for multiple delegates', () => {
const d1 = new FlowContext();
d1.defineProperty('foo', { value: 'from d1' });
const d2 = new FlowContext();
d2.defineProperty('bar', { value: 'from d2' });
const ctx = new FlowContext();
ctx.addDelegate(d1);
ctx.addDelegate(d2);
// 能查到 d1 和 d2 的属性
expect(ctx.foo).toBe('from d1');
expect(ctx.bar).toBe('from d2');
// 移除 d1 后,foo 查不到
ctx.removeDelegate(d1);
expect(ctx.foo).toBeUndefined();
expect(ctx.bar).toBe('from d2');
// 移除 d2 后,bar 也查不到
ctx.removeDelegate(d2);
expect(ctx.bar).toBeUndefined();
});
it('should respect delegate priority: later addDelegate has higher priority', () => {
const d1 = new FlowContext();
d1.defineProperty('foo', { value: 'from d1' });
const d2 = new FlowContext();
d2.defineProperty('foo', { value: 'from d2' });
const ctx = new FlowContext();
ctx.addDelegate(d1);
ctx.addDelegate(d2);
// d2 优先
expect(ctx.foo).toBe('from d2');
// 移除 d2 后,d1 生效
ctx.removeDelegate(d2);
expect(ctx.foo).toBe('from d1');
// 再移除 d1,查不到
ctx.removeDelegate(d1);
expect(ctx.foo).toBeUndefined();
// 重新添加 d2,再添加 d1,d1 优先
ctx.addDelegate(d2);
ctx.addDelegate(d1);
expect(ctx.foo).toBe('from d1');
});
});
describe('FlowEngine context', () => {
it('should support defineProperty on FlowEngine.context', () => {
const engine = new FlowEngine();
engine.context.defineProperty('appName', { value: 'NocoBase' });
expect(engine.context.appName).toBe('NocoBase');
});
it('engine.context.runAction should resolve action from engine.getAction', async () => {
const engine = new FlowEngine();
engine.registerActions({
engineOnly: {
name: 'engineOnly',
defaultParams: { a: 1 },
handler: async (ctx: any, params: any) => {
return {
hasModel: !!ctx.model,
sum: (params.a || 0) + (params.b || 0),
};
},
},
});
const ret = await engine.context.runAction('engineOnly', { b: 2 });
expect(ret).toEqual({ hasModel: false, sum: 3 });
});
it('engine.context.getAction/getActions should expose global actions', async () => {
const engine = new FlowEngine();
const a1 = { name: 'a1', handler: () => 'ok1' };
const a2 = { name: 'a2', handler: () => 'ok2' };
engine.registerActions({ a1, a2 });
const def = engine.context.getAction('a1');
expect(def?.name).toBe('a1');
const actions = engine.context.getActions();
expect(actions).toBeInstanceOf(Map);
expect(Array.from(actions.keys())).toEqual(expect.arrayContaining(['a1', 'a2']));
});
});
describe('ForkFlowModel context inheritance and isolation', () => {
let engine: FlowEngine;
class TestModel extends FlowModel {}
beforeEach(() => {
engine = new FlowEngine();
engine.registerModels({ TestModel });
engine.context.defineProperty('appName', { value: 'NocoBase' });
});
it('should inherit engine.context property in FlowModel.context', () => {
const model = engine.createModel({ use: 'TestModel' });
expect(model.context.appName).toBe('NocoBase');
});
it('model.context.runAction should resolve via model.getAction and override global', async () => {
// 全局注册一个同名 action
engine.registerActions({
sum: {
name: 'sum',
defaultParams: { x: 100 },
handler: (ctx, params) => ({ from: 'engine', x: params.x }),
},
});
// 类级别注册同名 action,应该覆盖全局
TestModel.registerAction({
name: 'sum',
defaultParams: { x: 1 },
handler: (ctx, params) => ({ from: 'model', x: params.x, hasModel: !!ctx.model }),
});
const model = engine.createModel({ use: 'TestModel' });
const ret = await model.context.runAction('sum', {});
expect(ret).toEqual({ from: 'model', x: 1, hasModel: true });
});
it('model.context.getAction/getActions should prefer model actions over global', async () => {
// 全局注册 foo / bar
const engineFoo = { name: 'foo', handler: () => 'engineFoo' };
const engineBar = { name: 'bar', handler: () => 'engineBar' };
engine.registerActions({ foo: engineFoo, bar: engineBar });
// 类级别覆盖 foo,并新增 baz
const modelFoo = { name: 'foo', handler: () => 'modelFoo' };
const modelBaz = { name: 'baz', handler: () => 'modelBaz' };
(TestModel as typeof FlowModel).registerActions({ foo: modelFoo, baz: modelBaz });
const model = engine.createModel({ use: 'TestModel' });
// getAction:foo 应该来自 model 级
const defFoo = model.context.getAction('foo');
expect(defFoo).toBe(modelFoo);
// getActions:应包含 foo(来自 model)、bar(来自 engine)、baz(来自 model)
const all = model.context.getActions();
expect(all.get('foo')).toBe(modelFoo);
expect(all.get('bar')).toBe(engineBar);
expect(all.get('baz')).toBe(modelBaz);
});
it('should inherit model.context property in ForkFlowModel.context', () => {
const model = engine.createModel({ use: 'TestModel' });
const fork = model.createFork();
expect(fork.context.appName).toBe('NocoBase');
});
it('should inherit latest value after model.context property changes in fork.context', () => {
const model = engine.createModel({ use: 'TestModel' });
model.context.defineProperty('appName', { value: 'NocoBase2' });
const fork = model.createFork();
expect(fork.context.appName).toBe('NocoBase2');
});
it('should not affect model.context when fork.context property changes', () => {
const model = engine.createModel({ use: 'TestModel' });
model.context.defineProperty('appName', { value: 'NocoBase2' });
const fork = model.createFork();
fork.context.defineProperty('appName', { value: 'NocoBase3' });
expect(fork.context.appName).toBe('NocoBase3');
});
it('should isolate fork.context property changes from subModel.context when subModel delegates to parent', () => {
const model = engine.createModel({
use: 'TestModel',
subModels: {
sub: { uid: 'sub1', use: 'TestModel' },
},
});
model.context.defineProperty('appName', { value: 'NocoBase2' });
const sub = engine.getModel<TestModel>('sub1');
expect(sub.context.appName).toBe('NocoBase2');
sub.context.defineProperty('appName', { value: 'NocoBase3' });
const fork = sub.createFork();
expect(fork.context.appName).toBe('NocoBase3');
fork.context.defineProperty('appName', { value: 'NocoBase4' });
expect(fork.context.appName).toBe('NocoBase4');
});
it('should isolate context property changes between different forks', () => {
const model = engine.createModel({
use: 'TestModel',
subModels: {
sub: { uid: 'sub1', use: 'TestModel' },
},
});
model.context.defineProperty('appName', { value: 'NocoBase2' });
const sub = engine.getModel<TestModel>('sub1');
expect(sub.context.appName).toBe('NocoBase2');
sub.context.defineProperty('appName', { value: 'NocoBase3' });
const fork = sub.createFork();
const fork2 = sub.createFork();
expect(fork.context.appName).toBe('NocoBase3');
expect(fork2.context.appName).toBe('NocoBase3');
fork.context.defineProperty('appName', { value: 'NocoBase4' });
fork2.context.defineProperty('appName', { value: 'NocoBase5' });
expect(fork.context.appName).toBe('NocoBase4');
expect(fork2.context.appName).toBe('NocoBase5');
});
it('should not inherit parent context property when subModel disables delegateToParent', () => {
const model = engine.createModel({
use: 'TestModel',
subModels: {
sub: { uid: 'sub1', use: 'TestModel', delegateToParent: false },
},
});
model.context.defineProperty('appName', { value: 'NocoBase2' });
const sub = engine.getModel<TestModel>('sub1');
expect(sub.context.appName).toBe('NocoBase');
});
it('should only define property once when once: true', () => {
const ctx = new FlowContext();
ctx.defineProperty('foo', { value: 1, once: true });
ctx.defineProperty('foo', { value: 2 });
expect(ctx.foo).toBe(1);
});
});
describe('runAction delegation from runtime context', () => {
it('ctx.runAction should delegate to model.context.runAction and use runtime ctx for expression/props', async () => {
const engine = new FlowEngine();
class M extends FlowModel {}
engine.registerModels({ M });
// 注册一个动作到模型类
M.registerAction({
name: 'echo',
handler: (ctx, params) => ({ mode: ctx.mode, x: params.x }),
});
const model = engine.createModel({ use: 'M' });
const ctx = new FlowRuntimeContext(model, 'fk');
const res = await ctx.runAction('echo', { x: '{{ ctx.model.uid }}' });
expect(res.mode).toBe('runtime');
expect(res.x).toBe(model.uid);
});
});
describe('FlowContext delayed meta loading', () => {
// 测试场景:属性定义时 meta 为异步函数,首次访问时延迟加载
// 输入:属性带有异步 meta 函数
// 期望:getPropertyMetaTree() 同步返回,节点包含异步 children 函数
it('should create lazy-loaded meta tree node with async meta function', async () => {
const ctx = new FlowContext();
// 模拟延迟加载的 meta(如 collection 不可用时的情况)
const delayedMeta = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return {
type: 'object',
title: 'Delayed User',
properties: {
id: { type: 'number', title: 'User ID' },
name: { type: 'string', title: 'Username' },
},
};
});
ctx.defineProperty('userAsync', {
meta: delayedMeta,
});
// 同步获取 meta tree
const tree = ctx.getPropertyMetaTree();
expect(tree).toHaveLength(1);
const userNode = tree[0];
// 验证延迟加载节点的默认属性
expect(userNode.name).toBe('userAsync');
expect(userNode.title).toBe('userAsync'); // 默认使用 name
expect(userNode.type).toBe('object'); // 默认类型
expect(typeof userNode.children).toBe('function'); // 异步加载函数
// 此时还未调用 meta 函数
expect(delayedMeta).not.toHaveBeenCalled();
// 首次访问 children 时才调用 meta 函数
const children = await (userNode.children as () => Promise<any>)();
expect(delayedMeta).toHaveBeenCalledTimes(1);
// 验证加载后的子节点
expect(children).toHaveLength(2);
expect(children[0]).toEqual({
name: 'id',
title: 'User ID',
type: 'number',
interface: undefined,
uiSchema: undefined,
paths: ['userAsync', 'id'],
parentTitles: ['Delayed User'],
disabled: false,
disabledReason: undefined,
children: undefined,
});
expect(children[1]).toEqual({
name: 'name',
title: 'Username',
type: 'string',
interface: undefined,
uiSchema: undefined,
paths: ['userAsync', 'name'],
parentTitles: ['Delayed User'],
disabled: false,
disabledReason: undefined,
children: undefined,
});
});
// 测试场景:异步 meta 函数抛出异常时的错误处理
// 输入:meta 函数抛出错误(如网络异常)
// 期望:children 函数返回空数组,记录警告但不中断程序
it('should handle async meta function errors gracefully', async () => {
const ctx = new FlowContext();
const failingMeta = vi.fn(async () => {
throw new Error('Collection load failed');
});
ctx.defineProperty('errorProp', { meta: failingMeta });
const tree = ctx.getPropertyMetaTree();
const node = tree[0];
// 模拟 console.warn 以验证错误处理
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const children = await (node.children as () => Promise<any>)();
expect(children).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith('Failed to load meta for errorProp:', expect.any(Error));
consoleSpy.mockRestore();
});
// 测试场景:异步 meta 包含嵌套异步 properties 的深度延迟加载
// 输入:异步 meta 返回的 properties 本身也是异步函数
// 期望:支持多层异步嵌套加载,每层按需加载
it('should support nested async properties in delayed meta loading', async () => {
const ctx = new FlowContext();
ctx.defineProperty('deepAsync', {
meta: async () => ({
type: 'object',
title: 'Deep Async Root',
properties: async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return {
level1: {
type: 'object',
title: 'Level 1',
properties: {
level2Field: { type: 'string', title: 'Level 2 Field' },
},
},
};
},
}),
});
const tree = ctx.getPropertyMetaTree();
const rootNode = tree[0];
// 第一层异步加载
const level1Children = await (rootNode.children as () => Promise<any>)();
expect(level1Children).toHaveLength(1);
const level1Node = level1Children[0];
expect(level1Node.name).toBe('level1');
expect(level1Node.title).toBe('Level 1');
expect(Array.isArray(level1Node.children)).toBe(true);
// 第二层直接可用(同步 properties)
const level2Children = level1Node.children as any[];
expect(level2Children).toHaveLength(1);
expect(level2Children[0].name).toBe('level2Field');
expect(level2Children[0].title).toBe('Level 2 Field');
});
});
describe('getPropertyMetaTree with deep delegate meta', () => {
it('should resolve meta defined on a deeper delegate when using value path', () => {
// bottom defines the actual meta for 'collection'
const bottom = new FlowContext();
bottom.defineProperty('collection', {
meta: {
type: 'object',
title: 'Collection',
properties: {
name: { type: 'string', title: 'Name' },
},
},
});
// middle delegates to bottom (no own meta)
const middle = new FlowContext();
middle.addDelegate(bottom);
// top delegates to middle (no own meta)
const top = new FlowContext();
top.addDelegate(middle);
// Previously, findMetaByPath only checked first-level delegates and failed here.
// After fix, it should find 'collection' meta through deep delegates.
const subTree = top.getPropertyMetaTree('{{ ctx.collection }}');
expect(Array.isArray(subTree)).toBe(true);
expect(subTree.length).toBeGreaterThan(0);
// Ensure we actually got the field children of collection
const fieldNames = subTree.map((n) => n.name);
expect(fieldNames).toContain('name');
// Also verify each child has full path starting with ['collection', ...]
subTree.forEach((n) => expect(n.paths[0]).toBe('collection'));
});
});
describe('FlowContext resolveOnServer selective server resolution', () => {
it('does not call server by default (no resolveOnServer set)', async () => {
const engine = new FlowEngine();
const api = { request: vi.fn() } as any;
engine.context.defineProperty('api', { value: api });
engine.context.defineProperty('view', {
get: () => ({ record: { id: 2 }, type: 'dialog' }),
// default resolveOnServer = false
});
const tpl = { id: '{{ ctx.view.record.id }}', type: '{{ ctx.view.type }}' } as any;
const out = await (engine.context as any).resolveJsonTemplate(tpl);
expect(out).toEqual({ id: 2, type: 'dialog' });
expect(api.request).not.toHaveBeenCalled();
});
it('calls server only for subpaths that match resolveOnServer function', async () => {
const engine = new FlowEngine();
const api = {
request: vi.fn(async (config: any) => {
const cp = config?.data?.values?.contextParams || {};
// Only 'view.record' should be present
expect(Object.keys(cp)).toContain('view.record');
return { data: { id: 1 } } as any;
}),
} as any;
engine.context.defineProperty('api', { value: api });
engine.context.defineProperty('view', {
get: () => ({ type: 'dialog' }),
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
meta: async () => ({
type: 'object',
title: 'View',
buildVariablesParams: () => ({ record: { collection: 'users', filterByTk: 1, dataSourceKey: 'main' } }),
}),
});
const tpl = { id: '{{ ctx.view.record.id }}', type: '{{ ctx.view.type }}' } as any;
const out = await (engine.context as any).resolveJsonTemplate(tpl);
expect(out.type).toBe('dialog');
expect(api.request).toHaveBeenCalledTimes(1);
const [{ url }] = api.request.mock.calls[0];
expect(url).toBe('variables:resolve');
});
it('calls server for whole variable when resolveOnServer is true', async () => {
const engine = new FlowEngine();
const api = {
request: vi.fn(async (config: any) => {
const cp = config?.data?.values?.contextParams || {};
expect(Object.keys(cp)).toContain('user');
return { data: { userId: 1 } } as any;
}),
} as any;
engine.context.defineProperty('api', { value: api });
engine.context.defineProperty('user', {
value: { id: 1, name: 'tester' },
resolveOnServer: true,
meta: async () => ({
type: 'object',
title: 'User',
buildVariablesParams: () => ({ collection: 'users', filterByTk: 1, dataSourceKey: 'main' }),
}),
});
const tpl = { uid: '{{ ctx.user.id }}' } as any;
await (engine.context as any).resolveJsonTemplate(tpl);
expect(api.request).toHaveBeenCalledTimes(1);
});
it('reads resolveOnServer from delegated context properties (e.g., engine.context -> model.context)', async () => {
const engine = new FlowEngine();
const api = {
request: vi.fn(async (config: any) => ({ data: { ok: true } })),
} as any;
engine.context.defineProperty('api', { value: api });
// Define 'user' on engine.context only
engine.context.defineProperty('user', {
value: { id: 3 },
resolveOnServer: true,
meta: async () => ({
type: 'object',
title: 'User',
// purposefully omit builder to test empty contextParams path
}),
});
// Create a model and call resolveJsonTemplate on model.context (delegates to engine.context)
const M = class extends FlowModel {};
engine.registerModels({ M });
const model = engine.createModel({ use: 'M' });
const tpl = { uid: '{{ ctx.user.id }}' } as any;
await (model.context as any).resolveJsonTemplate(tpl);
expect(api.request).toHaveBeenCalledTimes(1);
});
it('still calls server when resolveOnServer=true even without meta/buildVariablesParams', async () => {
const engine = new FlowEngine();
const api = { request: vi.fn() } as any;
engine.context.defineProperty('api', { value: api });
engine.context.defineProperty('x', {
value: { a: 1 },
resolveOnServer: true,
// no meta / no buildVariablesParams
});
const tpl = { a: '{{ ctx.x.a }}' } as any;
const out = await (engine.context as any).resolveJsonTemplate(tpl);
expect(out).toEqual({ a: 1 });
// based on resolveOnServer, still calls server with empty contextParams
expect(api.request).toHaveBeenCalledTimes(1);
});
it('mixes server and client variables correctly in one pass', async () => {
const engine = new FlowEngine();
const api = {
request: vi.fn(async (config: any) => {
const cp = config?.data?.values?.contextParams || {};
expect(Object.keys(cp).sort()).toEqual(['user', 'view.record']);
return { data: { ok: true } } as any;
}),
} as any;
engine.context.defineProperty('api', { value: api });
engine.context.defineProperty('user', {
value: { id: 7, name: 'u' },
resolveOnServer: true,
meta: async () => ({
type: 'object',
title: 'User',
buildVariablesParams: () => ({ collection: 'users', filterByTk: 7, dataSourceKey: 'main' }),
}),
});
engine.context.defineProperty('view', {
get: () => ({ type: 'dialog' }),
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
meta: async () => ({
type: 'object',
title: 'View',
buildVariablesParams: () => ({ record: { collection: 'posts', filterByTk: 11, dataSourceKey: 'main' } }),
}),
});
engine.context.defineProperty('role', { value: 'admin' });
const tpl = {
a: '{{ ctx.user.id }}',
b: '{{ ctx.view.record.id }}',
c: '{{ ctx.view.type }}',
d: '{{ ctx.role }}',
} as any;
const out = await (engine.context as any).resolveJsonTemplate(tpl);
// client-resolved fields
expect(out.c).toBe('dialog');
expect(out.d).toBe('admin');
// server was called
expect(api.request).toHaveBeenCalledTimes(1);
});
});
describe('FlowContext.getPropertyOptions()', () => {
it('returns own property options when defined locally', () => {
const ctx = new FlowContext();
ctx.defineProperty('alpha', { value: 1, cache: true });
const opt = ctx.getPropertyOptions('alpha');
expect(opt).toBeTruthy();
expect(opt.value).toBe(1);
expect(opt.cache).toBe(true);
});
it('returns delegated property options when not defined locally', () => {
const base = new FlowContext();
base.defineProperty('beta', { value: 2, resolveOnServer: true });
const ctx = new FlowContext();
ctx.addDelegate(base);
const opt = ctx.getPropertyOptions('beta');
expect(opt).toBeTruthy();
expect(opt.value).toBe(2);
expect(opt.resolveOnServer).toBe(true);
});
it('prefers own property options over delegated ones', () => {
const base = new FlowContext();
base.defineProperty('gamma', { value: 10, resolveOnServer: true });
const ctx = new FlowContext();
ctx.addDelegate(base);
ctx.defineProperty('gamma', { value: 99, resolveOnServer: false });
const opt = ctx.getPropertyOptions('gamma');
expect(opt).toBeTruthy();
expect(opt.value).toBe(99);
expect(opt.resolveOnServer).toBe(false);
});
});
describe('FlowContext getPropertyMetaTree with value parameter', () => {
it('should return full tree when no value parameter is provided', () => {
const ctx = new FlowContext();
ctx.defineProperty('user', {
meta: {
type: 'object',
title: 'User',
properties: {
id: { type: 'number', title: 'User ID' },
name: { type: 'string', title: 'Username' },
},
},
});
ctx.defineProperty('data', {
meta: { type: 'string', title: 'Data' },
});
const tree = ctx.getPropertyMetaTree();
expect(tree).toHaveLength(2);
expect(tree[0].name).toBe('user');
expect(tree[1].name).toBe('data');
});
it('should return subtree for valid {{ ctx.propertyName }} format', () => {
const ctx = new FlowContext();
ctx.defineProperty('user', {
meta: {
type: 'object',
title: 'User',
properties: {
id: { type: 'number', title: 'User ID' },
name: { type: 'string', title: 'Username' },
},
},
});
ctx.defineProperty('data', {
meta: { type: 'string', title: 'Data' },
});
const subTree = ctx.getPropertyMetaTree('{{ ctx.user }}');
expect(subTree).toHaveLength(2);
expect(subTree[0]).toEqual({
name: 'id',
title: 'User ID',
type: 'number',
interface: undefined,
uiSchema: undefined,
paths: ['user', 'id'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: undefined,
});
expect(subTree[1]).toEqual({
name: 'name',
title: 'Username',
type: 'string',
interface: undefined,
uiSchema: undefined,
paths: ['user', 'name'],
parentTitles: undefined,
disabled: false,
disabledReason: undefined,
children: undefined,
});
});
it('should handle spaces in {{ ctx.propertyName }} format', () => {
const ctx = new FlowContext();
ctx.defineProperty('user', {
meta: {
type: 'object',
title: 'User',
properties: {
id: { type: 'number', title: 'User ID' },
},
},
});
const subTree1 = ctx.getPropertyMetaTree('{{ctx.user}}');
const subTree2 = ctx.getPropertyMetaTree('{{ ctx.user }}');
const subTree3 = ctx.getPropertyMetaTree('{{ ctx.user }}');
expect(subTree1).toHaveLength(1);
expect(subTree2).toHaveLength(1);
expect(subTree3).toHaveLength(1);
expect(subTree1[0].name).toBe('id');
expect(subTree2[0].name).toBe('id');
expect(subTree3[0].name).toBe('id');
});
it('should return empty array for property without children', () => {
const ctx = new FlowContext();
ctx.defineProperty('simple', {
meta: { type: 'string', title: 'Simple' },
});
const subTree = ctx.getPropertyMetaTree('{{ ctx.simple }}');
expect(subTree).toEqual([]);
});
it('should warn and return full tree for unsupported formats', () => {
const ctx = new FlowContext();
ctx.defineProperty('user', {
meta: {
type: 'object',
title: 'User',
properties: {
id: { type: 'number', title: 'User ID' },
},
},
});
ctx.defineProperty('data', {
meta: { type: 'string', title: 'Data' },
});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Test various unsupported formats (should trigger warning)
const invalidTestCases = ['user', 'ctx.user', '{{user}}', '{{ user }}', 'invalid format'];
invalidTestCases.forEach((testCase) => {
consoleSpy.mockClear();
const result = ctx.getPropertyMetaTree(testCase);
// Should return empty tree for invalid formats
expect(result).toHaveLength(0);
// Should log warning
expect(consoleSpy).toHaveBeenCalledWith(
`[FlowContext] getPropertyMetaTree - unsupported value format: "${testCase}". Only "{{ ctx.propertyName }}" format is supported. Returning empty meta tree.`,
);
});
// Test valid root context formats (should NOT trigger warning)
const validRootCases = ['{{ ctx }}', '{{ctx}}'];
validRootCases.forEach((testCase) => {
consoleSpy.mockClear();
const result = ctx.getPropertyMetaTree(testCase);
// Should return full tree (since {{ ctx }} means all properties)
expect(result).toHaveLength(2);
expect(result[0].name).toBe('user');
expect(result[1].name).toBe('data');
// Should NOT log warning for valid formats
expect(consoleSpy).not.toHaveBeenCalled();
});
consoleSpy.mockRestore();
});
it('should return empty array when property is not found', () => {
const ctx = new FlowContext();
ctx.defineProperty('user', {
meta: { type: 'object', title: 'User' },
});
const result = ctx.getPropertyMetaTree('{{ ctx.nonExistent }}');
expect(result).toEqual([]);
});
it('should support async meta with value parameter', async () => {
const ctx = new FlowContext();
ctx.defineProperty('asyncUser', {
meta: async () => ({
type: 'object',
title: 'Async User',
properties: {
profile: {
type: 'object',
title: 'Profile',
properties: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return {
bio: { type: 'string', title: 'Biography' },
};
},
},
},
}),
});
const subTree = ctx.getPropertyMetaTree('{{ ctx.asyncUser }}');
const ensureArray = async (v: any) => (typeof v === 'function' ? await v() : v);
const arr = await ensureArray(subTree);
expect(arr).toHaveLength(1);
expect(arr[0].name).toBe('profile');
expect(typeof arr[0].children).toBe('function');
const profileChildren = await (arr[0].children as () => Promise<any>)();
expect(profileChildren).toHaveLength(1);
expect(profileChildren[0].name).toBe('bio');
expect(profileChildren[0].title).toBe('Biography');
});
it('should handle async meta errors with value parameter', async () => {
const ctx = new FlowContext();
const failingMeta = vi.fn(async () => {
throw new Error('Async meta failed');
});
ctx.defineProperty('errorProp', { meta: failingMeta });
const subTree = ctx.getPropertyMetaTree('{{ ctx.errorProp }}');
const ensureArray = async (v: any) => (typeof v === 'function' ? await v() : v);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const arr = await ensureArray(subTree);
expect(arr).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith('Failed to load meta for errorProp:', expect.any(Error));
consoleSpy.mockRestore();
});
it('should support multi-level path like {{ ctx.user.profile }}', () => {
const ctx = new FlowContext();
ctx.defineProperty('user', {
meta: {
type: 'object',
title: 'User',
properties: {
id: { type: 'number', title: 'User ID' },
profile: {
type: 'object',
title: 'User Profile',
properties: {
bio: { type: 'string', title: 'Biography' },
avatar: { type: 'string', title: 'Avatar URL' },
},
},
},
},
});
// Test getting profile subtree
const profileSubTree = ctx.getPropertyMetaTree('{{ ctx.user.profile }}');
expect(profileSubTree).toHaveLength(2);
expect(profileSubTree[0]).toEqual({
name: 'bio',
title: 'Biography',
type: 'string',
interface: undefined,
uiSche