feat(plugin-workflow): add test run for nodes (#5407)

* feat(plugin-workflow): add test run for nodes

* fix(plugin-workflow): fix locale

* test(plugin-workflow): add test cases
This commit is contained in:
Junyi 2024-10-16 22:39:20 +08:00 committed by GitHub
parent 05b9703101
commit 379ae83862
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 455 additions and 35 deletions

View File

@ -480,6 +480,7 @@ export function Input(props: VariableInputProps) {
margin-left: -1px;
`)}
type={variable ? 'primary' : 'default'}
disabled={disabled}
/>
)}
</Cascader>

View File

@ -25,7 +25,7 @@ function setNativeInputValue(input, value) {
export function RawTextArea(props): JSX.Element {
const inputRef = useRef<any>(null);
const { changeOnSelect, component: Component = Input.TextArea, fieldNames, scope, ...others } = props;
const { changeOnSelect, component: Component = Input.TextArea, fieldNames, scope, buttonClass, ...others } = props;
const dataScope = typeof scope === 'function' ? scope() : scope;
const [options, setOptions] = useState(dataScope ? dataScope : []);
@ -66,7 +66,7 @@ export function RawTextArea(props): JSX.Element {
>
<VariableSelect
className={
props.buttonClass ??
buttonClass ??
css`
&:not(:hover) {
border-right-color: transparent;

View File

@ -350,7 +350,7 @@ export default class extends Instruction {
},
ignoreFail: {
type: 'boolean',
title: `{{t("Ignore failed request and continue workflow", { ns: "${NAMESPACE}" })}}`,
'x-content': `{{t("Ignore failed request and continue workflow", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
@ -390,4 +390,5 @@ export default class extends Instruction {
],
};
}
testable = true;
}

View File

@ -17,10 +17,10 @@ export interface Header {
value: string;
}
export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'params' | 'data' | 'timeout'> & {
headers: Array<Header>;
export type RequestInstructionConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'params' | 'data' | 'timeout'> & {
headers?: Header[];
contentType: string;
ignoreFail: boolean;
ignoreFail?: boolean;
onlyData?: boolean;
};
@ -103,7 +103,7 @@ function responseFailure(error) {
export default class extends Instruction {
async run(node: FlowNodeModel, prevJob, processor: Processor) {
const config = processor.getParsedValue(node.config, node.id) as RequestConfig;
const config = processor.getParsedValue(node.config, node.id) as RequestInstructionConfig;
const { workflow } = processor.execution;
const sync = this.workflow.isWorkflowSync(workflow);
@ -171,10 +171,25 @@ export default class extends Instruction {
}
async resume(node: FlowNodeModel, job, processor: Processor) {
const { ignoreFail } = node.config as RequestConfig;
const { ignoreFail } = node.config as RequestInstructionConfig;
if (ignoreFail) {
job.set('status', JOB_STATUS.RESOLVED);
}
return job;
}
async test(config: RequestInstructionConfig) {
try {
const response = await request(config);
return {
status: JOB_STATUS.RESOLVED,
result: responseSuccess(response, config.onlyData),
};
} catch (error) {
return {
status: config.ignoreFail ? JOB_STATUS.RESOLVED : JOB_STATUS.FAILED,
result: error.isAxiosError ? error.toJSON() : error.message,
};
}
}
}

View File

@ -19,7 +19,7 @@ import { MockServer } from '@nocobase/test';
import PluginWorkflow, { EXECUTION_STATUS, JOB_STATUS, Processor } from '@nocobase/plugin-workflow';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { RequestConfig } from '../RequestInstruction';
import RequestInstruction, { RequestConfig } from '../RequestInstruction';
const HOST = 'localhost';
@ -111,6 +111,7 @@ describe('workflow > instructions > request', () => {
let WorkflowModel;
let workflow;
let api: MockAPI;
let instruction: RequestInstruction;
beforeEach(async () => {
api = new MockAPI();
@ -123,6 +124,9 @@ describe('workflow > instructions > request', () => {
});
db = app.db;
instruction = (app.pm.get(PluginWorkflow) as PluginWorkflow).instructions.get('request') as RequestInstruction;
WorkflowModel = db.getCollection('workflows').model;
PostCollection = db.getCollection('posts');
PostRepo = PostCollection.repository;
@ -606,4 +610,68 @@ describe('workflow > instructions > request', () => {
expect(job.result.status).toBe(404);
});
});
describe('test run', () => {
it('invalid config', async () => {
const { status, result } = await instruction.test(Object.create({}));
expect(status).toBe(JOB_STATUS.FAILED);
expect(result).toBe("Cannot read properties of null (reading 'replace')");
});
it('data url', async () => {
const { status, result } = await instruction.test({
url: api.URL_DATA,
method: 'POST',
contentType: 'application/json',
data: { a: 1 },
});
expect(status).toBe(JOB_STATUS.RESOLVED);
expect(result.status).toBe(200);
expect(result.data).toEqual({ meta: {}, data: { a: 1 } });
});
it('404', async () => {
const { status, result } = await instruction.test({
url: api.URL_404,
method: 'GET',
contentType: '',
});
expect(status).toBe(JOB_STATUS.FAILED);
expect(result.status).toBe(404);
});
it('timeout', async () => {
const { status, result } = await instruction.test({
url: api.URL_TIMEOUT,
method: 'GET',
timeout: 1000,
contentType: '',
});
expect(status).toBe(JOB_STATUS.FAILED);
expect(result.code).toBe('ECONNABORTED');
});
it('ignoreFail', async () => {
const { status, result } = await instruction.test({
url: api.URL_404,
method: 'GET',
ignoreFail: true,
contentType: '',
});
expect(status).toBe(JOB_STATUS.RESOLVED);
expect(result.status).toBe(404);
});
it('timeout and ignoreFail', async () => {
const { status, result } = await instruction.test({
url: api.URL_TIMEOUT,
method: 'GET',
timeout: 1000,
ignoreFail: true,
contentType: '',
});
expect(status).toBe(JOB_STATUS.RESOLVED);
expect(result.code).toBe('ECONNABORTED');
});
});
});

View File

@ -80,4 +80,5 @@ export default class extends Instruction {
[fieldNames.label]: title,
};
}
testable = true;
}

View File

@ -7,14 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '@nocobase/plugin-workflow';
export type SQLInstructionConfig = {
dataSource?: string;
sql?: string;
withMeta?: boolean;
};
export default class extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const dataSourceName = node.config.dataSource || 'main';
// @ts-ignore
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
if (!db) {
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
if (!(collectionManager instanceof SequelizeCollectionManager)) {
throw new Error(`type of data source "${node.config.dataSource}" is not database`);
}
const sql = processor.getParsedValue(node.config.sql || '', node.id).trim();
@ -25,7 +31,7 @@ export default class extends Instruction {
}
const [result = null, meta = null] =
(await db.sequelize.query(sql, {
(await collectionManager.db.sequelize.query(sql, {
transaction: this.workflow.useDataSourceTransaction(dataSourceName, processor.transaction),
// plain: true,
// model: db.getCollection(node.config.collection).model
@ -36,4 +42,33 @@ export default class extends Instruction {
status: JOB_STATUS.RESOLVED,
};
}
async test({ dataSource, sql, withMeta }: SQLInstructionConfig = {}) {
if (!sql) {
return {
result: null,
status: JOB_STATUS.RESOLVED,
};
}
const dataSourceName = dataSource || 'main';
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
if (!(collectionManager instanceof SequelizeCollectionManager)) {
throw new Error(`type of data source "${dataSource}" is not database`);
}
try {
const [result = null, meta = null] = (await collectionManager.db.sequelize.query(sql)) ?? [];
return {
result: withMeta ? [result, meta] : result,
status: JOB_STATUS.RESOLVED,
};
} catch (error) {
return {
result: error.message,
status: JOB_STATUS.ERROR,
};
}
}
}

View File

@ -9,10 +9,11 @@
import Database, { fn } from '@nocobase/database';
import { Application } from '@nocobase/server';
import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import WorkflowPluginServer, { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import Plugin from '..';
import SQLInstruction from '../SQLInstruction';
const mysql = process.env.DB_DIALECT === 'mysql' ? describe : describe.skip;
@ -24,12 +25,13 @@ describe('workflow > instructions > sql', () => {
let ReplyRepo;
let WorkflowModel;
let workflow;
let instruction: SQLInstruction;
beforeEach(async () => {
app = await getApp({
plugins: [Plugin],
});
instruction = (app.pm.get(WorkflowPluginServer) as WorkflowPluginServer).instructions.get('sql') as SQLInstruction;
db = app.db;
WorkflowModel = db.getCollection('workflows').model;
PostCollection = db.getCollection('posts');
@ -334,4 +336,23 @@ describe('workflow > instructions > sql', () => {
});
});
});
describe('test', () => {
it('empty sql', async () => {
const { status, result } = await instruction.test({});
expect(status).toBe(JOB_STATUS.RESOLVED);
expect(result).toBe(null);
});
it('invalid sql', async () => {
const { status, result } = await instruction.test({ sql: '1' });
expect(status).toBe(JOB_STATUS.ERROR);
});
it('valid sql', async () => {
const { status, result } = await instruction.test({ sql: 'select 1 as a' });
expect(status).toBe(JOB_STATUS.RESOLVED);
expect(result).toEqual([{ a: 1 }]);
});
});
});

View File

@ -17,6 +17,12 @@ export default {
result: config.path == null ? result : lodash.get(result, config.path),
};
},
test(config = {}) {
return {
status: 1,
result: null,
};
},
},
echoVariable: {
@ -40,6 +46,11 @@ export default {
status: 0,
};
},
test() {
return {
status: 0,
};
},
},
prompt: {

View File

@ -8,12 +8,12 @@
*/
import { CloseOutlined, DeleteOutlined } from '@ant-design/icons';
import { createForm } from '@formily/core';
import { createForm, Field } from '@formily/core';
import { toJS } from '@formily/reactive';
import { ISchema, useForm } from '@formily/react';
import { Alert, App, Button, Dropdown, Input, Tag, Tooltip, message } from 'antd';
import { cloneDeep, get } from 'lodash';
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { ISchema, observer, useField, useForm } from '@formily/react';
import { Alert, App, Button, Dropdown, Empty, Input, Space, Tag, Tooltip, message } from 'antd';
import { cloneDeep, get, set } from 'lodash';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@ -21,10 +21,12 @@ import {
FormProvider,
SchemaComponent,
SchemaInitializerItemType,
Variable,
css,
cx,
useAPIClient,
useActionContext,
useCancelAction,
useCompile,
usePlugin,
useResourceActionContext,
@ -40,7 +42,7 @@ import { JobStatusOptionsMap } from '../constants';
import { useGetAriaLabelOfAddButton } from '../hooks/useGetAriaLabelOfAddButton';
import { lang } from '../locale';
import useStyles from '../style';
import { UseVariableOptions, VariableOption } from '../variable';
import { UseVariableOptions, VariableOption, WorkflowVariableInput } from '../variable';
export type NodeAvailableContext = {
engine: WorkflowPlugin;
@ -58,7 +60,7 @@ export abstract class Instruction {
* @experimental
*/
options?: { label: string; value: any; key: string }[];
fieldset: { [key: string]: ISchema };
fieldset: Record<string, ISchema>;
/**
* @experimental
*/
@ -80,6 +82,7 @@ export abstract class Instruction {
*/
isAvailable?(ctx: NodeAvailableContext): boolean;
end?: boolean | ((node) => boolean);
testable?: boolean;
}
function useUpdateAction() {
@ -304,6 +307,194 @@ function useFormProviderProps() {
return { form: useForm() };
}
const useRunAction = () => {
const { values, query } = useForm();
const node = useNodeContext();
const api = useAPIClient();
const ctx = useActionContext();
const field = useField<Field>();
return {
async run() {
const template = parse(node.config);
const config = template(toJS(values.config));
const resultField = query('result').take() as Field;
resultField.setValue(null);
resultField.setFeedback({});
field.data = field.data || {};
field.data.loading = true;
try {
const {
data: { data },
} = await api.resource('flow_nodes').test({
values: {
config,
type: node.type,
},
});
resultField.setFeedback({
type: data.status > 0 ? 'success' : 'error',
messages: data.status > 0 ? [lang('Resolved')] : [lang('Failed')],
});
resultField.setValue(data.result);
} catch (err) {
resultField.setFeedback({
type: 'error',
messages: err.message,
});
}
field.data.loading = false;
ctx.setFormValueChanged(false);
},
};
};
const VariableKeysContext = createContext<string[]>([]);
function VariableReplacer({ name, value, onChange }) {
return (
<Space>
<WorkflowVariableInput variableOptions={{}} value={`{{${name}}}`} disabled />
<Variable.Input useTypedConstant={['string', 'number', 'boolean', 'date']} value={value} onChange={onChange} />
</Space>
);
}
function TestFormFieldset({ value, onChange }) {
const keys = useContext(VariableKeysContext);
return keys.length ? (
<>
{keys.map((key) => (
<VariableReplacer
key={key}
name={key}
value={get(value, key)}
onChange={(v) => {
set(value, key, v);
onChange(value);
}}
/>
))}
</>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={lang('No variable')} style={{ margin: '1em' }} />
);
}
function TestButton() {
const node = useNodeContext();
const { values } = useForm();
const template = parse(values);
const keys = template.parameters.map((item) => item.key);
const form = useMemo(() => createForm(), []);
return (
<NodeContext.Provider value={{ type: node.type, config: values }}>
<VariableKeysContext.Provider value={keys}>
<SchemaComponent
components={{
Alert,
}}
scope={{
useCancelAction,
useRunAction,
}}
schema={{
type: 'void',
name: 'testButton',
title: '{{t("Test run")}}',
'x-component': 'Action',
'x-component-props': {
icon: 'CaretRightOutlined',
// openSize: 'small',
},
properties: {
modal: {
type: 'void',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
},
'x-component': 'Action.Modal',
title: `{{t("Test run", { ns: "workflow" })}}`,
properties: {
alert: {
type: 'void',
'x-component': 'Alert',
'x-component-props': {
message: `{{t("Test run will do the actual data manipulating or API calling, please use with caution.", { ns: "workflow" })}}`,
type: 'warning',
showIcon: true,
className: css`
margin-bottom: 1em;
`,
},
},
config: {
type: 'object',
title: '{{t("Replace variables", { ns: "workflow" })}}',
'x-decorator': 'FormItem',
'x-component': TestFormFieldset,
},
actions: {
type: 'void',
'x-component': 'ActionBar',
properties: {
submit: {
type: 'void',
title: '{{t("Run")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useRunAction }}',
},
},
},
},
result: {
type: 'string',
title: `{{t("Result", { ns: "workflow" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Input.JSON',
'x-component-props': {
autoSize: {
minRows: 5,
maxRows: 20,
},
style: {
whiteSpace: 'pre',
cursor: 'text',
},
},
'x-pattern': 'disabled',
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
cancel: {
type: 'void',
title: '{{t("Close")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
},
},
},
},
},
}}
/>
</VariableKeysContext.Provider>
</NodeContext.Provider>
);
}
export function NodeDefaultView(props) {
const { data, children } = props;
const compile = useCompile();
@ -515,25 +706,45 @@ export function NodeDefaultView(props) {
},
properties: instruction.fieldset,
},
actions: workflow.executed
footer: workflow.executed
? null
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
style: {
flexGrow: 1,
},
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useUpdateAction }}',
properties: {
...(instruction.testable
? {
test: {
type: 'void',
'x-component': observer(TestButton),
'x-align': 'left',
},
}
: {}),
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useUpdateAction }}',
},
},
},
},
},

View File

@ -203,5 +203,8 @@
"End process": "结束流程",
"End the process immediately, with set status.": "以设置的状态立即结束流程。",
"End status": "结束状态",
"Succeeded": "成功"
"Succeeded": "成功",
"Test run": "测试执行",
"Test run will do the actual data manipulating or API calling, please use with caution.": "测试执行会进行实际的数据操作或 API 调用,请谨慎使用。",
"No variable": "无变量"
}

View File

@ -251,6 +251,7 @@ export default class PluginWorkflowServer extends Plugin {
'executions:destroy',
'flow_nodes:update',
'flow_nodes:destroy',
'flow_nodes:test',
],
});

View File

@ -10,6 +10,7 @@
import { MockServer } from '@nocobase/test';
import Database from '@nocobase/database';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { JOB_STATUS } from '../../constants';
describe('workflow > actions > workflows', () => {
let app: MockServer;
@ -244,4 +245,32 @@ describe('workflow > actions > workflows', () => {
expect(nodes.length).toBe(0);
});
});
describe('test', () => {
it('test method not implemented', async () => {
const { status } = await agent.resource('flow_nodes').test({ values: { type: 'error' } });
expect(status).toBe(400);
});
it('test method implemented', async () => {
const {
status,
body: { data },
} = await agent.resource('flow_nodes').test({ values: { type: 'echo' } });
expect(status).toBe(200);
expect(data.status).toBe(JOB_STATUS.RESOLVED);
});
it('test with pending status', async () => {
const {
status,
body: { data },
} = await agent.resource('flow_nodes').test({ values: { type: 'pending' } });
expect(status).toBe(200);
expect(data.status).toBe(JOB_STATUS.PENDING);
});
});
});

View File

@ -30,6 +30,7 @@ export default function ({ app }) {
...make('flow_nodes', {
update: nodes.update,
destroy: nodes.destroy,
test: nodes.test,
}),
...make('executions', executions),
});

View File

@ -9,6 +9,7 @@
import { Context, utils } from '@nocobase/actions';
import { MultipleRelationRepository, Op, Repository } from '@nocobase/database';
import WorkflowPlugin from '..';
import type { WorkflowModel } from '../types';
export async function create(context: Context, next) {
@ -219,3 +220,23 @@ export async function update(context: Context, next) {
await next();
}
export async function test(context: Context, next) {
const { values = {} } = context.action.params;
const { type, config = {} } = values;
const plugin = context.app.pm.get(WorkflowPlugin) as WorkflowPlugin;
const instruction = plugin.instructions.get(type);
if (!instruction) {
context.throw(400, `instruction "${type}" not registered`);
}
if (typeof instruction.test !== 'function') {
context.throw(400, `test method of instruction "${type}" not implemented`);
}
try {
context.body = await instruction.test(config);
} catch (error) {
context.throw(500, error.message);
}
next();
}

View File

@ -29,6 +29,7 @@ export type InstructionInterface = {
resume?: Runner;
getScope?: (node: FlowNodeModel, data: any, processor: Processor) => any;
duplicateConfig?: (node: FlowNodeModel, options: Transactionable) => object | Promise<object>;
test?: (config: Record<string, any>) => IJob | Promise<IJob>;
};
// what should a instruction do?