8726 workflow add a test button in workflow code step (#9016)
- add test button to workflow code step - add test tab to workflow code step https://github.com/user-attachments/assets/e180a827-7321-49a2-8026-88490c557da2  
This commit is contained in:
@ -0,0 +1,73 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
CodeEditor,
|
||||
CoreEditorHeader,
|
||||
IconSquareRoundedCheck,
|
||||
} from 'twenty-ui';
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||
import {
|
||||
DEFAULT_OUTPUT_VALUE,
|
||||
ServerlessFunctionTestData,
|
||||
} from '@/workflow/states/serverlessFunctionTestDataFamilyState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledOutput = styled.div<{ status?: ServerlessFunctionExecutionStatus }>`
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
color: ${({ theme, status }) =>
|
||||
status === ServerlessFunctionExecutionStatus.Success
|
||||
? theme.color.turquoise
|
||||
: theme.color.red};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const ServerlessFunctionExecutionResult = ({
|
||||
serverlessFunctionTestData,
|
||||
}: {
|
||||
serverlessFunctionTestData: ServerlessFunctionTestData;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const result =
|
||||
serverlessFunctionTestData.output.data ||
|
||||
serverlessFunctionTestData.output.error ||
|
||||
'';
|
||||
|
||||
const leftNode =
|
||||
serverlessFunctionTestData.output.data === DEFAULT_OUTPUT_VALUE ? (
|
||||
'Output'
|
||||
) : (
|
||||
<StyledOutput status={serverlessFunctionTestData.output.status}>
|
||||
<IconSquareRoundedCheck size={theme.icon.size.md} />
|
||||
{serverlessFunctionTestData.output.status ===
|
||||
ServerlessFunctionExecutionStatus.Success
|
||||
? '200 OK'
|
||||
: '500 Error'}
|
||||
{' - '}
|
||||
{serverlessFunctionTestData.output.duration}ms
|
||||
</StyledOutput>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<CoreEditorHeader
|
||||
leftNodes={[leftNode]}
|
||||
rightNodes={[<LightCopyIconButton copyText={result} />]}
|
||||
/>
|
||||
<CodeEditor
|
||||
value={result}
|
||||
language={serverlessFunctionTestData.language}
|
||||
height={serverlessFunctionTestData.height}
|
||||
options={{ readOnly: true, domReadOnly: true }}
|
||||
withHeader
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const INDEX_FILE_PATH = 'src/index.ts';
|
||||
@ -0,0 +1,51 @@
|
||||
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const useTestServerlessFunction = (
|
||||
serverlessFunctionId: string,
|
||||
callback?: (testResult: object) => void,
|
||||
) => {
|
||||
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
|
||||
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
|
||||
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
|
||||
|
||||
const testServerlessFunction = async () => {
|
||||
const result = await executeOneServerlessFunction({
|
||||
id: serverlessFunctionId,
|
||||
payload: serverlessFunctionTestData.input,
|
||||
version: 'draft',
|
||||
});
|
||||
|
||||
if (isDefined(result?.data?.executeOneServerlessFunction?.data)) {
|
||||
callback?.(result?.data?.executeOneServerlessFunction?.data);
|
||||
}
|
||||
|
||||
setServerlessFunctionTestData((prev) => ({
|
||||
...prev,
|
||||
language: 'json',
|
||||
height: 300,
|
||||
output: {
|
||||
data: result?.data?.executeOneServerlessFunction?.data
|
||||
? JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.data,
|
||||
null,
|
||||
4,
|
||||
)
|
||||
: undefined,
|
||||
duration: result?.data?.executeOneServerlessFunction?.duration,
|
||||
status: result?.data?.executeOneServerlessFunction?.status,
|
||||
error: result?.data?.executeOneServerlessFunction?.error
|
||||
? JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.error,
|
||||
null,
|
||||
4,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return { testServerlessFunction };
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import { InputSchema } from '@/workflow/types/InputSchema';
|
||||
import { getDefaultFunctionInputFromInputSchema } from '@/serverless-functions/utils/getDefaultFunctionInputFromInputSchema';
|
||||
|
||||
describe('getDefaultFunctionInputFromInputSchema', () => {
|
||||
it('should init function input properly', () => {
|
||||
const inputSchema = [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'string',
|
||||
},
|
||||
b: {
|
||||
type: 'number',
|
||||
},
|
||||
c: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
d: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
da: { type: 'string', enum: ['my', 'enum'] },
|
||||
db: { type: 'number' },
|
||||
},
|
||||
},
|
||||
e: { type: 'object' },
|
||||
},
|
||||
},
|
||||
] as InputSchema;
|
||||
const expectedResult = [
|
||||
{
|
||||
a: null,
|
||||
b: null,
|
||||
c: [],
|
||||
d: { da: 'my', db: null },
|
||||
e: {},
|
||||
},
|
||||
];
|
||||
expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual(
|
||||
expectedResult,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
|
||||
|
||||
describe('getFunctionInputFromSourceCode', () => {
|
||||
it('should return empty input if not parameter', () => {
|
||||
const fileContent = 'function testFunction() { return }';
|
||||
const result = getFunctionInputFromSourceCode(fileContent);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
it('should return first input if multiple parameters', () => {
|
||||
const fileContent =
|
||||
'function testFunction(params1: {}, params2: {}) { return }';
|
||||
const result = getFunctionInputFromSourceCode(fileContent);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
it('should return empty input if wrong parameter', () => {
|
||||
const fileContent = 'function testFunction(params: string) { return }';
|
||||
const result = getFunctionInputFromSourceCode(fileContent);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
it('should return input from source code', () => {
|
||||
const fileContent = `
|
||||
function testFunction(
|
||||
params: {
|
||||
param1: string;
|
||||
param2: number;
|
||||
param3: boolean;
|
||||
param4: object;
|
||||
param5: { subParam1: string };
|
||||
param6: "my" | "enum";
|
||||
param7: string[];
|
||||
}
|
||||
): void {
|
||||
return
|
||||
}
|
||||
`;
|
||||
|
||||
const result = getFunctionInputFromSourceCode(fileContent);
|
||||
expect(result).toEqual({
|
||||
param1: null,
|
||||
param2: null,
|
||||
param3: null,
|
||||
param4: {},
|
||||
param5: {
|
||||
subParam1: null,
|
||||
},
|
||||
param6: 'my',
|
||||
param7: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,67 @@
|
||||
import { getFunctionInputSchema } from '@/serverless-functions/utils/getFunctionInputSchema';
|
||||
|
||||
describe('getFunctionInputSchema', () => {
|
||||
it('should analyze a simple function correctly', () => {
|
||||
const fileContent = `
|
||||
function testFunction(param1: string, param2: number): void {
|
||||
return;
|
||||
}
|
||||
`;
|
||||
const result = getFunctionInputSchema(fileContent);
|
||||
|
||||
expect(result).toEqual([{ type: 'string' }, { type: 'number' }]);
|
||||
});
|
||||
|
||||
it('should analyze a arrow function correctly', () => {
|
||||
const fileContent = `
|
||||
export const main = async (
|
||||
param1: string,
|
||||
param2: number,
|
||||
): Promise<object> => {
|
||||
return param1;
|
||||
};
|
||||
`;
|
||||
const result = getFunctionInputSchema(fileContent);
|
||||
|
||||
expect(result).toEqual([{ type: 'string' }, { type: 'number' }]);
|
||||
});
|
||||
|
||||
it('should analyze a complex function correctly', () => {
|
||||
const fileContent = `
|
||||
function testFunction(
|
||||
params: {
|
||||
param1: string;
|
||||
param2: number;
|
||||
param3: boolean;
|
||||
param4: object;
|
||||
param5: { subParam1: string };
|
||||
param6: "my" | "enum";
|
||||
param7: string[];
|
||||
}
|
||||
): void {
|
||||
return
|
||||
}
|
||||
`;
|
||||
const result = getFunctionInputSchema(fileContent);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: { type: 'string' },
|
||||
param2: { type: 'number' },
|
||||
param3: { type: 'boolean' },
|
||||
param4: { type: 'object' },
|
||||
param5: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subParam1: { type: 'string' },
|
||||
},
|
||||
},
|
||||
param6: { type: 'string', enum: ['my', 'enum'] },
|
||||
param7: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
|
||||
|
||||
describe('getFunctionOutputSchema', () => {
|
||||
it('should compute outputSchema properly', () => {
|
||||
const testResult = {
|
||||
a: null,
|
||||
b: 'b',
|
||||
c: { cc: 1 },
|
||||
d: true,
|
||||
e: [1, 2, 3],
|
||||
};
|
||||
const expectedOutputSchema = {
|
||||
a: { isLeaf: true, type: 'unknown', value: null },
|
||||
b: { isLeaf: true, type: 'string', value: 'b' },
|
||||
c: {
|
||||
isLeaf: false,
|
||||
value: { cc: { isLeaf: true, type: 'number', value: 1 } },
|
||||
},
|
||||
d: { isLeaf: true, type: 'boolean', value: true },
|
||||
e: { isLeaf: true, type: 'array', value: [1, 2, 3] },
|
||||
};
|
||||
expect(getFunctionOutputSchema(testResult)).toEqual(expectedOutputSchema);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import { mergeDefaultFunctionInputAndFunctionInput } from '../mergeDefaultFunctionInputAndFunctionInput';
|
||||
|
||||
describe('mergeDefaultFunctionInputAndFunctionInput', () => {
|
||||
it('should merge properly', () => {
|
||||
const newInput = {
|
||||
a: null,
|
||||
b: null,
|
||||
c: { cc: null },
|
||||
d: null,
|
||||
e: { ee: null },
|
||||
};
|
||||
const oldInput = { a: 'a', c: 'c', d: { da: null }, e: { ee: 'ee' } };
|
||||
const expectedResult = {
|
||||
a: 'a',
|
||||
b: null,
|
||||
c: { cc: null },
|
||||
d: null,
|
||||
e: { ee: 'ee' },
|
||||
};
|
||||
expect(
|
||||
mergeDefaultFunctionInputAndFunctionInput({
|
||||
newInput: newInput,
|
||||
oldInput: oldInput,
|
||||
}),
|
||||
).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { InputSchema } from '@/workflow/types/InputSchema';
|
||||
import { FunctionInput } from '@/workflow/types/FunctionInput';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const getDefaultFunctionInputFromInputSchema = (
|
||||
inputSchema: InputSchema,
|
||||
): FunctionInput => {
|
||||
return inputSchema.map((param) => {
|
||||
if (['string', 'number', 'boolean'].includes(param.type)) {
|
||||
return param.enum && param.enum.length > 0 ? param.enum[0] : null;
|
||||
} else if (param.type === 'object') {
|
||||
const result: FunctionInput = {};
|
||||
if (isDefined(param.properties)) {
|
||||
Object.entries(param.properties).forEach(([key, val]) => {
|
||||
result[key] = getDefaultFunctionInputFromInputSchema([val])[0];
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} else if (param.type === 'array' && isDefined(param.items)) {
|
||||
return [];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { getDefaultFunctionInputFromInputSchema } from '@/serverless-functions/utils/getDefaultFunctionInputFromInputSchema';
|
||||
import { getFunctionInputSchema } from '@/serverless-functions/utils/getFunctionInputSchema';
|
||||
import { FunctionInput } from '@/workflow/types/FunctionInput';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const getFunctionInputFromSourceCode = (
|
||||
sourceCode?: string,
|
||||
): FunctionInput => {
|
||||
if (!isDefined(sourceCode)) {
|
||||
throw new Error('Source code is not defined');
|
||||
}
|
||||
|
||||
const functionInputSchema = getFunctionInputSchema(sourceCode);
|
||||
|
||||
const result = getDefaultFunctionInputFromInputSchema(functionInputSchema)[0];
|
||||
|
||||
if (!isObject(result)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -0,0 +1,137 @@
|
||||
import {
|
||||
ArrayTypeNode,
|
||||
ArrowFunction,
|
||||
createSourceFile,
|
||||
FunctionDeclaration,
|
||||
FunctionLikeDeclaration,
|
||||
LiteralTypeNode,
|
||||
PropertySignature,
|
||||
ScriptTarget,
|
||||
StringLiteral,
|
||||
SyntaxKind,
|
||||
TypeNode,
|
||||
Node,
|
||||
UnionTypeNode,
|
||||
VariableStatement,
|
||||
} from 'typescript';
|
||||
import { InputSchema, InputSchemaProperty } from '@/workflow/types/InputSchema';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
const getTypeString = (typeNode: TypeNode): InputSchemaProperty => {
|
||||
switch (typeNode.kind) {
|
||||
case SyntaxKind.NumberKeyword:
|
||||
return { type: 'number' };
|
||||
case SyntaxKind.StringKeyword:
|
||||
return { type: 'string' };
|
||||
case SyntaxKind.BooleanKeyword:
|
||||
return { type: 'boolean' };
|
||||
case SyntaxKind.ArrayType:
|
||||
return {
|
||||
type: 'array',
|
||||
items: getTypeString((typeNode as ArrayTypeNode).elementType),
|
||||
};
|
||||
case SyntaxKind.ObjectKeyword:
|
||||
return { type: 'object' };
|
||||
case SyntaxKind.TypeLiteral: {
|
||||
const properties: InputSchemaProperty['properties'] = {};
|
||||
|
||||
(typeNode as any).members.forEach((member: PropertySignature) => {
|
||||
if (isDefined(member.name) && isDefined(member.type)) {
|
||||
const memberName = (member.name as any).text;
|
||||
|
||||
properties[memberName] = getTypeString(member.type);
|
||||
}
|
||||
});
|
||||
|
||||
return { type: 'object', properties };
|
||||
}
|
||||
case SyntaxKind.UnionType: {
|
||||
const unionNode = typeNode as UnionTypeNode;
|
||||
const enumValues: string[] = [];
|
||||
|
||||
let isEnum = true;
|
||||
|
||||
unionNode.types.forEach((subType) => {
|
||||
if (subType.kind === SyntaxKind.LiteralType) {
|
||||
const literal = (subType as LiteralTypeNode).literal;
|
||||
|
||||
if (literal.kind === SyntaxKind.StringLiteral) {
|
||||
enumValues.push((literal as StringLiteral).text);
|
||||
} else {
|
||||
isEnum = false;
|
||||
}
|
||||
} else {
|
||||
isEnum = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (isEnum) {
|
||||
return { type: 'string', enum: enumValues };
|
||||
}
|
||||
|
||||
return { type: 'unknown' };
|
||||
}
|
||||
default:
|
||||
return { type: 'unknown' };
|
||||
}
|
||||
};
|
||||
|
||||
const computeFunctionParameters = (
|
||||
funcNode: FunctionDeclaration | FunctionLikeDeclaration | ArrowFunction,
|
||||
schema: InputSchema,
|
||||
): InputSchema => {
|
||||
const params = funcNode.parameters;
|
||||
|
||||
return params.reduce((updatedSchema, param) => {
|
||||
const typeNode = param.type;
|
||||
|
||||
if (isDefined(typeNode)) {
|
||||
return [...updatedSchema, getTypeString(typeNode)];
|
||||
} else {
|
||||
return [...updatedSchema, { type: 'unknown' }];
|
||||
}
|
||||
}, schema);
|
||||
};
|
||||
|
||||
const extractFunctions = (node: Node): FunctionLikeDeclaration[] => {
|
||||
if (node.kind === SyntaxKind.FunctionDeclaration) {
|
||||
return [node as FunctionDeclaration];
|
||||
}
|
||||
|
||||
if (node.kind === SyntaxKind.VariableStatement) {
|
||||
const varStatement = node as VariableStatement;
|
||||
return varStatement.declarationList.declarations
|
||||
.filter(
|
||||
(declaration) =>
|
||||
isDefined(declaration.initializer) &&
|
||||
declaration.initializer.kind === SyntaxKind.ArrowFunction,
|
||||
)
|
||||
.map((declaration) => declaration.initializer as ArrowFunction);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getFunctionInputSchema = (fileContent: string): InputSchema => {
|
||||
const sourceFile = createSourceFile(
|
||||
'temp.ts',
|
||||
fileContent,
|
||||
ScriptTarget.ESNext,
|
||||
true,
|
||||
);
|
||||
let schema: InputSchema = [];
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (
|
||||
node.kind === SyntaxKind.FunctionDeclaration ||
|
||||
node.kind === SyntaxKind.VariableStatement
|
||||
) {
|
||||
const functions = extractFunctions(node);
|
||||
functions.forEach((func) => {
|
||||
schema = computeFunctionParameters(func, schema);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return schema;
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { BaseOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
const getValueType = (value: any): InputSchemaPropertyType => {
|
||||
if (!isDefined(value) || value === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return 'string';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return 'number';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return 'boolean';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return 'array';
|
||||
}
|
||||
if (isObject(value)) {
|
||||
return 'object';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const getFunctionOutputSchema = (testResult: object) => {
|
||||
return testResult
|
||||
? Object.entries(testResult).reduce(
|
||||
(acc: BaseOutputSchema, [key, value]) => {
|
||||
if (isObject(value) && !Array.isArray(value)) {
|
||||
acc[key] = {
|
||||
isLeaf: false,
|
||||
value: getFunctionOutputSchema(value),
|
||||
};
|
||||
} else {
|
||||
acc[key] = {
|
||||
isLeaf: true,
|
||||
value,
|
||||
type: getValueType(value),
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
)
|
||||
: {};
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { FunctionInput } from '@/workflow/types/FunctionInput';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
export const mergeDefaultFunctionInputAndFunctionInput = ({
|
||||
newInput,
|
||||
oldInput,
|
||||
}: {
|
||||
newInput: FunctionInput;
|
||||
oldInput: FunctionInput;
|
||||
}): FunctionInput => {
|
||||
const result: FunctionInput = {};
|
||||
|
||||
for (const key of Object.keys(newInput)) {
|
||||
const newValue = newInput[key];
|
||||
const oldValue = oldInput[key];
|
||||
|
||||
if (!(key in oldInput)) {
|
||||
result[key] = newValue;
|
||||
} else if (newValue === null && isObject(oldValue)) {
|
||||
result[key] = null;
|
||||
} else if (isObject(newValue)) {
|
||||
result[key] = mergeDefaultFunctionInputAndFunctionInput({
|
||||
newInput: newValue,
|
||||
oldInput: isObject(oldValue) ? oldValue : {},
|
||||
});
|
||||
} else {
|
||||
result[key] = oldValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
Reference in New Issue
Block a user