UNPKG

@nocobase/flow-engine

Version:

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

653 lines (575 loc) 21.2 kB
/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Collection } from '../../data-source'; import { FlowRuntimeContext } from '../../flowContext'; import { FlowEngine } from '../../flowEngine'; import { FlowModel } from '../../models'; import { preprocessExpression, resolveExpressions } from '../params-resolvers'; /** * preprocessExpression 测试套件 * * preprocessExpression 是表达式预处理器的核心函数,提供以下功能: * * 1. **路径解析与分类** * - 识别单层路径(ctx.user)和多层路径(ctx.user.name) * - 排除函数调用(ctx.method())避免误处理 * - 批量收集所有需要处理的路径表达式 * * 2. **RecordProxy 检测** * - 检测一层路径是否为 RecordProxy 实例 * - 对 RecordProxy 深层路径添加双重 await 优化数据库查询 * - 对普通对象路径仅在第一层添加 await * * 3. **字符串预处理与优化** * - 在保持表达式计算顺序的前提下插入必要的 await * - 使用精确的正则替换避免误替换类似路径 * - 支持复杂表达式中的混合路径处理 * * 测试结构: * - 基础路径处理: 单层、多层路径的 await 插入 * - RecordProxy 优化: RecordProxy 实例的双重 await 处理 * - 复杂表达式支持: 运算符、方法调用等混合场景 * - 边界情况处理: 函数调用排除、路径冲突处理 */ describe('preprocessExpression', () => { let engine: FlowEngine; let model: FlowModel; let ctx: FlowRuntimeContext; let postsCollection: Collection; let usersCollection: Collection; let profilesCollection: Collection; beforeEach(() => { // 创建真实的流程引擎环境 engine = new FlowEngine(); model = new FlowModel({ uid: 'test-model', flowEngine: engine, }); ctx = new FlowRuntimeContext(model, 'test-flow', 'runtime'); // 获取数据源管理器并创建集合定义 const dataSourceManager = engine.context.dataSourceManager; const dataSource = dataSourceManager.getDataSource('main'); // 创建完整的集合定义(参考 RecordProxy.test.ts) postsCollection = new Collection({ name: 'posts', filterTargetKey: 'id', fields: [ { name: 'id', type: 'integer' }, { name: 'title', type: 'string' }, { name: 'author', type: 'belongsTo', target: 'users' }, // to-one { name: 'comments', type: 'hasMany', target: 'comments' }, // to-many ], }); usersCollection = new Collection({ name: 'users', filterTargetKey: 'id', fields: [ { name: 'id', type: 'integer' }, { name: 'name', type: 'string' }, { name: 'profile', type: 'hasOne', target: 'profiles' }, // to-one { name: 'posts', type: 'hasMany', target: 'posts' }, // to-many ], }); profilesCollection = new Collection({ name: 'profiles', filterTargetKey: 'id', fields: [ { name: 'id', type: 'integer' }, { name: 'bio', type: 'string' }, { name: 'avatar', type: 'string' }, ], }); // 将集合添加到数据源 dataSource.addCollection(postsCollection); dataSource.addCollection(usersCollection); dataSource.addCollection(profilesCollection); // 设置测试上下文数据 ctx.defineProperty('user', { value: { id: 1, name: 'John Doe', email: 'john@example.com', }, }); ctx.defineProperty('config', { value: { theme: 'dark', debug: true, }, }); ctx.defineProperty('count', { value: 42 }); ctx.defineProperty('items', { value: [ { name: 'Item 1', type: 'A' }, { name: 'Item 2', type: 'B' }, ], }); const mockRecord = { id: 1, title: 'Test Post', author: { id: 10, name: 'Author Name', }, }; // Mock APIClient request method const mockApiRequest = vi.fn(); model.context.defineProperty('api', { get: () => ({ request: mockApiRequest, }), }); }); /** * 基础路径处理功能测试组 * * 测试 preprocessExpression 的核心路径识别和 await 插入功能 * 验证单层和多层路径的正确处理 */ describe('Basic path processing', () => { /** * 测试无 ctx 路径表达式的直接返回 * 场景:传入不包含 ctx 路径的纯计算表达式 * 预期:直接返回原表达式,不进行任何处理 */ test('should directly return expressions without ctx paths', async () => { const expression = '1 + 2 * 3'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('1 + 2 * 3'); }); /** * 测试单层 ctx 路径的 await 插入 * 场景:处理 ctx.user、ctx.config 等单层属性访问 * 预期:在单层路径前添加 await,变成 await ctx.user */ test('should add await for single-layer ctx paths', async () => { const expression = 'ctx.user'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('await ctx.user'); }); /** * 测试多层 ctx 路径的分步 await 插入 * 场景:处理 ctx.user.name、ctx.config.theme 等多层属性访问 * 预期:在第一层添加 await,变成 (await ctx.user).name */ test('should add await to the first layer of multi-layer ctx paths', async () => { // 多层路径 - 输入: 'ctx.user.name', 输出: '(await ctx.user).name' const expression = 'ctx.user.name'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('(await ctx.user).name'); }); /** * 测试深层嵌套路径的正确处理 * 场景:处理 ctx.user.profile.avatar 等深层嵌套访问 * 预期:仅在第一层添加 await,保持后续访问的同步性 */ test('should correctly handle deeply nested paths', async () => { // 深层嵌套 - 输入: 'ctx.user.profile.avatar', 输出: '(await ctx.user).profile.avatar' const expression = 'ctx.user.profile.avatar'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('(await ctx.user).profile.avatar'); }); }); /** * 复杂表达式支持功能测试组 * * 测试在复杂 JavaScript 表达式中的路径处理能力 * 验证运算符、方法调用等场景下的正确处理 */ describe('Complex expression support', () => { /** * 测试包含运算符的复杂表达式处理 * 场景:表达式包含算术运算符、比较运算符等 * 预期:正确识别并处理所有 ctx 路径,不影响运算符 */ test('should correctly handle expressions containing operators', async () => { // 运算符表达式 - 输入: 'ctx.user.age > 18 && ctx.config.debug', 正确处理所有ctx路径' const expression = 'ctx.user.age > 18 && ctx.config.debug'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('(await ctx.user).age > 18 && (await ctx.config).debug'); }); /** * 测试包含数组索引访问的表达式处理 * 场景:表达式包含数组索引语法,如 ctx.items[0] * 预期:正确处理路径部分,保持数组索引语法不变 */ test('should correctly handle expressions containing array indices', async () => { const expression = 'ctx.items[0].name'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('(await ctx.items)[0].name'); }); /** * 测试多个 ctx 路径的批量处理 * 场景:单个表达式中包含多个不同的 ctx 路径 * 预期:正确识别并处理所有路径,按长度排序避免冲突 */ test('should correctly handle complex expressions with multiple ctx paths', async () => { const expression = 'ctx.user.name + " - " + ctx.config.theme + " - " + ctx.count'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('(await ctx.user).name + " - " + (await ctx.config).theme + " - " + await ctx.count'); }); /** * 测试路径长度排序的冲突避免机制 * 场景:表达式包含前缀相似的路径,如 ctx.user 和 ctx.user.name * 预期:长路径优先处理,避免短路径替换影响长路径 */ test('should sort by path length to avoid replacement conflicts', async () => { const expression = 'ctx.user.name.length + ctx.user.age'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('(await ctx.user).name.length + (await ctx.user).age'); }); }); /** * 边界情况处理功能测试组 * * 测试各种边界情况和异常场景的处理能力 * 验证函数调用排除、错误处理等机制 */ describe('Edge case handling', () => { /** * 测试函数调用的正确排除 * 场景:表达式包含 ctx.method() 形式的函数调用 * 预期:函数调用不被当作路径处理,保持原始形式 * 核心功能:验证函数调用识别和排除机制的准确性 */ test('should exclude function calls and not perform path processing', async () => { const expression = 'ctx.method() + ctx.user.name'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('ctx.method() + (await ctx.user).name'); }); /** * 测试带空格的函数调用排除 * 场景:函数调用前有空格,如 ctx.method () * 预期:正确识别为函数调用并排除处理 * 核心功能:验证空格容忍的函数调用识别机制 */ test('should exclude function calls with spaces', async () => { const expression = 'ctx.method () + ctx.config.theme'; const result = await preprocessExpression(expression, ctx); expect(result).toBe('ctx.method () + (await ctx.config).theme'); }); }); }); /** * resolveExpressions 测试套件 * * resolveExpressions 是参数表达式解析器,提供以下核心功能: * * 1. **表达式解析与求值** * - 支持单表达式 `{{ ctx.user.name }}` 和多表达式模板字符串 * - 使用 SES (Secure ECMAScript) 沙箱环境安全执行 JavaScript 代码 * - 支持复杂表达式:运算符、方法调用、条件判断等 * * 2. **RecordProxy** * - 自动检测 RecordProxy 实例 * - 对 RecordProxy 深层路径使用整体 await,避免多次数据库请求 * - 普通对象使用分步解析,支持嵌套属性访问 * * 3. **类型安全与错误处理** * - 表达式执行失败时返回原始字符串,保证数据完整性 * - 支持各种数据类型:字符串、数字、对象、数组、null/undefined * - 完善的边界情况处理和容错机制 * * 测试结构: * - 静态内容处理: 不包含表达式的参数原样返回 * - 单表达式解析: 基础的 `{{ }}` 表达式解析功能 * - 多表达式解析: 模板字符串中包含多个表达式 * - 对象和数组遍历: 复杂数据结构的递归解析 * - RecordProxy 支持: 数据库关联对象的智能处理 * - 复杂表达式支持: 运算符、方法调用等高级表达式 * - 边界情况和错误处理: 异常情况的容错处理 */ describe('resolveExpressions', () => { let engine: FlowEngine; let model: FlowModel; let ctx: FlowRuntimeContext; beforeEach(() => { // 创建真实的上下文环境 engine = new FlowEngine(); model = engine.createModel({ use: 'FlowModel' }); ctx = new FlowRuntimeContext(model, 'test-flow', 'runtime'); // 设置测试数据 ctx.defineProperty('user', { value: { name: 'John Doe', age: 30, profile: { email: 'john@example.com', role: 'admin', }, }, }); ctx.defineProperty('config', { value: { theme: 'dark', notifications: true, }, }); ctx.defineProperty('items', { value: [ { id: 1, title: 'Item 1' }, { id: 2, title: 'Item 2' }, ], }); // 设置数值数据用于运算测试 ctx.defineProperty('aa', { value: { bb: 10, }, }); ctx.defineProperty('cc', { value: 5 }); // 设置方法用于方法调用测试 ctx.defineMethod('method1', (a, b) => { return a + b; }); ctx.defineMethod('multiply', (x, y) => { return x * y; }); // 获取数据源管理器并创建集合定义 const dataSourceManager = engine.context.dataSourceManager; const dataSource = dataSourceManager.getDataSource('main'); // 创建完整的集合定义 const usersCollection = new Collection({ name: 'users', filterTargetKey: 'id', fields: [ { name: 'id', type: 'integer' }, { name: 'name', type: 'string' }, { name: 'posts', type: 'hasMany', target: 'posts' }, ], }); const postsCollection = new Collection({ name: 'posts', filterTargetKey: 'id', fields: [ { name: 'id', type: 'integer' }, { name: 'title', type: 'string' }, { name: 'content', type: 'string' }, ], }); // 将集合添加到数据源 dataSource.addCollection(usersCollection); dataSource.addCollection(postsCollection); // Mock APIClient request method const mockApiRequest = vi.fn(); model.context.defineProperty('api', { get: () => ({ request: mockApiRequest, }), }); const mockRecord = { id: 1, name: 'John Doe', posts: [ { id: 1, title: 'Post 1', content: 'Content 1' }, { id: 2, title: 'Post 2', content: 'Content 2' }, ], }; // 创建 RecordProxy 实例 }); // 测试核心功能:单表达式解析 // 验证 {{ }} 表达式的解析能力,包括简单属性访问和嵌套路径访问 describe('Single expression resolution', () => { // 简单表达式 - 输入: {{ctx.user.name}}, 输出: 'John Doe' test('should resolve simple context property access', async () => { const params = '{{ctx.user.name}}'; const result = await resolveExpressions(params, ctx); expect(result).toEqual('John Doe'); }); // 简单表达式 - 输入: {{ctx.user.name}}, 输出: 'John Doe' test('should resolve simple context property access', async () => { const params = { userName: '{{ctx.user.name}}', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ userName: 'John Doe', }); }); // 深层属性 - 输入: {{ctx.user.profile.email}}, 输出: 'john@example.com' test('should resolve deeply nested property paths', async () => { const params = { email: '{{ctx.user.profile.email}}', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ email: 'john@example.com', }); }); // 复杂对象 - 输入: {{ctx.user}}, 输出: 完整用户对象 test('should resolve expressions returning complex objects', async () => { const params = { userData: '{{ctx.user}}', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ userData: { name: 'John Doe', age: 30, profile: { email: 'john@example.com', role: 'admin', }, }, }); }); // 数组类型 - 输入: {{ctx.items}}, 输出: 数组对象 test('should resolve array type expressions', async () => { const params = { itemList: '{{ctx.items}}', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ itemList: [ { id: 1, title: 'Item 1' }, { id: 2, title: 'Item 2' }, ], }); }); }); // 测试高级功能:多表达式模板字符串 // 验证在单个字符串中包含多个 {{ }} 表达式的解析和替换能力 describe('Multiple expressions in template strings', () => { /** * 测试多表达式模板字符串的解析 * 场景:解析包含多个 {{ }} 表达式的模板字符串 * 预期:正确识别并替换所有表达式,生成最终的文本内容 * 核心功能:验证模板字符串处理能力和表达式批量替换功能 */ test('should resolve multiple expressions in template strings', async () => { const params = { message: 'User: {{ctx.user.name}}, Age: {{ctx.user.age}}', profileInfo: 'Profile: {{ctx.user.profile}}', welcomeMessage: 'Welcome {{ctx.user.name}}! Your theme is {{ctx.config.theme}}.', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ message: 'User: John Doe, Age: 30', profileInfo: 'Profile: {"email":"john@example.com","role":"admin"}', welcomeMessage: 'Welcome John Doe! Your theme is dark.', }); }); }); describe('Expression resolution in objects', () => { test('should recursively resolve expressions in objects and preserve types', async () => { const params = { greeting: 'Hello {{ctx.user.name}}', userInfo: { email: '{{ctx.user.profile.email}}', role: '{{ctx.user.profile.role}}', }, static: 'No expressions here', // 测试单个表达式的类型保持 user: '{{ctx.user}}', age: '{{ctx.user.age}}', config: '{{ctx.config}}', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ greeting: 'Hello John Doe', userInfo: { email: 'john@example.com', role: 'admin', }, static: 'No expressions here', user: { name: 'John Doe', age: 30, profile: { email: 'john@example.com', role: 'admin', }, }, age: 30, config: { theme: 'dark', notifications: true, }, }); }); }); describe('Expression resolution in arrays and complex structures', () => { test('should recursively resolve expressions in arrays and nested structures', async () => { const params = { simpleArray: [ 'Hello {{ctx.user.name}}', '{{ctx.user.age}}', { email: '{{ctx.user.profile.email}}', static: 'unchanged', }, ], complexStructure: { users: [ { name: '{{ctx.user.name}}', role: 'static-role' }, { name: 'Static User', role: '{{ctx.user.profile.role}}' }, ], config: ['{{ctx.config.theme}}', 'static-value'], }, }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ simpleArray: [ 'Hello John Doe', 30, { email: 'john@example.com', static: 'unchanged', }, ], complexStructure: { users: [ { name: 'John Doe', role: 'static-role' }, { name: 'Static User', role: 'admin' }, ], config: ['dark', 'static-value'], }, }); }); }); describe('Edge case handling', () => { test('should handle non-existent property paths', async () => { const params = { value: '{{ctx.nonexistent.property}}', }; const result = await resolveExpressions(params, ctx); // 不存在的属性应该返回 undefined expect(result).toEqual({ value: undefined, }); }); test('should handle expressions with various space formats', async () => { const params = { noSpace: '{{ctx.user.name}}', leftSpace: '{{ ctx.user.name}}', rightSpace: '{{ctx.user.name }}', bothSpace: '{{ ctx.user.name }}', }; const result = await resolveExpressions(params, ctx); expect(result).toEqual({ noSpace: 'John Doe', leftSpace: 'John Doe', rightSpace: 'John Doe', bothSpace: 'John Doe', }); }); test('should handle null and undefined values', async () => { ctx.defineProperty('nullValue', { value: null }); ctx.defineProperty('undefinedValue', { value: undefined }); const params = { nullTest: '{{ctx.nullValue}}', undefinedTest: '{{ctx.undefinedValue}}', }; const result = await resolveExpressions(params, ctx); expect(result.nullTest).toBe(null); expect(result.undefinedTest).toBe(undefined); }); }); });