diff --git a/packages/workflow/src/TelemetryHelpers.ts b/packages/workflow/src/TelemetryHelpers.ts index 058b76a42627f..e4c5ed30f4350 100644 --- a/packages/workflow/src/TelemetryHelpers.ts +++ b/packages/workflow/src/TelemetryHelpers.ts @@ -1,3 +1,4 @@ +import { getNodeParameters } from './NodeHelpers'; import type { IConnection, INode, @@ -7,7 +8,6 @@ import type { INodesGraphResult, IWorkflowBase, INodeTypes, - INodeType, } from './Interfaces'; import { ApplicationError } from './errors/application.error'; @@ -21,28 +21,6 @@ export function isNumber(value: unknown): value is number { return typeof value === 'number'; } -function getStickyDimensions(note: INode, stickyType: INodeType | undefined) { - const heightProperty = stickyType?.description?.properties.find( - (property) => property.name === 'height', - ); - const widthProperty = stickyType?.description?.properties.find( - (property) => property.name === 'width', - ); - - const defaultHeight = - heightProperty && isNumber(heightProperty?.default) ? heightProperty.default : 0; - const defaultWidth = - widthProperty && isNumber(widthProperty?.default) ? widthProperty.default : 0; - - const height: number = isNumber(note.parameters.height) ? note.parameters.height : defaultHeight; - const width: number = isNumber(note.parameters.width) ? note.parameters.width : defaultWidth; - - return { - height, - width, - }; -} - type XYPosition = [number, number]; function areOverlapping( @@ -112,123 +90,152 @@ export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string { } export function generateNodesGraph( - workflow: IWorkflowBase, + workflow: Partial, nodeTypes: INodeTypes, options?: { sourceInstanceId?: string; nodeIdMap?: { [curr: string]: string }; }, ): INodesGraphResult { - const nodesGraph: INodesGraph = { + const nodeGraph: INodesGraph = { node_types: [], node_connections: [], nodes: {}, notes: {}, is_pinned: Object.keys(workflow.pinData ?? {}).length > 0, }; - const nodeNameAndIndex: INodeNameIndex = {}; + const nameIndices: INodeNameIndex = {}; const webhookNodeNames: string[] = []; - try { - const notes = workflow.nodes.filter((node) => node.type === STICKY_NODE_TYPE); - const otherNodes = workflow.nodes.filter((node) => node.type !== STICKY_NODE_TYPE); - - notes.forEach((stickyNote: INode, index: number) => { - const stickyType = nodeTypes.getByNameAndVersion(STICKY_NODE_TYPE, stickyNote.typeVersion); - const { height, width } = getStickyDimensions(stickyNote, stickyType); - - const topLeft = stickyNote.position; - const bottomRight: [number, number] = [topLeft[0] + width, topLeft[1] + height]; - const overlapping = Boolean( - otherNodes.find((node) => areOverlapping(topLeft, bottomRight, node.position)), - ); - nodesGraph.notes[index] = { - overlapping, - position: topLeft, - height, - width, - }; - }); + const notes = (workflow.nodes ?? []).filter((node) => node.type === STICKY_NODE_TYPE); + const otherNodes = (workflow.nodes ?? []).filter((node) => node.type !== STICKY_NODE_TYPE); + + notes.forEach((stickyNote: INode, index: number) => { + const stickyType = nodeTypes.getByNameAndVersion(STICKY_NODE_TYPE, stickyNote.typeVersion); + if (!stickyType) { + return; + } + + const nodeParameters = + getNodeParameters( + stickyType.description.properties, + stickyNote.parameters, + true, + false, + stickyNote, + ) ?? {}; + + const height: number = typeof nodeParameters.height === 'number' ? nodeParameters.height : 0; + const width: number = typeof nodeParameters.width === 'number' ? nodeParameters.width : 0; + + const topLeft = stickyNote.position; + const bottomRight: [number, number] = [topLeft[0] + width, topLeft[1] + height]; + const overlapping = Boolean( + otherNodes.find((node) => areOverlapping(topLeft, bottomRight, node.position)), + ); + nodeGraph.notes[index] = { + overlapping, + position: topLeft, + height, + width, + }; + }); + + otherNodes.forEach((node: INode, index: number) => { + nodeGraph.node_types.push(node.type); + const nodeItem: INodeGraphItem = { + id: node.id, + type: node.type, + version: node.typeVersion, + position: node.position, + }; - otherNodes.forEach((node: INode, index: number) => { - nodesGraph.node_types.push(node.type); - const nodeItem: INodeGraphItem = { - id: node.id, - type: node.type, - version: node.typeVersion, - position: node.position, - }; - - if (options?.sourceInstanceId) { - nodeItem.src_instance_id = options.sourceInstanceId; - } + if (options?.sourceInstanceId) { + nodeItem.src_instance_id = options.sourceInstanceId; + } - if (node.id && options?.nodeIdMap?.[node.id]) { - nodeItem.src_node_id = options.nodeIdMap[node.id]; - } + if (node.id && options?.nodeIdMap?.[node.id]) { + nodeItem.src_node_id = options.nodeIdMap[node.id]; + } - if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) { - try { - nodeItem.domain = new URL(node.parameters.url as string).hostname; - } catch { - nodeItem.domain = getDomainBase(node.parameters.url as string); - } - } else if (node.type === 'n8n-nodes-base.httpRequest' && [2, 3].includes(node.typeVersion)) { - const { authentication } = node.parameters as { authentication: string }; - - nodeItem.credential_type = { - none: 'none', - genericCredentialType: node.parameters.genericAuthType as string, - predefinedCredentialType: node.parameters.nodeCredentialType as string, - }[authentication]; - - nodeItem.credential_set = node.credentials - ? Object.keys(node.credentials).length > 0 - : false; - - const { url } = node.parameters as { url: string }; - - nodeItem.domain_base = getDomainBase(url); - nodeItem.domain_path = getDomainPath(url); - nodeItem.method = node.parameters.requestMethod as string; - } else if (node.type === 'n8n-nodes-base.webhook') { - webhookNodeNames.push(node.name); - } else { + if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) { + try { + nodeItem.domain = new URL(node.parameters.url as string).hostname; + } catch { + nodeItem.domain = getDomainBase(node.parameters.url as string); + } + } else if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion > 1) { + const { authentication } = node.parameters as { authentication: string }; + + nodeItem.credential_type = { + none: 'none', + genericCredentialType: node.parameters.genericAuthType as string, + predefinedCredentialType: node.parameters.nodeCredentialType as string, + }[authentication]; + + nodeItem.credential_set = node.credentials ? Object.keys(node.credentials).length > 0 : false; + + const { url } = node.parameters as { url: string }; + + nodeItem.domain_base = getDomainBase(url); + nodeItem.domain_path = getDomainPath(url); + nodeItem.method = node.parameters.requestMethod as string; + } else if (node.type === 'n8n-nodes-base.webhook') { + webhookNodeNames.push(node.name); + } else { + try { const nodeType = nodeTypes.getByNameAndVersion(node.type); - - nodeType?.description?.properties?.forEach((property) => { - if ( - property.name === 'operation' || - property.name === 'resource' || - property.name === 'mode' - ) { - nodeItem[property.name] = property.default ? property.default.toString() : undefined; + if (nodeType) { + const nodeParameters = getNodeParameters( + nodeType.description.properties, + node.parameters, + true, + false, + node, + ); + + if (nodeParameters) { + const keys: Array<'operation' | 'resource' | 'mode'> = [ + 'operation', + 'resource', + 'mode', + ]; + keys.forEach((key) => { + if (nodeParameters.hasOwnProperty(key)) { + nodeItem[key] = nodeParameters[key]?.toString(); + } + }); } - }); - - nodeItem.operation = node.parameters.operation?.toString() ?? nodeItem.operation; - nodeItem.resource = node.parameters.resource?.toString() ?? nodeItem.resource; - nodeItem.mode = node.parameters.mode?.toString() ?? nodeItem.mode; + } + } catch (e: unknown) { + if (!(e instanceof Error && e.message.includes('Unrecognized node type'))) { + throw e; + } } - nodesGraph.nodes[`${index}`] = nodeItem; - nodeNameAndIndex[node.name] = index.toString(); - }); + } - const getGraphConnectionItem = (startNode: string, connectionItem: IConnection) => { - return { start: nodeNameAndIndex[startNode], end: nodeNameAndIndex[connectionItem.node] }; - }; + nodeGraph.nodes[index.toString()] = nodeItem; + nameIndices[node.name] = index.toString(); + }); + + const getGraphConnectionItem = (startNode: string, connectionItem: IConnection) => { + return { start: nameIndices[startNode], end: nameIndices[connectionItem.node] }; + }; + + Object.keys(workflow.connections ?? []).forEach((nodeName) => { + const connections = workflow.connections?.[nodeName]; + if (!connections) { + return; + } - Object.keys(workflow.connections).forEach((nodeName) => { - const connections = workflow.connections[nodeName]; - connections.main.forEach((element) => { + Object.keys(connections).forEach((key) => { + connections[key].forEach((element) => { element.forEach((element2) => { - nodesGraph.node_connections.push(getGraphConnectionItem(nodeName, element2)); + nodeGraph.node_connections.push(getGraphConnectionItem(nodeName, element2)); }); }); }); - } catch (e) { - return { nodeGraph: nodesGraph, nameIndices: nodeNameAndIndex, webhookNodeNames }; - } + }); - return { nodeGraph: nodesGraph, nameIndices: nodeNameAndIndex, webhookNodeNames }; + return { nodeGraph, nameIndices, webhookNodeNames }; } diff --git a/packages/workflow/test/Helpers.ts b/packages/workflow/test/Helpers.ts index c076708cfe4ca..37016d4a14334 100644 --- a/packages/workflow/test/Helpers.ts +++ b/packages/workflow/test/Helpers.ts @@ -20,9 +20,6 @@ import type { INodeExecutionData, INodeParameters, INodeType, - INodeTypeData, - INodeTypes, - IVersionedNodeType, IRunExecutionData, ITaskDataConnections, IWorkflowBase, @@ -40,6 +37,7 @@ import * as NodeHelpers from '@/NodeHelpers'; import { deepCopy } from '@/utils'; import { getGlobalState } from '@/GlobalState'; import { ApplicationError } from '@/errors/application.error'; +import { NodeTypes as NodeTypesClass } from './NodeTypes'; export interface INodeTypesObject { [key: string]: INodeType; @@ -528,135 +526,6 @@ export function getExecuteSingleFunctions( })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); } -class NodeTypesClass implements INodeTypes { - nodeTypes: INodeTypeData = { - 'test.set': { - sourcePath: '', - type: { - description: { - displayName: 'Set', - name: 'set', - group: ['input'], - version: 1, - description: 'Sets a value', - defaults: { - name: 'Set', - color: '#0000FF', - }, - inputs: ['main'], - outputs: ['main'], - properties: [ - { - displayName: 'Value1', - name: 'value1', - type: 'string', - default: 'default-value1', - }, - { - displayName: 'Value2', - name: 'value2', - type: 'string', - default: 'default-value2', - }, - ], - }, - }, - }, - 'test.setMulti': { - sourcePath: '', - type: { - description: { - displayName: 'Set Multi', - name: 'setMulti', - group: ['input'], - version: 1, - description: 'Sets multiple values', - defaults: { - name: 'Set Multi', - color: '#0000FF', - }, - inputs: ['main'], - outputs: ['main'], - properties: [ - { - displayName: 'Values', - name: 'values', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - name: 'string', - displayName: 'String', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: 'propertyName', - placeholder: 'Name of the property to write data to.', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - placeholder: 'The string value to write in the property.', - }, - ], - }, - ], - }, - ], - }, - }, - }, - 'test.switch': { - sourcePath: '', - type: { - description: { - displayName: 'Set', - name: 'set', - group: ['input'], - version: 1, - description: 'Switches', - defaults: { - name: 'Switch', - color: '#0000FF', - }, - inputs: ['main'], - outputs: ['main', 'main', 'main', 'main'], - outputNames: ['0', '1', '2', '3'], - properties: [ - { - displayName: 'Value1', - name: 'value1', - type: 'string', - default: 'default-value1', - }, - { - displayName: 'Value2', - name: 'value2', - type: 'string', - default: 'default-value2', - }, - ], - }, - }, - }, - }; - - getByName(nodeType: string): INodeType | IVersionedNodeType { - return this.nodeTypes[nodeType].type; - } - - getByNameAndVersion(nodeType: string, version?: number): INodeType { - return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version); - } -} - let nodeTypesInstance: NodeTypesClass | undefined; export function NodeTypes(): NodeTypesClass { diff --git a/packages/workflow/test/NodeTypes.ts b/packages/workflow/test/NodeTypes.ts new file mode 100644 index 0000000000000..5b39d44dd18c2 --- /dev/null +++ b/packages/workflow/test/NodeTypes.ts @@ -0,0 +1,670 @@ +import { + NodeHelpers, + type INodeType, + type INodeTypeData, + type INodeTypes, + type IVersionedNodeType, + type LoadedClass, +} from '@/index'; + +const stickyNode: LoadedClass = { + type: { + description: { + displayName: 'Sticky Note', + name: 'n8n-nodes-base.stickyNote', + icon: 'fa:sticky-note', + group: ['input'], + version: 1, + description: 'Make your workflow easier to understand', + defaults: { name: 'Sticky Note', color: '#FFD233' }, + inputs: [], + outputs: [], + properties: [ + { + displayName: 'Content', + name: 'content', + type: 'string', + required: true, + default: + "## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)", + }, + { displayName: 'Height', name: 'height', type: 'number', required: true, default: 160 }, + { displayName: 'Width', name: 'width', type: 'number', required: true, default: 240 }, + { displayName: 'Color', name: 'color', type: 'number', required: true, default: 1 }, + ], + }, + }, + sourcePath: '', +}; + +const googleSheetsNode: LoadedClass = { + sourcePath: '', + type: { + nodeVersions: { + '1': { + methods: { + loadOptions: {}, + credentialTest: {}, + }, + description: { + displayName: 'Google Sheets ', + name: 'googleSheets', + icon: 'file:googleSheets.svg', + group: ['input', 'output'], + defaultVersion: 4.2, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Read, update and write data to Google Sheets', + version: [1, 2], + defaults: { + name: 'Google Sheets', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + testedBy: 'googleApiCredentialTest', + }, + { + name: 'googleSheetsOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + properties: [ + { + displayName: + 'New node version available: get the latest version with added features from the nodes panel.', + name: 'oldVersionNotice', + type: 'notice', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Service Account', + value: 'serviceAccount', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'serviceAccount', + displayOptions: { + show: { + '@version': [1], + }, + }, + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'OAuth2 (recommended)', + value: 'oAuth2', + }, + { + name: 'Service Account', + value: 'serviceAccount', + }, + ], + default: 'oAuth2', + displayOptions: { + show: { + '@version': [2], + }, + }, + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Spreadsheet', + value: 'spreadsheet', + }, + { + name: 'Sheet', + value: 'sheet', + }, + ], + default: 'sheet', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['sheet'], + }, + }, + options: [ + { + name: 'Append', + value: 'append', + description: 'Append data to a sheet', + action: 'Append data to a sheet', + }, + { + name: 'Clear', + value: 'clear', + description: 'Clear data from a sheet', + action: 'Clear a sheet', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new sheet', + action: 'Create a sheet', + }, + { + name: 'Create or Update', + value: 'upsert', + description: + 'Create a new record, or update the current one if it already exists (upsert)', + action: 'Create or update a sheet', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete columns and rows from a sheet', + action: 'Delete a sheet', + }, + { + name: 'Lookup', + value: 'lookup', + description: 'Look up a specific column value and return the matching row', + action: 'Look up a column value in a sheet', + }, + { + name: 'Read', + value: 'read', + description: 'Read data from a sheet', + action: 'Read a sheet', + }, + { + name: 'Remove', + value: 'remove', + description: 'Remove a sheet', + action: 'Remove a sheet', + }, + { + name: 'Update', + value: 'update', + description: 'Update rows in a sheet', + action: 'Update a sheet', + }, + ], + default: 'read', + }, + { + displayName: 'Spreadsheet ID', + name: 'sheetId', + type: 'string', + displayOptions: { + show: { + resource: ['sheet'], + }, + }, + default: '', + required: true, + description: + 'The ID of the Google Spreadsheet. Found as part of the sheet URL https://docs.google.com/spreadsheets/d/{ID}/.', + }, + { + displayName: 'Range', + name: 'range', + type: 'string', + displayOptions: { + show: { + resource: ['sheet'], + }, + hide: { + operation: ['create', 'delete', 'remove'], + }, + }, + default: 'A:F', + required: true, + description: + 'The table range to read from or to append data to. See the Google documentation for the details. If it contains multiple sheets it can also be added like this: "MySheet!A:F"', + }, + ], + }, + }, + '4': { + description: { + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + version: [3, 4, 4.1, 4.2], + credentials: [ + { + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + name: 'googleApi', + required: true, + testedBy: 'googleApiCredentialTest', + }, + { + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + name: 'googleSheetsOAuth2Api', + required: true, + }, + ], + defaults: { + name: 'Google Sheets', + }, + defaultVersion: 4.2, + description: 'Read, update and write data to Google Sheets', + displayName: 'Google Sheets', + group: ['input', 'output'], + icon: 'file:googleSheets.svg', + inputs: ['main'], + name: 'googleSheets', + outputs: ['main'], + properties: [ + { + default: 'oAuth2', + displayName: 'Authentication', + name: 'authentication', + options: [ + { + name: 'Service Account', + value: 'serviceAccount', + }, + { + name: 'OAuth2 (recommended)', + value: 'oAuth2', + }, + ], + type: 'options', + }, + { + default: 'sheet', + displayName: 'Resource', + name: 'resource', + noDataExpression: true, + options: [ + { + name: 'Document', + value: 'spreadsheet', + }, + { + name: 'Sheet Within Document', + value: 'sheet', + }, + ], + type: 'options', + }, + { + default: 'read', + displayName: 'Operation', + displayOptions: { + show: { + resource: ['sheet'], + }, + }, + name: 'operation', + noDataExpression: true, + options: [ + { + action: 'Append or update row in sheet', + description: 'Append a new row or update an existing one (upsert)', + name: 'Append or Update Row', + value: 'appendOrUpdate', + }, + { + action: 'Append row in sheet', + description: 'Create a new row in a sheet', + name: 'Append Row', + value: 'append', + }, + { + action: 'Clear sheet', + description: 'Delete all the contents or a part of a sheet', + name: 'Clear', + value: 'clear', + }, + { + action: 'Create sheet', + description: 'Create a new sheet', + name: 'Create', + value: 'create', + }, + { + action: 'Delete sheet', + description: 'Permanently delete a sheet', + name: 'Delete', + value: 'remove', + }, + { + action: 'Delete rows or columns from sheet', + description: 'Delete columns or rows from a sheet', + name: 'Delete Rows or Columns', + value: 'delete', + }, + { + action: 'Get row(s) in sheet', + description: 'Retrieve one or more rows from a sheet', + name: 'Get Row(s)', + value: 'read', + }, + { + action: 'Update row in sheet', + description: 'Update an existing row in a sheet', + name: 'Update Row', + value: 'update', + }, + ], + type: 'options', + }, + { + default: { + mode: 'list', + value: '', + }, + displayName: 'Document', + displayOptions: { + show: { + resource: ['sheet'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchable: true, + searchListMethod: 'spreadSheetsSearch', + }, + }, + { + displayName: 'By URL', + extractValue: { + regex: + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + type: 'regex', + }, + name: 'url', + type: 'string', + validation: [ + { + properties: { + errorMessage: 'Not a valid Google Drive File URL', + regex: + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + type: 'regex', + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit', + validation: [ + { + properties: { + errorMessage: 'Not a valid Google Drive File ID', + regex: '[a-zA-Z0-9\\-_]{2,}', + }, + type: 'regex', + }, + ], + }, + ], + name: 'documentId', + required: true, + type: 'resourceLocator', + }, + { + default: { + mode: 'list', + value: '', + }, + displayName: 'Sheet', + displayOptions: { + show: { + operation: [ + 'append', + 'appendOrUpdate', + 'clear', + 'delete', + 'read', + 'remove', + 'update', + ], + resource: ['sheet'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchable: false, + searchListMethod: 'sheetsSearch', + }, + }, + { + displayName: 'By URL', + extractValue: { + regex: + 'https:\\/\\/docs\\.google\\.com/spreadsheets\\/d\\/[0-9a-zA-Z\\-_]+\\/edit\\#gid=([0-9]+)', + type: 'regex', + }, + name: 'url', + type: 'string', + validation: [ + { + properties: { + errorMessage: 'Not a valid Sheet URL', + regex: + 'https:\\/\\/docs\\.google\\.com/spreadsheets\\/d\\/[0-9a-zA-Z\\-_]+\\/edit\\#gid=([0-9]+)', + }, + type: 'regex', + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + properties: { + errorMessage: 'Not a valid Sheet ID', + regex: '((gid=)?[0-9]{1,})', + }, + type: 'regex', + }, + ], + }, + ], + name: 'sheetName', + required: true, + type: 'resourceLocator', + typeOptions: { + loadOptionsDependsOn: ['documentId.value'], + }, + }, + ], + }, + }, + }, + currentVersion: 4.2, + description: { + displayName: 'Google Sheets', + name: 'googleSheets', + icon: 'file:googleSheets.svg', + group: ['input', 'output'], + defaultVersion: 4.2, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Read, update and write data to Google Sheets', + }, + getNodeType(version: number | undefined) { + return this.nodeVersions[Math.floor(version ?? this.currentVersion)]; + }, + }, +}; + +export class NodeTypes implements INodeTypes { + nodeTypes: INodeTypeData = { + 'n8n-nodes-base.stickyNote': stickyNode, + 'test.googleSheets': googleSheetsNode, + 'test.set': { + sourcePath: '', + type: { + description: { + displayName: 'Set', + name: 'set', + group: ['input'], + version: 1, + description: 'Sets a value', + defaults: { + name: 'Set', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Value1', + name: 'value1', + type: 'string', + default: 'default-value1', + }, + { + displayName: 'Value2', + name: 'value2', + type: 'string', + default: 'default-value2', + }, + ], + }, + }, + }, + 'test.setMulti': { + sourcePath: '', + type: { + description: { + displayName: 'Set Multi', + name: 'setMulti', + group: ['input'], + version: 1, + description: 'Sets multiple values', + defaults: { + name: 'Set Multi', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Values', + name: 'values', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'string', + displayName: 'String', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'propertyName', + placeholder: 'Name of the property to write data to.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'The string value to write in the property.', + }, + ], + }, + ], + }, + ], + }, + }, + }, + 'test.switch': { + sourcePath: '', + type: { + description: { + displayName: 'Set', + name: 'set', + group: ['input'], + version: 1, + description: 'Switches', + defaults: { + name: 'Switch', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main', 'main', 'main', 'main'], + outputNames: ['0', '1', '2', '3'], + properties: [ + { + displayName: 'Value1', + name: 'value1', + type: 'string', + default: 'default-value1', + }, + { + displayName: 'Value2', + name: 'value2', + type: 'string', + default: 'default-value2', + }, + ], + }, + }, + }, + }; + + getByName(nodeType: string): INodeType | IVersionedNodeType { + return this.nodeTypes[nodeType]?.type; + } + + getByNameAndVersion(nodeType: string, version?: number): INodeType { + if (this.nodeTypes[nodeType]?.type) { + return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType]?.type, version); + } + } +} diff --git a/packages/workflow/test/TelemetryHelpers.test.ts b/packages/workflow/test/TelemetryHelpers.test.ts index 763d528d98186..97015194cac31 100644 --- a/packages/workflow/test/TelemetryHelpers.test.ts +++ b/packages/workflow/test/TelemetryHelpers.test.ts @@ -1,5 +1,12 @@ import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid'; -import { ANONYMIZATION_CHARACTER as CHAR, getDomainBase, getDomainPath } from '@/TelemetryHelpers'; +import { + ANONYMIZATION_CHARACTER as CHAR, + generateNodesGraph, + getDomainBase, + getDomainPath, +} from '@/TelemetryHelpers'; +import type { IWorkflowBase } from '@/index'; +import { nodeTypes } from './ExpressionExtensions/Helpers'; describe('getDomainBase should return protocol plus domain', () => { test('in valid URLs', () => { @@ -67,6 +74,697 @@ describe('getDomainPath should return pathname, excluding query string', () => { }); }); +describe('generateNodesGraph', () => { + test('should return node graph when node type is unknown', () => { + const workflow: IWorkflowBase = { + createdAt: new Date('2024-01-05T13:49:14.244Z'), + updatedAt: new Date('2024-01-05T15:44:31.000Z'), + id: 'NfV4GV9aQTifSLc2', + name: 'My workflow 26', + active: false, + nodes: [ + { + parameters: {}, + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + name: 'When clicking "Execute Workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [420, 420], + }, + { + parameters: { + documentId: { __rl: true, mode: 'list', value: '' }, + sheetName: { __rl: true, mode: 'list', value: '' }, + }, + id: '266128b9-e5db-4c26-9555-185d48946afb', + name: 'Google Sheets', + type: 'test.unknown', + typeVersion: 4.2, + position: [640, 420], + }, + ], + connections: { + 'When clicking "Execute Workflow"': { + main: [[{ node: 'Google Sheets', type: 'main', index: 0 }]], + }, + }, + settings: { executionOrder: 'v1' }, + pinData: {}, + versionId: '70b92d94-0e9a-4b41-9976-a654df420af5', + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.manualTrigger', 'test.unknown'], + node_connections: [{ start: '0', end: '1' }], + nodes: { + '0': { + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + type: 'n8n-nodes-base.manualTrigger', + version: 1, + position: [420, 420], + }, + '1': { + id: '266128b9-e5db-4c26-9555-185d48946afb', + type: 'test.unknown', + version: 4.2, + position: [640, 420], + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' }, + webhookNodeNames: [], + }); + }); + + test('should return node graph when workflow is empty', () => { + const workflow: IWorkflowBase = { + createdAt: new Date('2024-01-05T13:49:14.244Z'), + updatedAt: new Date('2024-01-05T15:44:31.000Z'), + id: 'NfV4GV9aQTifSLc2', + name: 'My workflow 26', + active: false, + nodes: [], + connections: {}, + settings: { executionOrder: 'v1' }, + pinData: {}, + versionId: '70b92d94-0e9a-4b41-9976-a654df420af5', + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: [], + node_connections: [], + nodes: {}, + notes: {}, + is_pinned: false, + }, + nameIndices: {}, + webhookNodeNames: [], + }); + }); + + test('should return node graph when workflow keys are not set', () => { + const workflow: Partial = {}; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: [], + node_connections: [], + nodes: {}, + notes: {}, + is_pinned: false, + }, + nameIndices: {}, + webhookNodeNames: [], + }); + }); + + test('should return node graph when node has multiple operation fields with different display options', () => { + const workflow: IWorkflowBase = { + createdAt: new Date('2024-01-05T13:49:14.244Z'), + updatedAt: new Date('2024-01-05T15:44:31.000Z'), + id: 'NfV4GV9aQTifSLc2', + name: 'My workflow 26', + active: false, + nodes: [ + { + parameters: {}, + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + name: 'When clicking "Execute Workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [420, 420], + }, + { + parameters: { + documentId: { __rl: true, mode: 'list', value: '' }, + sheetName: { __rl: true, mode: 'list', value: '' }, + }, + id: '266128b9-e5db-4c26-9555-185d48946afb', + name: 'Google Sheets', + type: 'test.googleSheets', + typeVersion: 4.2, + position: [640, 420], + }, + ], + connections: { + 'When clicking "Execute Workflow"': { + main: [[{ node: 'Google Sheets', type: 'main', index: 0 }]], + }, + }, + settings: { executionOrder: 'v1' }, + pinData: {}, + versionId: '70b92d94-0e9a-4b41-9976-a654df420af5', + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'], + node_connections: [{ start: '0', end: '1' }], + nodes: { + '0': { + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + type: 'n8n-nodes-base.manualTrigger', + version: 1, + position: [420, 420], + }, + '1': { + id: '266128b9-e5db-4c26-9555-185d48946afb', + type: 'test.googleSheets', + version: 4.2, + position: [640, 420], + operation: 'read', + resource: 'sheet', + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' }, + webhookNodeNames: [], + }); + }); + + test('should return node graph with stickies of default size', () => { + const workflow: IWorkflowBase = { + createdAt: new Date('2024-01-05T13:49:14.244Z'), + updatedAt: new Date('2024-01-05T15:44:31.000Z'), + id: 'NfV4GV9aQTifSLc2', + name: 'My workflow 26', + active: false, + nodes: [ + { + parameters: {}, + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + name: 'When clicking "Execute Workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [420, 420], + }, + { + parameters: { + documentId: { __rl: true, mode: 'list', value: '' }, + sheetName: { __rl: true, mode: 'list', value: '' }, + }, + id: '266128b9-e5db-4c26-9555-185d48946afb', + name: 'Google Sheets', + type: 'test.googleSheets', + typeVersion: 4.2, + position: [640, 420], + }, + { + parameters: { + content: + "test\n\n## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)", + }, + id: '03e85c3e-4303-4f93-8d62-e05d457e8f70', + name: 'Sticky Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [240, 140], + }, + ], + connections: { + 'When clicking "Execute Workflow"': { + main: [[{ node: 'Google Sheets', type: 'main', index: 0 }]], + }, + }, + settings: { executionOrder: 'v1' }, + pinData: {}, + versionId: '70b92d94-0e9a-4b41-9976-a654df420af5', + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'], + node_connections: [{ start: '0', end: '1' }], + nodes: { + '0': { + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + type: 'n8n-nodes-base.manualTrigger', + version: 1, + position: [420, 420], + }, + '1': { + id: '266128b9-e5db-4c26-9555-185d48946afb', + type: 'test.googleSheets', + version: 4.2, + position: [640, 420], + operation: 'read', + resource: 'sheet', + }, + }, + notes: { '0': { overlapping: false, position: [240, 140], height: 160, width: 240 } }, + is_pinned: false, + }, + nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' }, + webhookNodeNames: [], + }); + }); + + test('should return node graph with stickies indicating overlap', () => { + const workflow: IWorkflowBase = { + createdAt: new Date('2024-01-05T13:49:14.244Z'), + updatedAt: new Date('2024-01-05T15:44:31.000Z'), + id: 'NfV4GV9aQTifSLc2', + name: 'My workflow 26', + active: false, + nodes: [ + { + parameters: {}, + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + name: 'When clicking "Execute Workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [420, 420], + }, + { + parameters: { + documentId: { __rl: true, mode: 'list', value: '' }, + sheetName: { __rl: true, mode: 'list', value: '' }, + }, + id: '266128b9-e5db-4c26-9555-185d48946afb', + name: 'Google Sheets', + type: 'test.googleSheets', + typeVersion: 4.2, + position: [640, 420], + }, + { + parameters: { + content: + "test\n\n## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)", + height: 488, + width: 645, + }, + id: '03e85c3e-4303-4f93-8d62-e05d457e8f70', + name: 'Sticky Note', + type: 'n8n-nodes-base.stickyNote', + typeVersion: 1, + position: [240, 140], + }, + ], + connections: { + 'When clicking "Execute Workflow"': { + main: [[{ node: 'Google Sheets', type: 'main', index: 0 }]], + }, + }, + settings: { executionOrder: 'v1' }, + pinData: {}, + versionId: '70b92d94-0e9a-4b41-9976-a654df420af5', + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'], + node_connections: [{ start: '0', end: '1' }], + nodes: { + '0': { + id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5', + type: 'n8n-nodes-base.manualTrigger', + version: 1, + position: [420, 420], + }, + '1': { + id: '266128b9-e5db-4c26-9555-185d48946afb', + type: 'test.googleSheets', + version: 4.2, + position: [640, 420], + operation: 'read', + resource: 'sheet', + }, + }, + notes: { '0': { overlapping: true, position: [240, 140], height: 488, width: 645 } }, + is_pinned: false, + }, + nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' }, + webhookNodeNames: [], + }); + }); + + test('should return node graph indicating pinned data', () => { + const workflow: Partial = { + nodes: [ + { + parameters: {}, + id: 'e59d3ad9-3448-4899-9f47-d2922c8727ce', + name: 'When clicking "Execute Workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [460, 460], + }, + ], + connections: {}, + pinData: { + 'When clicking "Execute Workflow"': [], + }, + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nameIndices: { + 'When clicking "Execute Workflow"': '0', + }, + nodeGraph: { + is_pinned: true, + node_connections: [], + node_types: ['n8n-nodes-base.manualTrigger'], + nodes: { + '0': { + id: 'e59d3ad9-3448-4899-9f47-d2922c8727ce', + position: [460, 460], + type: 'n8n-nodes-base.manualTrigger', + version: 1, + }, + }, + notes: {}, + }, + webhookNodeNames: [], + }); + }); + + test('should return graph with webhook node', () => { + const workflow: Partial = { + nodes: [ + { + parameters: { + path: 'bf4c0699-cff8-4440-8964-8e97fda8b4f8', + options: {}, + }, + id: '5e49e129-2c59-4650-95ea-14d4b94db1f3', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1.1, + position: [520, 380], + webhookId: 'bf4c0699-cff8-4440-8964-8e97fda8b4f8', + }, + ], + connections: {}, + pinData: {}, + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.webhook'], + node_connections: [], + nodes: { + '0': { + id: '5e49e129-2c59-4650-95ea-14d4b94db1f3', + type: 'n8n-nodes-base.webhook', + version: 1.1, + position: [520, 380], + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { Webhook: '0' }, + webhookNodeNames: ['Webhook'], + }); + }); + + test('should return graph with http v4 node with generic auth', () => { + const workflow: Partial = { + nodes: [ + { + parameters: { + url: 'google.com/path/test', + authentication: 'genericCredentialType', + genericAuthType: 'httpBasicAuth', + options: {}, + }, + id: '04d6e44f-09c1-454d-9225-60aeed7f022c', + name: 'HTTP Request V4 with generic auth', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.1, + position: [780, 120], + credentials: { + httpBasicAuth: { + id: 'yuuJAO2Ang5B64wd', + name: 'Unnamed credential', + }, + }, + }, + ], + connections: {}, + pinData: {}, + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.httpRequest'], + node_connections: [], + nodes: { + '0': { + id: '04d6e44f-09c1-454d-9225-60aeed7f022c', + type: 'n8n-nodes-base.httpRequest', + version: 4.1, + position: [780, 120], + credential_type: 'httpBasicAuth', + credential_set: true, + domain_base: 'google.com', + domain_path: '/path/test', + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { 'HTTP Request V4 with generic auth': '0' }, + webhookNodeNames: [], + }); + }); + + test('should return graph with HTTP V4 with predefined cred', () => { + const workflow: Partial = { + nodes: [ + { + parameters: { + url: 'google.com/path/test', + authentication: 'predefinedCredentialType', + nodeCredentialType: 'activeCampaignApi', + options: {}, + }, + id: 'dcc4a9e1-c2c5-4d7e-aec0-2a23adabbb77', + name: 'HTTP Request V4 with predefined cred', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.1, + position: [320, 220], + credentials: { + httpBasicAuth: { + id: 'yuuJAO2Ang5B64wd', + name: 'Unnamed credential', + }, + activeCampaignApi: { + id: 'SFCbnfgRBuSzRu6N', + name: 'ActiveCampaign account', + }, + }, + }, + ], + connections: {}, + pinData: {}, + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.httpRequest'], + node_connections: [], + nodes: { + '0': { + id: 'dcc4a9e1-c2c5-4d7e-aec0-2a23adabbb77', + type: 'n8n-nodes-base.httpRequest', + version: 4.1, + position: [320, 220], + credential_type: 'activeCampaignApi', + credential_set: true, + domain_base: 'google.com', + domain_path: '/path/test', + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { 'HTTP Request V4 with predefined cred': '0' }, + webhookNodeNames: [], + }); + }); + + test('should return graph with http v1 node', () => { + const workflow: Partial = { + nodes: [ + { + parameters: { + url: 'https://google.com', + options: {}, + }, + id: 'b468b603-3e59-4515-b555-90cfebd64d47', + name: 'HTTP Request V1', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [320, 460], + }, + ], + connections: {}, + pinData: {}, + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.httpRequest'], + node_connections: [], + nodes: { + '0': { + id: 'b468b603-3e59-4515-b555-90cfebd64d47', + type: 'n8n-nodes-base.httpRequest', + version: 1, + position: [320, 460], + domain: 'google.com', + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { 'HTTP Request V1': '0' }, + webhookNodeNames: [], + }); + }); + + test('should return graph with http v4 node with no parameters and no credentials', () => { + const workflow: Partial = { + nodes: [ + { + parameters: { + options: {}, + }, + id: 'd002e66f-deba-455c-9f8b-65239db453c3', + name: 'HTTP Request v4 with defaults', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.1, + position: [600, 240], + }, + ], + connections: {}, + pinData: {}, + }; + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: ['n8n-nodes-base.httpRequest'], + node_connections: [], + nodes: { + '0': { + id: 'd002e66f-deba-455c-9f8b-65239db453c3', + type: 'n8n-nodes-base.httpRequest', + version: 4.1, + position: [600, 240], + credential_set: false, + domain_base: '', + domain_path: '', + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { 'HTTP Request v4 with defaults': '0' }, + webhookNodeNames: [], + }); + }); + + test('should support custom connections like in AI nodes', () => { + const workflow: Partial = { + nodes: [ + { + parameters: {}, + id: 'fe69383c-e418-4f98-9c0e-924deafa7f93', + name: 'When clicking "Test Workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [540, 220], + }, + { + parameters: {}, + id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a', + name: 'Chain', + type: '@n8n/n8n-nodes-langchain.chainLlm', + typeVersion: 1, + position: [760, 320], + }, + { + parameters: { + options: {}, + }, + id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12', + name: 'Model', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1, + position: [780, 500], + }, + ], + connections: { + 'When clicking "Test Workflow"': { + main: [ + [ + { + node: 'Chain', + type: 'main', + index: 0, + }, + ], + ], + }, + Model: { + ai_languageModel: [ + [ + { + node: 'Chain', + type: 'ai_languageModel', + index: 0, + }, + ], + ], + }, + }, + }; + + expect(generateNodesGraph(workflow, nodeTypes)).toEqual({ + nodeGraph: { + node_types: [ + 'n8n-nodes-base.manualTrigger', + '@n8n/n8n-nodes-langchain.chainLlm', + '@n8n/n8n-nodes-langchain.lmChatOpenAi', + ], + node_connections: [ + { + start: '0', + end: '1', + }, + { + start: '2', + end: '1', + }, + ], + nodes: { + '0': { + id: 'fe69383c-e418-4f98-9c0e-924deafa7f93', + type: 'n8n-nodes-base.manualTrigger', + version: 1, + position: [540, 220], + }, + '1': { + id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a', + type: '@n8n/n8n-nodes-langchain.chainLlm', + version: 1, + position: [760, 320], + }, + '2': { + id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12', + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + version: 1, + position: [780, 500], + }, + }, + notes: {}, + is_pinned: false, + }, + nameIndices: { + 'When clicking "Test Workflow"': '0', + Chain: '1', + Model: '2', + }, + webhookNodeNames: [], + }); + }); +}); + function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) { const firstId = idMaker(); const secondId = idMaker();