UNPKG

@nocobase/flow-engine

Version:

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

213 lines (178 loc) 7.46 kB
/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ /** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. */ import { describe, expect, it, vi } from 'vitest'; import { APIClient as SDKApiClient } from '@nocobase/sdk'; import { FlowEngine } from '../flowEngine'; import { createViewScopedEngine } from '../ViewScopedFlowEngine'; import { FlowModel } from '../models'; describe('ViewScopedFlowEngine', () => { it('shares global actions/events and model classes with parent', async () => { const parent = new FlowEngine(); parent.registerActions({ ping: { name: 'ping', handler: () => 'pong', }, }); const child = createViewScopedEngine(parent); // child can read parent's action expect(child.getAction('ping')).toBeDefined(); // registering via child should affect parent child.registerActions({ pong: { name: 'pong', handler: () => 'ok' } }); expect(parent.getAction('pong')).toBeDefined(); // model class resolution is proxied class TestModel extends FlowModel {} parent.registerModels({ TestModel }); expect(child.getModelClass('TestModel')).toBeDefined(); }); it('isolates model instances map by default', () => { const parent = new FlowEngine(); const child = createViewScopedEngine(parent); class TestModel extends FlowModel {} const uid = 'same-uid'; const pm = parent.createModel<TestModel>({ use: TestModel, uid }); const cm = child.createModel<TestModel>({ use: TestModel, uid }); expect(pm).not.toBe(cm); // 默认 getModel 仅在当前作用域查找 expect(parent.getModel(uid)).toBe(pm); expect(child.getModel(uid)).toBe(cm); }); it('isolates auto flow cache across engines with identical model uid', async () => { const parent = new FlowEngine(); const child = createViewScopedEngine(parent); let count = 0; class CounterModel extends FlowModel {} CounterModel.registerFlow({ key: 'autoCounter', steps: { s1: { handler: async () => { count += 1; return count; }, }, }, }); const uid = 'model-uid-cache'; const pm = parent.createModel<CounterModel>({ use: CounterModel, uid }); const cm = child.createModel<CounterModel>({ use: CounterModel, uid }); await pm.applyAutoFlows(); expect(count).toBe(1); // If cache was shared, the next call would hit cache and not increment. await cm.applyAutoFlows(); expect(count).toBe(2); }); it('creates resources using parent registry but binds to scoped context by default', () => { const parent = new FlowEngine(); const child = createViewScopedEngine(parent); // mock minimal api to satisfy ctx.auth and related getters const api = new SDKApiClient({ storageType: 'memory' }); api.auth.role = 'guest'; api.auth.locale = 'en-US'; api.auth.token = 't'; parent.context.defineProperty('api', { value: api }); // use built-in FlowResource class name const res = child.createResource('FlowResource'); // resource context is a wrapper that delegates to child context child.context.defineProperty('foo', { value: 123 }); expect((res as any).context.foo).toBe(123); }); it('uses local context and delegates to parent context', () => { const parent = new FlowEngine(); const child = createViewScopedEngine(parent); // In runtime, context.api is always provided by host app. // Provide a minimal mock here to avoid incidental getter access (e.g. ctx.auth) from throwing. const api = new SDKApiClient({ storageType: 'memory' }); api.auth.role = 'guest'; api.auth.locale = 'en-US'; api.auth.token = 't'; parent.context.defineProperty('api', { value: api }); const parentCtx = parent.context; const childCtx = child.context; expect(Object.is(childCtx, parentCtx)).toBe(true); // define a value on parent context parent.context.defineProperty('foo', { value: 42 }); // child context should be able to read via delegate chain expect((child.context as any).foo).toBe(42); }); it('delegates saveModel to parent (concurrency gate sharing)', async () => { const parent = new FlowEngine(); const child = createViewScopedEngine(parent); const spy = vi.spyOn(parent, 'saveModel').mockResolvedValueOnce(true); class T extends FlowModel {} const m = child.createModel<T>({ use: T }); await child.saveModel(m); expect(spy).toHaveBeenCalledOnce(); }); it('stacks multiple scoped engines on tail (linkAfter attaches to bottom)', () => { const root = new FlowEngine(); const c1 = createViewScopedEngine(root); const c2 = createViewScopedEngine(root); // child pointers point back correctly expect(c1.previousEngine).toBe(root); expect(c2.previousEngine?.previousEngine).toBe(root); // chain length from root is 2 (two scoped engines) let count = 0; let p = root.nextEngine; while (p) { count += 1; p = p.nextEngine; } expect(count).toBe(2); }); it('supports stacking when anchoring from nested child (still appends to bottom)', () => { const root = new FlowEngine(); const c1 = createViewScopedEngine(root); const c2 = createViewScopedEngine(c1); // pass child as anchor expect(c1.previousEngine).toBe(root); expect(c2.previousEngine?.previousEngine).toBe(root); }); it('global model lookup traverses from top to root across stack', () => { const root = new FlowEngine(); const c1 = createViewScopedEngine(root); const c2 = createViewScopedEngine(root); class TM extends FlowModel {} const uid = 'same-uid-global-lookup'; const mRoot = root.createModel<TM>({ use: TM, uid }); const m1 = c1.createModel<TM>({ use: TM, uid }); const m2 = c2.createModel<TM>({ use: TM, uid }); // sanity: each scope returns its own when not global expect(root.getModel(uid)).toBe(mRoot); expect(c1.getModel(uid)).toBe(m1); expect(c2.getModel(uid)).toBe(m2); // global search from any engine hits the top-most engine's instance first expect(root.getModel(uid, true)).toBe(m2); expect(c1.getModel(uid, true)).toBe(m2); expect(c2.getModel(uid, true)).toBe(m2); }); it('unlinkFromStack detaches only the top engine', () => { const root = new FlowEngine(); const c1 = createViewScopedEngine(root); const c2 = createViewScopedEngine(root); class TM extends FlowModel {} const uid = 'unlink-top-only'; c1.createModel<TM>({ use: TM, uid }); const m2 = c2.createModel<TM>({ use: TM, uid }); // before unlink: top is c2 expect(root.getModel(uid, true)).toBe(m2); // unlink the top scoped engine c2.unlinkFromStack(); // After unlink, root should have only one child (c1). Its next should be undefined. expect(root.nextEngine?.nextEngine).toBeUndefined(); expect(root.nextEngine?.previousEngine).toBe(root); // global lookup should now resolve to c1's instance const resolved = root.getModel<TM>(uid, true); expect(resolved?.flowEngine.previousEngine).toBe(root); }); });