/** * 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 axios from 'axios'; import { describe, expect, it, vi, afterEach } from 'vitest'; import { FlowContext, FlowRuntimeContext, FlowRunJSContext, type PropertyMetaFactory } from '../flowContext'; import { FlowEngine } from '../flowEngine'; import { FlowModel } from '../models/flowModel'; import { RunJSContextRegistry } from '../runjs-context/registry'; import { setupRunJSContexts } from '../runjs-context/setup'; import { createViewScopedEngine } from '../ViewScopedFlowEngine'; import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents'; import { serializeCtxDateExpressionConfig } from '../utils/dateVariable'; 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 expose current language as a top-level variable', async () => { const engine = new FlowEngine(); const ctx = engine.context; ctx.defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); ctx.defineProperty('i18n', { value: { language: 'en-US' } }); expect(ctx.locale).toBe('zh-CN'); await expect(ctx.resolveJsonTemplate('{{ ctx.locale }}')).resolves.toBe('zh-CN'); const localeNode = ctx.getPropertyMetaTree().find((node) => node.name === 'locale'); expect(localeNode).toMatchObject({ name: 'locale', title: '{{t("Current language")}}', paths: ['locale'], }); }); it('should expose current role as a top-level variable', async () => { const engine = new FlowEngine(); const ctx = engine.context; ctx.defineProperty('api', { value: { auth: { role: 'admin' } } }); expect(ctx.role).toBe('admin'); await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toBe('admin'); const roleNode = ctx.getPropertyMetaTree().find((node) => node.name === 'role'); expect(roleNode).toMatchObject({ name: 'role', title: '{{t("Current role")}}', paths: ['role'], }); }); it('should expose actual role names for union role mode', async () => { const engine = new FlowEngine(); const ctx = engine.context; ctx.defineProperty('api', { value: { auth: { role: '__union__' } } }); ctx.defineProperty('user', { value: { roles: [ { name: 'admin', title: 'Admin' }, { name: 'member', title: 'Member' }, ], }, }); expect(ctx.role).toEqual(['admin', 'member']); await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual(['admin', 'member']); }); it('should expose an empty role list for union role mode without user roles', async () => { const engine = new FlowEngine(); const ctx = engine.context; ctx.defineProperty('api', { value: { auth: { role: '__union__' } } }); ctx.defineProperty('user', { value: {} }); expect(ctx.role).toEqual([]); await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual([]); }); 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, hidden: false, children: undefined, }, { name: 'bar', title: 'Bar', type: 'object', interface: undefined, uiSchema: undefined, paths: ['bar'], parentTitles: undefined, disabled: false, disabledReason: undefined, hidden: false, children: [ { name: 'baz', title: 'Baz', type: 'number', interface: undefined, uiSchema: undefined, paths: ['bar', 'baz'], parentTitles: ['Bar'], disabled: false, disabledReason: undefined, hidden: false, children: undefined, }, { name: 'qux', title: 'Qux', type: 'string', interface: undefined, uiSchema: undefined, paths: ['bar', 'qux'], parentTitles: ['Bar'], disabled: false, disabledReason: undefined, hidden: false, 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, hidden: false, children: undefined, }, { name: 'bar', title: 'Local Bar', type: 'object', interface: 'object', uiSchema: { type: 'object' }, paths: ['bar'], parentTitles: undefined, disabled: false, disabledReason: undefined, hidden: false, children: [ { name: 'x', title: 'X', type: 'string', interface: 'text', uiSchema: { type: 'text' }, paths: ['bar', 'x'], parentTitles: ['Local Bar'], disabled: false, disabledReason: undefined, hidden: false, 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, hidden: false, children: [ { name: 'field1', title: 'Field 1', type: 'string', interface: undefined, uiSchema: undefined, paths: ['syncProp', 'field1'], parentTitles: ['Sync Property'], disabled: false, disabledReason: undefined, hidden: false, children: undefined, }, { name: 'field2', title: 'Field 2', type: 'number', interface: undefined, uiSchema: undefined, paths: ['syncProp', 'field2'], parentTitles: ['Sync Property'], disabled: false, disabledReason: undefined, hidden: false, 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)(); 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, hidden: false, children: undefined, }, { name: 'dynamicField2', title: 'Dynamic Field 2', type: 'boolean', interface: undefined, uiSchema: undefined, paths: ['asyncProp', 'dynamicField2'], parentTitles: ['Async Property'], disabled: false, disabledReason: undefined, hidden: false, 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('FlowContext.getApiInfos', () => { it('should support defineMethod info (string as description)', async () => { const ctx = new FlowContext(); ctx.defineMethod('foo', () => 1, 'do something'); expect(ctx.foo()).toBe(1); const infos = await ctx.getApiInfos(); expect(infos.foo?.description).toBe('do something'); expect(infos.foo?.type).toBe('function'); }); it('should support defineMethod info object (completion/ref)', async () => { const ctx = new FlowContext(); ctx.defineMethod('bar', () => 2, { description: 'Bar', completion: { insertText: 'ctx.bar()' }, ref: { url: 'https://example.com', title: 'Docs' }, }); const infos = await ctx.getApiInfos(); expect((infos.bar as any)?.completion).toBeUndefined(); expect((infos.bar?.ref as any)?.url).toBe('https://example.com'); }); it('should include completion only when requested by getApiInfos()', async () => { const ctx = new FlowContext(); ctx.defineMethod('bar', () => 2, { description: 'Bar', completion: { insertText: 'ctx.bar()' }, }); ctx.defineProperty('token', { value: 't', info: { description: 'Token string', completion: { insertText: 'ctx.token' }, }, }); const compact = await ctx.getApiInfos(); expect((compact.bar as any)?.completion).toBeUndefined(); expect((compact.token as any)?.completion).toBeUndefined(); const editorInfos = await ctx.getApiInfos({ includeCompletion: true }); expect((editorInfos.bar as any)?.completion?.insertText).toBe('ctx.bar()'); expect((editorInfos.token as any)?.completion?.insertText).toBe('ctx.token'); }); it('should return property infos with completion/ref/examples', async () => { const ctx = new FlowContext(); ctx.defineProperty('token', { value: 't', meta: { type: 'string', title: 'Token', }, info: { description: 'Token string', completion: { insertText: 'ctx.token' }, ref: 'docs/token', examples: ['const t = ctx.token'], }, }); const infos = await ctx.getApiInfos(); expect((infos.token as any)?.completion).toBeUndefined(); expect(infos.token?.title).toBeUndefined(); expect(infos.token?.type).toBeUndefined(); expect(infos.token?.ref).toBe('docs/token'); expect(infos.token?.examples).toContain('const t = ctx.token'); }); it('should not return meta-only properties and should not trigger meta factory', async () => { const ctx = new FlowContext(); const metaFactory = vi.fn(() => ({ type: 'string', title: 'MetaOnly' })) as any; ctx.defineProperty('metaOnly', { meta: metaFactory }); const infos = await ctx.getApiInfos(); expect(infos.metaOnly).toBeUndefined(); expect(metaFactory).not.toHaveBeenCalled(); }); it('should not return keys starting with underscore', async () => { class TestRunJSContext extends FlowRunJSContext {} TestRunJSContext.define({ properties: { _internal: 'internal', pub: { type: 'object', description: 'pub', properties: { _secret: 'secret', visible: 'visible', }, }, }, methods: { _m: 'hidden method', m: 'visible method', }, }); RunJSContextRegistry.register('test-private', '*', TestRunJSContext as any); const ctx = new FlowContext(); ctx.defineProperty('_runtimeSecret', { info: { description: '_runtimeSecret' }, }); ctx.defineMethod('_runtimeMethod', () => 1, 'runtime hidden'); const infos = await ctx.getApiInfos({ version: 'test-private' as any }); expect(infos._internal).toBeUndefined(); expect(infos._runtimeSecret).toBeUndefined(); expect(infos._m).toBeUndefined(); expect(infos._runtimeMethod).toBeUndefined(); expect(infos.pub).toBeTruthy(); expect(infos.pub?.properties).toBeUndefined(); expect(infos.m).toBeTruthy(); }); it('should only return top-level keys and no completion', async () => { class TestRunJSContext extends FlowRunJSContext {} TestRunJSContext.define({ properties: { api: { type: 'object', description: 'api', properties: { request: { type: 'function', description: 'request' }, }, }, }, }); RunJSContextRegistry.register('test-layer', '*', TestRunJSContext as any); const ctx = new FlowContext(); const infos = await ctx.getApiInfos({ version: 'test-layer' as any }); expect(infos.api).toBeTruthy(); expect(infos.api?.properties).toBeUndefined(); expect((infos.api as any)?.completion).toBeUndefined(); expect((infos as any).request).toBeUndefined(); }); it('should exclude variable-like roots and expose libs docs under libs.*', async () => { class TestRunJSContext extends FlowRunJSContext {} TestRunJSContext.define({ properties: { record: 'Current record (var-like).', formValues: 'Form values (var-like).', popup: { description: 'Popup context (var-like).', detail: 'Promise', }, React: 'React namespace. Recommended access path: `ctx.libs.React`.', ReactDOM: 'ReactDOM API. Recommended access path: `ctx.libs.ReactDOM`.', antd: 'Ant Design. Recommended access path: `ctx.libs.antd`.', libs: { description: 'Libraries namespace.', properties: { antdIcons: 'Ant Design icons library. Example: `ctx.libs.antdIcons.PlusOutlined`.', }, }, }, }); RunJSContextRegistry.register('test-api-libs' as any, '*', TestRunJSContext as any); const ctx = new FlowContext(); const infos = await ctx.getApiInfos({ version: 'test-api-libs' as any }); // Variable-like roots documented in RunJS doc should be served by getVarInfos/getEnvInfos. expect(infos.record).toBeUndefined(); expect(infos.formValues).toBeUndefined(); expect(infos.popup).toBeUndefined(); // Avoid exposing global lib aliases; document them under ctx.libs.* instead. expect((infos as any).React).toBeUndefined(); expect((infos as any).ReactDOM).toBeUndefined(); expect((infos as any).antd).toBeUndefined(); expect(infos['libs.React']?.description).toContain('ctx.libs.React'); expect(infos['libs.ReactDOM']?.description).toContain('ctx.libs.ReactDOM'); expect(infos['libs.antd']?.description).toContain('ctx.libs.antd'); expect(infos['libs.antdIcons']?.description).toContain('antdIcons'); }); it('should include getApiInfos/getVarInfos/getEnvInfos in api infos output', async () => { await setupRunJSContexts(); const ctx = new FlowContext(); const infos = await ctx.getApiInfos(); expect(infos.getApiInfos?.type).toBe('function'); expect(String(infos.getApiInfos?.description || '')).toMatch(/\S/); expect(infos.getVarInfos?.type).toBe('function'); expect(String(infos.getVarInfos?.description || '')).toMatch(/\S/); expect(infos.getEnvInfos?.type).toBe('function'); expect(String(infos.getEnvInfos?.description || '')).toMatch(/\S/); }); }); describe('FlowContext.getVarInfos', () => { it('should respect maxDepth when expanding properties', async () => { const ctx = new FlowContext(); ctx.defineProperty('deep', { meta: { type: 'object', title: 'deep', properties: { level1: { type: 'object', title: 'l1', properties: { level2: { type: 'object', title: 'l2', properties: { level3: { type: 'string', title: 'l3' }, }, }, }, }, }, }, }); const vars = await ctx.getVarInfos({ path: 'deep', maxDepth: 2 }); expect(vars.deep?.properties?.level1).toBeTruthy(); expect(vars.deep?.properties?.level1?.properties?.level2).toBeUndefined(); }); it('should support deep path pruning (root key is the path string)', async () => { const ctx = new FlowContext(); ctx.defineProperty('deep', { meta: { type: 'object', title: 'deep', properties: { level1: { type: 'object', title: 'l1', properties: { level2: { type: 'string', title: 'l2' }, }, }, }, }, }); const vars = await ctx.getVarInfos({ path: 'deep.level1', maxDepth: 2 }); expect(vars['deep.level1']?.title).toBe('l1'); expect(vars['deep.level1']?.properties?.level2).toBeTruthy(); }); it('should expand PropertyMetaFactory nodes in path pruning', async () => { const ctx = new FlowContext(); const recordFactory: PropertyMetaFactory = async () => ({ type: 'object', title: 'Record', properties: { id: { type: 'string', title: 'id' }, }, }); recordFactory.title = 'Record'; recordFactory.hasChildren = true; ctx.defineProperty('popup', { meta: () => ({ type: 'object', title: 'popup', properties: async () => ({ record: recordFactory as any, }), }), }); const vars = await ctx.getVarInfos({ path: 'popup.record', maxDepth: 3 }); expect(vars['popup.record']?.title).toBe('Record'); expect(vars['popup.record']?.properties?.id).toBeTruthy(); }); it('should support deep path pruning through PropertyMetaFactory nodes', async () => { const ctx = new FlowContext(); const recordFactory: PropertyMetaFactory = async () => ({ type: 'object', title: 'Record', properties: { id: { type: 'string', title: 'id' }, }, }); recordFactory.title = 'Record'; recordFactory.hasChildren = true; ctx.defineProperty('popup', { meta: () => ({ type: 'object', title: 'popup', properties: async () => ({ record: recordFactory as any, }), }), }); const vars = await ctx.getVarInfos({ path: 'popup.record.id', maxDepth: 2 }); expect(vars['popup.record.id']?.title).toBe('id'); expect(vars['popup.record.id']?.type).toBe('string'); }); it('should expand PropertyMetaFactory children in full output', async () => { const ctx = new FlowContext(); const recordFactory: PropertyMetaFactory = async () => ({ type: 'object', title: 'Record', properties: { id: { type: 'string', title: 'id' }, }, }); recordFactory.title = 'Record'; recordFactory.hasChildren = true; ctx.defineProperty('popup', { meta: () => ({ type: 'object', title: 'popup', properties: async () => ({ record: recordFactory as any, }), }), }); const vars = await ctx.getVarInfos({ maxDepth: 3 }); expect(vars.popup?.properties?.record?.properties?.id).toBeTruthy(); }); it('should compute/skip hidden nodes and compute disabled/disabledReason', async () => { const ctx = new FlowContext(); ctx.defineProperty('secret', { meta: { type: 'string', title: 'secret', hidden: () => true }, }); ctx.defineProperty('disabledProp', { meta: { type: 'string', title: 'disabledProp', disabled: async () => true, disabledReason: async () => 'not available', }, }); const vars = await ctx.getVarInfos(); expect(vars.secret).toBeUndefined(); expect(vars.disabledProp?.disabled).toBe(true); expect(vars.disabledProp?.disabledReason).toBe('not available'); }); it('should not return empty property infos (no meta)', async () => { const ctx = new FlowContext(); ctx.defineProperty('plainProp', { value: 1 }); ctx.defineMethod('plainMethod', () => 1); const vars = await ctx.getVarInfos(); expect(vars.plainProp).toBeUndefined(); expect(vars.plainMethod).toBeUndefined(); }); it('should not return keys starting with underscore (including nested)', async () => { const ctx = new FlowContext(); ctx.defineProperty('_runtimeSecret', { meta: { type: 'string', title: '_runtimeSecret' }, }); ctx.defineProperty('runtime', { meta: { type: 'object', title: 'runtime', properties: { _child: { type: 'string', title: '_child' }, child: { type: 'string', title: 'child' }, }, }, }); const vars = await ctx.getVarInfos({ maxDepth: 3 }); expect(vars._runtimeSecret).toBeUndefined(); expect(vars.runtime?.properties?._child).toBeUndefined(); expect(vars.runtime?.properties?.child).toBeTruthy(); }); }); describe('FlowContext.getEnvInfos', () => { it('should return envs nodes (popup/block/resource/record)', async () => { const ctx = new FlowContext(); ctx.defineProperty('blockModel', { value: { uid: 'b1' } }); ctx.defineProperty('popup', { value: Promise.resolve({ uid: 'p1', resource: { collectionName: 'posts', dataSourceKey: 'main', associationName: 'comments', filterByTk: 1, sourceId: 2, }, }), }); const envs = await ctx.getEnvInfos(); expect(envs.resource?.getVar).toBe('ctx.popup.resource'); expect(envs.resource?.properties?.collectionName?.value).toBe('posts'); expect(envs.resource?.properties?.filterByTk?.value).toBeUndefined(); expect(envs.record?.getVar).toBe('ctx.record'); expect(envs.record?.value).toBeUndefined(); expect(envs.block?.properties?.uid?.getVar).toBe('ctx.blockModel.uid'); expect(envs.popup?.properties?.uid?.value).toBe('p1'); }); it('should include block/flowModel/resource envs when blockModel exists', async () => { const ctx = new FlowContext(); ctx.defineProperty('model', { value: { uid: 'm1', constructor: { name: 'MyFlowModel', meta: { label: 'MyModel' } }, }, }); ctx.defineProperty('blockModel', { value: { uid: 'b1', constructor: { name: 'TableBlockModel', meta: { label: 'MyBlock' } }, resource: { collectionName: 'posts', dataSourceKey: 'main', associationName: 'comments', filterByTk: 1, sourceId: 2, }, }, }); const envs = await ctx.getEnvInfos(); expect(envs.block?.getVar).toBe('ctx.blockModel'); expect(envs.block?.properties?.modelClass?.value).toBe('TableBlockModel'); expect(envs.flowModel?.getVar).toBe('ctx.model'); expect(envs.flowModel?.properties?.label?.value).toBe('MyModel'); expect(envs.flowModel?.properties?.modelClass?.value).toBe('MyFlowModel'); expect(envs.resource?.getVar).toBe('ctx.blockModel.resource'); expect(envs.resource?.properties?.collectionName?.value).toBe('posts'); expect(envs.resource?.properties?.filterByTk?.value).toBeUndefined(); expect(envs.record?.getVar).toBe('ctx.record'); }); it('should omit popup env section when popup is not available', async () => { const ctx = new FlowContext(); const envs = await ctx.getEnvInfos(); expect(envs.popup).toBeUndefined(); }); it('should omit optional resource fields and record env when missing', async () => { const ctx = new FlowContext(); ctx.defineProperty('popup', { value: Promise.resolve({ uid: 'p1', resource: { dataSourceKey: 'main', collectionName: 'posts', // filterByTk/sourceId intentionally omitted }, }), }); const envs = await ctx.getEnvInfos(); expect(envs.record).toBeUndefined(); expect(envs.resource?.properties?.filterByTk).toBeUndefined(); expect(envs.resource?.properties?.sourceId).toBeUndefined(); expect(envs.popup?.properties?.resource?.properties?.filterByTk).toBeUndefined(); expect(envs.popup?.properties?.resource?.properties?.sourceId).toBeUndefined(); }); it('should return currentViewBlocks for page view (BlockModel only)', async () => { const engine = new FlowEngine(); class ViewModel extends FlowModel {} class BlockModelLike extends FlowModel { onInit(options: any): void { super.onInit(options); this.context.defineProperty('blockModel', { value: this }); } } class NonBlockModel extends FlowModel {} BlockModelLike.define({ label: 'MyBlock' } as any); NonBlockModel.define({ label: 'NotBlock' } as any); engine.registerModels({ ViewModel, BlockModelLike, NonBlockModel }); engine.createModel({ uid: 'view1', use: 'ViewModel', subModels: { blocks: [ { uid: 'b1', use: 'BlockModelLike' }, { uid: 'b2', use: 'BlockModelLike' }, ], other: { uid: 'x1', use: 'NonBlockModel' }, }, }); (engine.getModel('b1') as any).resource = { dataSourceKey: 'main', collectionName: 'posts' }; (engine.getModel('b2') as any).resource = { dataSourceKey: 'main', collectionName: 'users', associationName: 'roles', }; engine.context.defineProperty('view', { value: { inputArgs: { viewUid: 'view1' } } }); const envs = await engine.context.getEnvInfos(); const blocks = (envs.currentViewBlocks?.value || []) as any[]; const uids = blocks.map((b) => b.uid); expect(envs.currentViewBlocks?.getVar).toBeUndefined(); expect(uids).toEqual(expect.arrayContaining(['b1', 'b2'])); expect(uids).not.toContain('x1'); const b1 = blocks.find((b) => b.uid === 'b1'); expect(b1?.label).toBeTruthy(); expect(b1).not.toHaveProperty('use'); expect(b1?.modelClass).toBe('BlockModelLike'); expect(b1?.resource?.collectionName).toBe('posts'); }); it('should prefer current popup view for currentViewBlocks', async () => { const engine = new FlowEngine(); class ViewModel extends FlowModel {} class BlockModelLike extends FlowModel { onInit(options: any): void { super.onInit(options); this.context.defineProperty('blockModel', { value: this }); } } BlockModelLike.define({ label: 'MyBlock' } as any); engine.registerModels({ ViewModel, BlockModelLike }); engine.createModel({ uid: 'page1', use: 'ViewModel', subModels: { blocks: [{ uid: 'pageBlock1', use: 'BlockModelLike' }] }, }); engine.createModel({ uid: 'popup1', use: 'ViewModel', subModels: { blocks: [{ uid: 'popupBlock1', use: 'BlockModelLike' }] }, }); engine.context.defineProperty('view', { value: { inputArgs: { viewUid: 'page1' } } }); engine.context.defineProperty('popup', { value: { uid: 'popup1' } }); const envs = await engine.context.getEnvInfos(); const blocks = (envs.currentViewBlocks?.value || []) as any[]; const uids = blocks.map((b) => b.uid); expect(uids).toContain('popupBlock1'); expect(uids).not.toContain('pageBlock1'); }); it('should include hidden blocks in currentViewBlocks', async () => { const engine = new FlowEngine(); class ViewModel extends FlowModel {} class BlockModelLike extends FlowModel { onInit(options: any): void { super.onInit(options); this.context.defineProperty('blockModel', { value: this }); } } BlockModelLike.define({ label: 'MyBlock' } as any); engine.registerModels({ ViewModel, BlockModelLike }); engine.createModel({ uid: 'view1', use: 'ViewModel', subModels: { blocks: [{ uid: 'b1', use: 'BlockModelLike' }] }, }); const b1 = engine.getModel('b1') as any; b1.hidden = true; engine.context.defineProperty('view', { value: { inputArgs: { viewUid: 'view1' } } }); const envs = await engine.context.getEnvInfos(); const blocks = (envs.currentViewBlocks?.value || []) as any[]; expect(blocks.map((b) => b.uid)).toContain('b1'); }); it('should omit currentViewBlocks when view is not available', async () => { const engine = new FlowEngine(); const envs = await engine.context.getEnvInfos(); expect(envs.currentViewBlocks).toBeUndefined(); }); }); 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('ctx.api should return a dirty-aware wrapper for static api properties', async () => { const engine = new FlowEngine(); const update = vi.fn(async () => ({ data: { data: { id: 1 } } })); const api = { auth: { locale: 'zh-CN' }, request: vi.fn(async () => ({ data: { ok: true } })), resource: vi.fn(() => ({ update })), }; engine.context.defineProperty('api', { value: api }); await engine.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } }); expect(update).toHaveBeenCalledTimes(1); expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1); expect(engine.context.api).toBe(engine.context.api); }); it('ctx.api should stay dirty-aware when resolved from a scoped context delegate', async () => { const root = new FlowEngine(); const scoped = createViewScopedEngine(root); const update = vi.fn(async () => ({ data: { data: { id: 1 } } })); root.context.defineProperty('api', { value: { auth: { locale: 'zh-CN' }, request: vi.fn(async () => ({ data: { ok: true } })), resource: vi.fn(() => ({ update })), }, }); await scoped.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } }); expect(update).toHaveBeenCalledTimes(1); expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1); expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1); }); it('ctx.request should use the dirty-aware api wrapper', async () => { const engine = new FlowEngine(); const request = vi.fn(async () => ({ data: { ok: true } })); engine.context.defineProperty('api', { value: { auth: { locale: 'zh-CN' }, request, resource: vi.fn(), }, }); await engine.context.request({ resource: 'posts', action: 'update', headers: { 'X-Data-Source': 'analytics' }, params: { filterByTk: 1 }, } as any); expect(request).toHaveBeenCalledTimes(1); expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1); }); it('ctx.request should use the caller context when resolved through a scoped delegate', async () => { const root = new FlowEngine(); const scoped = createViewScopedEngine(root); const callerCtx = new FlowContext(); const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = []; const request = vi.fn(async () => ({ data: { ok: true } })); root.context.defineProperty('api', { value: { auth: { locale: 'zh-CN' }, request, resource: vi.fn(), }, }); callerCtx.addDelegate(scoped.context); scoped.context.engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event)); await callerCtx.request({ resource: 'posts', action: 'update', params: { filterByTk: 1 }, } as any); expect(request).toHaveBeenCalledTimes(1); expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1); expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1); expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]); }); it('ctx.sql should resolve template variables from caller context in delegate chain', async () => { const engine = new FlowEngine(); const request = vi.fn(async () => ({ data: { data: [] } })); engine.context.defineProperty('api', { value: { request }, }); engine.context.defineProperty('minId', { get: () => 999, cache: false, }); const callerCtx = new FlowContext(); callerCtx.addDelegate(engine.context); callerCtx.defineProperty('minId', { get: () => 1, cache: false, }); await callerCtx.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' }); expect(request).toHaveBeenCalledTimes(1); const config = request.mock.calls[0]?.[0]; expect(config?.url).toBe('flowSql:run'); expect(config?.data.bind.__var1).toBe(1); }); it('ctx.sql should not share repository instance between different caller contexts', async () => { const engine = new FlowEngine(); const request = vi.fn(async () => ({ data: { data: [] } })); engine.context.defineProperty('api', { value: { request }, }); const caller1 = new FlowContext(); caller1.addDelegate(engine.context); caller1.defineProperty('minId', { get: () => 1, cache: false, }); const caller2 = new FlowContext(); caller2.addDelegate(engine.context); caller2.defineProperty('minId', { get: () => 2, cache: false, }); expect(caller1.sql).not.toBe(caller2.sql); await caller1.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' }); await caller2.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' }); expect(request).toHaveBeenCalledTimes(2); const config1 = request.mock.calls[0]?.[0]; const config2 = request.mock.calls[1]?.[0]; expect(config1?.data.bind.__var1).toBe(1); expect(config2?.data.bind.__var1).toBe(2); }); it('engine.context.sql should keep working when accessed directly', async () => { const engine = new FlowEngine(); const request = vi.fn(async () => ({ data: { data: [] } })); engine.context.defineProperty('api', { value: { request }, }); engine.context.defineProperty('minId', { get: () => 3, cache: false, }); await engine.context.sql.run('SELECT * FROM users WHERE id > {{ctx.minId}}', { type: 'selectRows' }); expect(request).toHaveBeenCalledTimes(1); const config = request.mock.calls[0]?.[0]; expect(config?.url).toBe('flowSql:run'); expect(config?.data.bind.__var1).toBe(3); }); it('engine.context.getVar should resolve variable by path', async () => { const engine = new FlowEngine(); engine.context.defineProperty('foo', { value: { bar: 1 } }); const v1 = await (engine.context as any).getVar('ctx.foo.bar'); expect(v1).toBe(1); await expect((engine.context as any).getVar('{{ ctx.foo.bar }}')).rejects.toThrow(); await expect((engine.context as any).getVar('foo.bar')).rejects.toThrow(); }); it('model.context.getVar should resolve the latest ctx.auth.user after user changes', async () => { const engine = new FlowEngine(); engine.context.defineProperty('api', { value: { auth: { role: 'admin', locale: 'en-US', token: 'token-1' } }, }); engine.context.defineProperty('user', { value: { id: 1, nickname: 'Alice' } }); class AuthUserModel extends FlowModel {} engine.registerModels({ AuthUserModel }); const model = engine.createModel({ use: 'AuthUserModel' }); expect(await model.context.getVar('ctx.auth.user.id')).toBe(1); expect(await model.context.getVar('ctx.auth.token')).toBe('token-1'); engine.context.api.auth.role = 'member'; engine.context.api.auth.token = 'token-2'; engine.context.defineProperty('user', { value: { id: 2, nickname: 'Bob' } }); expect(await model.context.getVar('ctx.auth.user.id')).toBe(2); expect(await model.context.getVar('ctx.auth.roleName')).toBe('member'); expect(await model.context.getVar('ctx.auth.token')).toBe('token-2'); }); 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('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('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('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 request defaults', () => { class RequestModel extends FlowModel {} afterEach(() => { vi.restoreAllMocks(); }); const createRequestContext = () => { const engine = new FlowEngine(); engine.registerModels({ RequestModel }); const apiRequest = vi.fn(async (options) => options); const app = { getApiUrl(pathname = '') { return 'https://app.example.com/api/'.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, ''); }, }; engine.context.defineProperty('api', { value: { request: apiRequest } as any }); engine.context.defineProperty('app', { value: app }); const model = engine.createModel({ use: 'RequestModel' }); const ctx = new FlowRuntimeContext(model, 'flow'); const directAxiosRequest = vi.spyOn(axios, 'request').mockResolvedValue({ data: {} } as any); return { ctx, apiRequest, directAxiosRequest }; }; it.each([ ['apiClient', 'users:list', 'api'], ['apiClient', '/api/users:list', 'api'], ['apiClient', 'https://app.example.com/api/users:list', 'api'], ['direct axios', 'https://app.example.com/custom-api/users', 'axios'], ])('should use %s for %s', async (_target, url, expected) => { const { ctx, apiRequest, directAxiosRequest } = createRequestContext(); await ctx.request({ url, method: 'get' }); if (expected === 'api') { expect(apiRequest).toHaveBeenCalledTimes(1); expect(directAxiosRequest).not.toHaveBeenCalled(); return; } expect(directAxiosRequest).toHaveBeenCalledTimes(1); expect(apiRequest).not.toHaveBeenCalled(); }); it('should use direct axios for cross-origin absolute urls', async () => { const { ctx, apiRequest, directAxiosRequest } = createRequestContext(); await ctx.request({ url: 'https://api.example.com/users', method: 'get', skipAuth: false }); expect(directAxiosRequest).toHaveBeenCalledTimes(1); expect(apiRequest).not.toHaveBeenCalled(); expect(directAxiosRequest.mock.calls[0][0]).toMatchObject({ url: 'https://api.example.com/users', method: 'get', skipAuth: false, }); }); }); 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)(); 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, hidden: false, 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, hidden: false, 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)(); 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)(); 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('resolves ctx.date expressions on client context', async () => { const engine = new FlowEngine(); const formattedToday = serializeCtxDateExpressionConfig({ kind: 'preset', preset: 'today', format: 'YYYY/MM/DD', }); const out = await (engine.context as any).resolveJsonTemplate({ today: '{{ ctx.date.preset.today }}', next12: '{{ ctx.date.relative.next.day.n12 }}', now: '{{ ctx.date.preset.now }}', formattedToday, }); expect(typeof out.today).toBe('string'); expect(out.today).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(typeof out.next12).toBe('string'); expect(out.next12).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(typeof out.now).toBe('string'); expect(out.now.length).toBeGreaterThan(0); expect(out.formattedToday).toMatch(/^\d{4}\/\d{2}\/\d{2}$/); }); 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); }); it('skips server call when all vars require contextParams but none provided', async () => { const engine = new FlowEngine(); const api = { request: vi.fn() } as any; engine.context.defineProperty('api', { value: api }); engine.context.defineProperty('foo', { value: { a: 1 }, resolveOnServer: true, serverOnlyWhenContextParams: true, meta: async () => ({ type: 'object', title: 'Foo', // no buildVariablesParams -> empty contextParams }), }); const tpl = { a: '{{ ctx.foo.a }}' } as any; const out = await (engine.context as any).resolveJsonTemplate(tpl); expect(out).toEqual({ a: 1 }); // skipped server, because all server vars (only foo) require contextParams but none present expect(api.request).not.toHaveBeenCalled(); }); it('still calls server when at least one var has contextParams (even if others require contextParams but none)', async () => { const engine = new FlowEngine(); const api = { request: vi.fn(async () => ({ data: { ok: true } })) } as any; engine.context.defineProperty('api', { value: api }); // foo: requires contextParams but none will be provided engine.context.defineProperty('foo', { value: { a: 1 }, resolveOnServer: true, serverOnlyWhenContextParams: true, meta: async () => ({ type: 'object', title: 'Foo' }), }); // user: provides contextParams via builder engine.context.defineProperty('user', { value: { id: 9 }, resolveOnServer: true, meta: async () => ({ type: 'object', title: 'User', buildVariablesParams: () => ({ collection: 'users', filterByTk: 9, dataSourceKey: 'main' }), }), }); const tpl = { a: '{{ ctx.foo.a }}', u: '{{ ctx.user.id }}' } as any; await (engine.context as any).resolveJsonTemplate(tpl); 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, hidden: false, 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, hidden: false, 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)(); 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, uiSchema: undefined, paths: ['user', 'profile', 'bio'], parentTitles: ['User', 'User Profile'], disabled: false, disabledReason: undefined, hidden: false, children: undefined, }); expect(profileSubTree[1]).toEqual({ name: 'avatar', title: 'Avatar URL', type: 'string', interface: undefined, uiSchema: undefined, paths: ['user', 'profile', 'avatar'], parentTitles: ['User', 'User Profile'], disabled: false, disabledReason: undefined, hidden: false, children: undefined, }); }); it('should support deep multi-level path like {{ ctx.data.user.profile.settings }}', () => { const ctx = new FlowContext(); ctx.defineProperty('data', { meta: { type: 'object', title: 'Data', properties: { user: { type: 'object', title: 'User Data', properties: { profile: { type: 'object', title: 'Profile', properties: { settings: { type: 'object', title: 'Settings', properties: { theme: { type: 'string', title: 'Theme' }, language: { type: 'string', title: 'Language' }, }, }, }, }, }, }, }, }, }); const settingsSubTree = ctx.getPropertyMetaTree('{{ ctx.data.user.profile.settings }}'); expect(settingsSubTree).toHaveLength(2); expect(settingsSubTree[0].name).toBe('theme'); expect(settingsSubTree[0].title).toBe('Theme'); expect(settingsSubTree[1].name).toBe('language'); expect(settingsSubTree[1].title).toBe('Language'); }); it('should return empty array when multi-level path property does not exist', () => { 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' }, }); // Test path that doesn't exist const result = ctx.getPropertyMetaTree('{{ ctx.user.nonExistent }}'); expect(result).toEqual([]); // Should return empty array for non-existent path }); }); describe('FlowContext meta sort and factory title', () => { it('should sort root nodes by meta.sort (including factory.sort)', async () => { const ctx = new FlowContext(); // Static metas with sort ctx.defineProperty('b', { meta: { type: 'string', title: 'B', sort: 10 } }); ctx.defineProperty('a', { meta: { type: 'string', title: 'A', sort: 5 } }); ctx.defineProperty('c', { meta: { type: 'string', title: 'C' } }); // sort defaults to 0 // Factory meta with title/sort on the function itself const fFactory: any = async () => ({ type: 'object', title: 'F-resolved', properties: {} }); fFactory.title = 'F-initial'; fFactory.sort = 15; ctx.defineProperty('f', { meta: fFactory }); const tree = ctx.getPropertyMetaTree(); expect(tree.map((n) => n.name)).toEqual(['f', 'b', 'a', 'c']); // factory node uses its function.title before resolve const fNode = tree[0]; expect(fNode.name).toBe('f'); expect(fNode.title).toBe('F-initial'); // After resolving children, title should update to resolved meta.title if (typeof fNode.children === 'function') { await (fNode.children as () => Promise)(); expect(fNode.title).toBe('F-resolved'); } }); it('should sort child nodes by meta.sort (sync properties)', () => { const ctx = new FlowContext(); ctx.defineProperty('parent', { meta: { type: 'object', title: 'Parent', properties: { c: { type: 'string', title: 'C', sort: 1 }, a: { type: 'string', title: 'A', sort: 3 }, b: { type: 'string', title: 'B', sort: 2 }, }, }, }); const subTree = ctx.getPropertyMetaTree('{{ ctx.parent }}'); expect(Array.isArray(subTree)).toBe(true); const names = (subTree as any[]).map((n) => n.name); expect(names).toEqual(['a', 'b', 'c']); // desc by sort }); it('should sort child nodes by meta.sort (async properties)', async () => { const ctx = new FlowContext(); ctx.defineProperty('asyncParent', { meta: { type: 'object', title: 'AsyncParent', properties: async () => ({ c: { type: 'string', title: 'C', sort: 1 }, a: { type: 'string', title: 'A', sort: 3 }, b: { type: 'string', title: 'B', sort: 2 }, }), }, }); const subTree = ctx.getPropertyMetaTree('{{ ctx.asyncParent }}'); const nodes = typeof subTree === 'function' ? await (subTree as any)() : subTree; const names = (nodes as any[]).map((n) => n.name); expect(names).toEqual(['a', 'b', 'c']); }); }); describe('FlowContext getPropertyMetaTree with complex async/sync mixing scenarios', () => { it('should handle async meta factory with async properties', async () => { const ctx = new FlowContext(); // 异步 meta factory 包含异步 properties ctx.defineProperty('complexUser', { meta: async () => ({ type: 'object', title: 'Complex User', properties: async () => ({ profile: { type: 'object', title: 'Profile', properties: { name: { type: 'string', title: 'Name' }, age: { type: 'number', title: 'Age' }, }, }, settings: { type: 'object', title: 'Settings', properties: { theme: { type: 'string', title: 'Theme' }, notifications: { type: 'boolean', title: 'Notifications' }, }, }, }), }), }); // 新行为:根级返回 children 数组(profile、settings) const rootTree = ctx.getPropertyMetaTree('{{ ctx.complexUser }}'); const ensureArray = async (v: any) => (typeof v === 'function' ? await v() : v); const arr = await ensureArray(rootTree); expect(arr).toHaveLength(2); expect(arr[0].name).toBe('profile'); expect(arr[1].name).toBe('settings'); }); it('should handle async meta factory with mixed sync/async properties in deep path', async () => { const ctx = new FlowContext(); // 复杂的嵌套场景 ctx.defineProperty('enterprise', { meta: async () => ({ type: 'object', title: 'Enterprise', properties: { // 同步 properties info: { type: 'object', title: 'Info', properties: async () => ({ // 异步 properties 返回同步结构 company: { type: 'object', title: 'Company', properties: { name: { type: 'string', title: 'Company Name' }, industry: { type: 'string', title: 'Industry' }, }, }, location: { type: 'object', title: 'Location', properties: async () => ({ // 深度异步嵌套 address: { type: 'string', title: 'Address' }, coordinates: { type: 'object', title: 'Coordinates', properties: { lat: { type: 'number', title: 'Latitude' }, lng: { type: 'number', title: 'Longitude' }, }, }, }), }, }), }, }, }), }); // 测试深度路径:{{ ctx.enterprise.info.company.name }} const companyNameTree = ctx.getPropertyMetaTree('{{ ctx.enterprise.info.company.name }}'); expect(companyNameTree).toHaveLength(1); expect(companyNameTree[0].name).toBe('name'); // 解析到了最终的 name 属性 expect(companyNameTree[0].title).toBe('name'); // 异步解析的初始 title 是 name expect(companyNameTree[0].type).toBe('object'); // 异步节点的初始类型是 object // 测试深度路径:{{ ctx.enterprise.info.location.coordinates.lat }} // 这个路径包含异步函数,会返回包装的异步节点 const coordinatesTree = ctx.getPropertyMetaTree('{{ ctx.enterprise.info.location.coordinates.lat }}'); expect(coordinatesTree).toHaveLength(1); expect(coordinatesTree[0].name).toBe('lat'); // 最终解析到 lat expect(typeof coordinatesTree[0].children).toBe('function'); // 但仍然是异步的 }); it('should handle sync meta with async properties in deep path', () => { const ctx = new FlowContext(); // 同步 meta 包含异步 properties ctx.defineProperty('project', { meta: { type: 'object', title: 'Project', properties: { details: { type: 'object', title: 'Details', properties: async () => ({ // 异步加载详细信息 metadata: { type: 'object', title: 'Metadata', properties: { version: { type: 'string', title: 'Version' }, author: { type: 'string', title: 'Author' }, }, }, dependencies: { type: 'object', title: 'Dependencies', properties: async () => ({ // 双重异步嵌套 production: { type: 'array', title: 'Production Dependencies', }, development: { type: 'array', title: 'Development Dependencies', }, }), }, }), }, }, }, }); // 测试路径:{{ ctx.project.details.metadata.version }} // 新逻辑现在能够处理这种复杂场景了! const versionTree = ctx.getPropertyMetaTree('{{ ctx.project.details.metadata.version }}'); expect(versionTree).toHaveLength(1); expect(versionTree[0].name).toBe('version'); // 成功解析到最终属性 expect(versionTree[0].title).toBe('version'); // 异步解析的初始 title 是 name expect(versionTree[0].type).toBe('object'); // 异步节点的初始类型是 object }); it('should handle async meta factory with error in deep path resolution', async () => { const ctx = new FlowContext(); ctx.defineProperty('errorProne', { meta: async () => ({ type: 'object', title: 'Error Prone', properties: { working: { type: 'object', title: 'Working Section', properties: { data: { type: 'string', title: 'Data' }, }, }, broken: { type: 'object', title: 'Broken Section', // 这里故意没有 properties,测试路径解析失败的情况 }, }, }), }); // 测试正常路径 - 新逻辑能直接解析到最终属性! const workingTree = ctx.getPropertyMetaTree('{{ ctx.errorProne.working.data }}'); expect(workingTree).toHaveLength(1); expect(workingTree[0].name).toBe('data'); // 直接解析到data属性 expect(workingTree[0].title).toBe('data'); // 异步解析的初始 title 是 name expect(workingTree[0].type).toBe('object'); // 异步节点的初始类型是 object // 新行为:根级直接返回 working、broken 子节点 const rootTree = ctx.getPropertyMetaTree('{{ ctx.errorProne }}'); const ensureArray = async (v: any) => (typeof v === 'function' ? await v() : v); const arr = await ensureArray(rootTree); const names = arr.map((n: any) => n.name).sort(); expect(names).toEqual(['broken', 'working']); // 测试不存在的路径 - 新逻辑会尝试构建异步节点,即使路径可能不存在 const brokenTree = ctx.getPropertyMetaTree('{{ ctx.errorProne.broken.nonExistent }}'); expect(brokenTree).toHaveLength(1); expect(brokenTree[0].name).toBe('nonExistent'); expect(brokenTree[0].title).toBe('nonExistent'); expect(typeof brokenTree[0].children).toBe('function'); // 构建了异步解析函数 // 测试异步解析会优雅失败(因为 broken 没有 properties) if (typeof brokenTree[0].children === 'function') { // 异步解析会失败,但被捕获并返回空数组 const result = await brokenTree[0].children(); expect(result).toEqual([]); // 错误被优雅处理,返回空数组 } }); it('should handle extremely deep async nesting', async () => { const ctx = new FlowContext(); // 创建深度嵌套的异步结构 ctx.defineProperty('deepNest', { meta: async () => ({ type: 'object', title: 'Deep Nest', properties: async () => ({ level1: { type: 'object', title: 'Level 1', properties: async () => ({ level2: { type: 'object', title: 'Level 2', properties: async () => ({ level3: { type: 'object', title: 'Level 3', properties: { final: { type: 'string', title: 'Final Value' }, }, }, }), }, }), }, }), }), }); // 测试深度路径 - 新逻辑能够深入解析到最终属性! const deepTree = ctx.getPropertyMetaTree('{{ ctx.deepNest.level1.level2.level3.final }}'); expect(deepTree).toHaveLength(1); expect(deepTree[0].name).toBe('final'); // 直接解析到最终属性! expect(deepTree[0].title).toBe('final'); // 异步解析的初始 title 是 name expect(deepTree[0].type).toBe('object'); // 异步节点的初始类型是 object // 测试中间层级的路径 const level2Tree = ctx.getPropertyMetaTree('{{ ctx.deepNest.level1.level2 }}'); expect(level2Tree).toHaveLength(1); expect(level2Tree[0].name).toBe('level2'); expect(typeof level2Tree[0].children).toBe('function'); // 仍然是异步的,因为包含异步嵌套 // 新行为:根级直接返回 level1 子节点 const rootDeepTree = ctx.getPropertyMetaTree('{{ ctx.deepNest }}'); const ensureArray = async (v: any) => (typeof v === 'function' ? await v() : v); const arr = await ensureArray(rootDeepTree); expect(arr).toHaveLength(1); expect(arr[0].name).toBe('level1'); }); it('aggregates multiple resolves into one batch request', async () => { const engine = new FlowEngine(); const api = { request: vi.fn(async (config: any) => { // Should be batched into one request with values.batch const batch = config?.data?.values?.batch; expect(Array.isArray(batch)).toBe(true); expect(batch.length).toBe(2); // Echo back distinct data for each id to verify mapping const results = batch.map((item: any, idx: number) => ({ id: item.id, data: { ok: idx + 1 } })); return { data: { data: { results } } } as any; }), } as any; engine.context.defineProperty('api', { value: api }); // Define a server-resolved variable (no builder needed) engine.context.defineProperty('foo', { value: { a: 1 }, resolveOnServer: true, }); // 使用不同的模板键,避免运行期去重(dedup)导致只保留一个 payload const t1 = { a: '{{ ctx.foo.a }}' } as any; const t2 = { b: '{{ ctx.foo.a }}' } as any; // Fire two resolves within the same micro-batch window (concurrently) const [r1, r2] = await Promise.all([ (engine.context as any).resolveJsonTemplate(t1), (engine.context as any).resolveJsonTemplate(t2), ]); // API called once with batched payload expect(api.request).toHaveBeenCalledTimes(1); // Ensure both results were mapped back correctly by order (ok:1, ok:2) expect(r1.ok).toBe(1); expect(r2.ok).toBe(2); }); });